mirror of
https://github.com/aljazceru/mutiny-web.git
synced 2025-12-21 16:24:22 +01:00
Gifting
This commit is contained in:
committed by
Tony Giorgio
parent
ccb7f3737e
commit
932c0a1f2b
3
src/assets/icons/gift.svg
Normal file
3
src/assets/icons/gift.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.75 20.9531C3.75 21.368 4.08516 21.7031 4.5 21.7031H11.2031V12.8906H3.75V20.9531ZM12.7969 21.7031H19.5C19.9148 21.7031 20.25 21.368 20.25 20.9531V12.8906H12.7969V21.7031ZM20.625 7.26562H17.1656C17.4844 6.76406 17.6719 6.16875 17.6719 5.53125C17.6719 3.74766 16.2211 2.29688 14.4375 2.29688C13.4672 2.29688 12.593 2.72812 12 3.40781C11.407 2.72812 10.5328 2.29688 9.5625 2.29688C7.77891 2.29688 6.32812 3.74766 6.32812 5.53125C6.32812 6.16875 6.51328 6.76406 6.83438 7.26562H3.375C2.96016 7.26562 2.625 7.60078 2.625 8.01562V11.2969H11.2031V7.26562H12.7969V11.2969H21.375V8.01562C21.375 7.60078 21.0398 7.26562 20.625 7.26562ZM11.2031 7.17188H9.5625C8.65781 7.17188 7.92188 6.43594 7.92188 5.53125C7.92188 4.62656 8.65781 3.89062 9.5625 3.89062C10.4672 3.89062 11.2031 4.62656 11.2031 5.53125V7.17188ZM14.4375 7.17188H12.7969V5.53125C12.7969 4.62656 13.5328 3.89062 14.4375 3.89062C15.3422 3.89062 16.0781 4.62656 16.0781 5.53125C16.0781 6.43594 15.3422 7.17188 14.4375 7.17188Z" fill="#FA0050"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
BIN
src/assets/treasure-closed.png
Normal file
BIN
src/assets/treasure-closed.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
BIN
src/assets/treasure.gif
Normal file
BIN
src/assets/treasure.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 254 KiB |
@@ -564,7 +564,8 @@ export const AmountEditable: ParentComponent<{
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
function handleClose(e: SubmitEvent | MouseEvent | KeyboardEvent) {
|
||||
e.preventDefault();
|
||||
props.setAmountSats(BigInt(props.initialAmountSats));
|
||||
setIsOpen(false);
|
||||
setLocalSats(props.initialAmountSats);
|
||||
@@ -576,6 +577,7 @@ export const AmountEditable: ParentComponent<{
|
||||
)
|
||||
);
|
||||
props.exitRoute && navigate(props.exitRoute);
|
||||
return false;
|
||||
}
|
||||
|
||||
// What we're all here for in the first place: returning a value
|
||||
@@ -586,6 +588,7 @@ export const AmountEditable: ParentComponent<{
|
||||
satsToFiat(state.price, Number(localSats()) || 0, state.fiat)
|
||||
);
|
||||
setIsOpen(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
function handleSatsInput(e: InputEvent) {
|
||||
@@ -706,6 +709,7 @@ export const AmountEditable: ParentComponent<{
|
||||
<div class="flex w-full justify-end">
|
||||
<button
|
||||
onClick={handleClose}
|
||||
type="button"
|
||||
class="h-8 w-8 rounded-lg hover:bg-white/10 active:bg-m-blue"
|
||||
>
|
||||
<img src={close} alt="Close" />
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { Show } from "solid-js";
|
||||
import { QRCodeSVG } from "solid-qr-code";
|
||||
|
||||
import { useI18n } from "~/i18n/context";
|
||||
import { useCopy } from "~/utils";
|
||||
|
||||
export function CopyableQR(props: { value: string }) {
|
||||
const i18n = useI18n();
|
||||
const [copy, copied] = useCopy({ copiedTimeout: 1000 });
|
||||
return (
|
||||
<div
|
||||
id="qr"
|
||||
class="relative w-full rounded-xl bg-white"
|
||||
onClick={() => copy(props.value)}
|
||||
>
|
||||
<Show when={copied()}>
|
||||
<div class="absolute z-50 flex h-full w-full flex-col items-center justify-center rounded-xl bg-neutral-900/60 transition-all">
|
||||
<p class="text-xl font-bold">{i18n.t("common.copied")}</p>
|
||||
</div>
|
||||
</Show>
|
||||
<QRCodeSVG
|
||||
value={props.value}
|
||||
class="h-full max-h-[400px] w-full p-8"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -65,7 +65,7 @@ export const OVERLAY = "fixed inset-0 z-50 bg-black/50 backdrop-blur-sm";
|
||||
export const DIALOG_POSITIONER =
|
||||
"fixed inset-0 z-50 flex items-center justify-center";
|
||||
export const DIALOG_CONTENT =
|
||||
"max-w-[500px] w-[90vw] max-h-[100dvh] overflow-y-scroll disable-scrollbars mx-4 p-4 bg-neutral-800/80 backdrop-blur-md shadow-xl rounded-xl border border-white/10";
|
||||
"max-w-[500px] w-[90vw] max-h-[100dvh] overflow-y-scroll disable-scrollbars mx-4 p-4 bg-m-grey-800/75 backdrop-blur-md shadow-xl rounded-xl border border-white/10";
|
||||
|
||||
function LightningHeader(props: {
|
||||
info: MutinyInvoice;
|
||||
|
||||
22
src/components/GiftLink.tsx
Normal file
22
src/components/GiftLink.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { A, useLocation } from "solid-start";
|
||||
|
||||
import gift from "~/assets/icons/gift.svg";
|
||||
import { useI18n } from "~/i18n/context";
|
||||
|
||||
export function GiftLink() {
|
||||
const i18n = useI18n();
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<A
|
||||
class="flex items-center gap-2 font-semibold text-m-red no-underline"
|
||||
href="/settings/gift"
|
||||
state={{
|
||||
previous: location.pathname
|
||||
}}
|
||||
>
|
||||
{i18n.t("settings.gift.give_sats_link")}
|
||||
<img src={gift} class="h-5 w-5" alt="Gift" />
|
||||
</A>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import { useI18n } from "~/i18n/context";
|
||||
import { ReceiveFlavor } from "~/routes/Receive";
|
||||
import { useCopy } from "~/utils";
|
||||
|
||||
function KindIndicator(props: { kind: ReceiveFlavor }) {
|
||||
function KindIndicator(props: { kind: ReceiveFlavor | "gift" }) {
|
||||
const i18n = useI18n();
|
||||
return (
|
||||
<div class="flex flex-col items-end text-black">
|
||||
@@ -29,6 +29,13 @@ function KindIndicator(props: { kind: ReceiveFlavor }) {
|
||||
<img src={boltBlack} alt="bolt" />
|
||||
</Match>
|
||||
|
||||
<Match when={props.kind === "gift"}>
|
||||
<h3 class="font-semibold">
|
||||
{i18n.t("receive.integrated_qr.gift")}
|
||||
</h3>
|
||||
<img src={boltBlack} alt="bolt" />
|
||||
</Match>
|
||||
|
||||
<Match when={props.kind === "unified"}>
|
||||
<h3 class="font-semibold">
|
||||
{i18n.t("receive.integrated_qr.unified")}
|
||||
@@ -62,7 +69,7 @@ async function share(receiveString: string) {
|
||||
export function IntegratedQr(props: {
|
||||
value: string;
|
||||
amountSats: string;
|
||||
kind: ReceiveFlavor;
|
||||
kind: ReceiveFlavor | "gift";
|
||||
}) {
|
||||
const i18n = useI18n();
|
||||
const [copy, copied] = useCopy({ copiedTimeout: 1000 });
|
||||
|
||||
29
src/components/Logo.tsx
Normal file
29
src/components/Logo.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Match, Switch } from "solid-js";
|
||||
|
||||
import pixelLogo from "~/assets/mutiny-pixel-logo.png";
|
||||
import plusLogo from "~/assets/mutiny-plus-logo.png";
|
||||
import { useMegaStore } from "~/state/megaStore";
|
||||
|
||||
export function Logo() {
|
||||
const [state, _actions] = useMegaStore();
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={state.mutiny_plus}>
|
||||
<img
|
||||
id="mutiny-logo"
|
||||
src={plusLogo}
|
||||
class="h-[25px] w-[86px]"
|
||||
alt="Mutiny Plus logo"
|
||||
/>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<img
|
||||
id="mutiny-logo"
|
||||
src={pixelLogo}
|
||||
class="h-[25px] w-[75px]"
|
||||
alt="Mutiny logo"
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
@@ -104,7 +104,7 @@ export function NWCBudgetEditor(props: {
|
||||
<TinyText>
|
||||
{i18n.t("settings.connections.careful")}
|
||||
</TinyText>
|
||||
<KeyValue key={"Budget"}>
|
||||
<KeyValue key={i18n.t("settings.connections.budget")}>
|
||||
<Field name="budget_amount">
|
||||
{(field, _fieldProps) => (
|
||||
<div class="flex flex-col items-end gap-2">
|
||||
@@ -129,7 +129,9 @@ export function NWCBudgetEditor(props: {
|
||||
)}
|
||||
</Field>
|
||||
</KeyValue>
|
||||
<KeyValue key={"Resets every"}>
|
||||
<KeyValue
|
||||
key={i18n.t("settings.connections.resets_every")}
|
||||
>
|
||||
<Field name="interval">
|
||||
{(field, fieldProps) => (
|
||||
<select
|
||||
|
||||
@@ -4,6 +4,7 @@ import airplane from "~/assets/icons/airplane.svg";
|
||||
import receive from "~/assets/icons/big-receive.svg";
|
||||
import mutiny_m from "~/assets/icons/m.svg";
|
||||
import redshift from "~/assets/icons/rs.svg";
|
||||
import scan from "~/assets/icons/scan.svg";
|
||||
import settings from "~/assets/icons/settings.svg";
|
||||
import userClock from "~/assets/icons/user-clock.svg";
|
||||
|
||||
@@ -67,6 +68,12 @@ export function NavBar(props: { activeTab: ActiveTab }) {
|
||||
active={props.activeTab === "activity"}
|
||||
alt="activity"
|
||||
/>
|
||||
<NavBarItem
|
||||
href="/scanner"
|
||||
icon={scan}
|
||||
active={false}
|
||||
alt="scan"
|
||||
/>
|
||||
<NavBarItem
|
||||
href="/redshift"
|
||||
icon={redshift}
|
||||
|
||||
@@ -12,7 +12,6 @@ export * from "./ChooseCurrency";
|
||||
export * from "./ContactEditor";
|
||||
export * from "./ContactForm";
|
||||
export * from "./ContactViewer";
|
||||
export * from "./CopyableQR";
|
||||
export * from "./DecryptDialog";
|
||||
export * from "./DeleteEverything";
|
||||
export * from "./DetailsModal";
|
||||
@@ -25,6 +24,7 @@ export * from "./IntegratedQR";
|
||||
export * from "./JsonModal";
|
||||
export * from "./KitchenSink";
|
||||
export * from "./LoadingIndicator";
|
||||
export * from "./Logo";
|
||||
export * from "./Logs";
|
||||
export * from "./MoreInfoModal";
|
||||
export * from "./NavBar";
|
||||
@@ -42,3 +42,4 @@ export * from "./TagEditor";
|
||||
export * from "./Toaster";
|
||||
export * from "./NostrActivity";
|
||||
export * from "./SyncContactsForm";
|
||||
export * from "./GiftLink";
|
||||
|
||||
@@ -14,7 +14,10 @@ export function BackPop() {
|
||||
|
||||
const state = location.state as StateWithPrevious;
|
||||
|
||||
const backPath = () => (state?.previous ? state?.previous : "/");
|
||||
// If there's no previous state want to just go back one level, basically ../
|
||||
const newBackPath = location.pathname.split("/").slice(0, -1).join("/");
|
||||
|
||||
const backPath = () => (state?.previous ? state?.previous : newBackPath);
|
||||
|
||||
return (
|
||||
<BackButton
|
||||
|
||||
@@ -89,7 +89,8 @@ export default {
|
||||
integrated_qr: {
|
||||
onchain: "On-chain",
|
||||
lightning: "Lightning",
|
||||
unified: "Unified"
|
||||
unified: "Unified",
|
||||
gift: "Lightning Gift"
|
||||
},
|
||||
remember_choice: "Remember my choice next time"
|
||||
},
|
||||
@@ -269,6 +270,7 @@ export default {
|
||||
error_budget_zero: "Budget must be greater than zero",
|
||||
add_connection: "Add Connection",
|
||||
manage_connections: "Manage Connections",
|
||||
manage_gifts: "Manage Gifts",
|
||||
delete_connection: "Delete",
|
||||
new_connection: "New Connection",
|
||||
edit_connection: "Edit Connection",
|
||||
@@ -292,7 +294,9 @@ export default {
|
||||
"Be careful where you share this connection! Requests within budget will paid automatically.",
|
||||
spent: "Spent",
|
||||
remaining: "Remaining",
|
||||
confirm_delete: "Are you sure you want to delete this connection?"
|
||||
confirm_delete: "Are you sure you want to delete this connection?",
|
||||
budget: "Budget",
|
||||
resets_every: "Resets every"
|
||||
},
|
||||
emergency_kit: {
|
||||
title: "Emergency Kit",
|
||||
@@ -448,6 +452,41 @@ export default {
|
||||
npub_label: "Nostr npub",
|
||||
npub_required: "Npub can't be blank",
|
||||
sync: "Sync"
|
||||
},
|
||||
gift: {
|
||||
give_sats_link: "Give sats as a gift",
|
||||
title: "Gifting",
|
||||
receive_too_small:
|
||||
"Your first receive needs to be {{amount}} SATS or greater.",
|
||||
setup_fee_lightning:
|
||||
"A lightning setup fee will be charged to receive this gift.",
|
||||
already_claimed: "This gift has already been claimed",
|
||||
sender_is_poor:
|
||||
"The sender doesn't have enough balance to pay this gift.",
|
||||
sender_generic_error: "Sender sent error: {{error}}",
|
||||
receive_header: "You've been gifted some sats!",
|
||||
receive_description:
|
||||
"You must be pretty special. To claim your money just hit the big button. Funds will be added to your balance the next time your gifter is online.",
|
||||
receive_claimed:
|
||||
"Gift claimed! You should see the gift hit your balance shortly.",
|
||||
receive_cta: "Claim Gift",
|
||||
send_header: "Create Gift",
|
||||
send_explainer:
|
||||
"Give the gift of sats. Create a Mutiny gift URL that can be claimed by anyone with a web browser.",
|
||||
send_name_required: "This is for your records",
|
||||
send_name_label: "Recepient Name",
|
||||
send_header_claimed: "Gift Received!",
|
||||
send_claimed: "Your gift has been claimed. Thanks for sharing.",
|
||||
send_sharable_header: "Sharable URL",
|
||||
send_instructions:
|
||||
"Copy this gift URL to your recipient, or ask them to scan this QR code with their wallet.",
|
||||
send_another: "Create Another",
|
||||
send_small_warning:
|
||||
"A brand new Mutiny user won't be able to redeem fewer than 50k sats.",
|
||||
send_cta: "Create a gift",
|
||||
send_delete_button: "Delete Gift",
|
||||
send_delete_confirm:
|
||||
"Are you sure you want to delete this gift? Is this your rugpull moment?"
|
||||
}
|
||||
},
|
||||
swap: {
|
||||
|
||||
226
src/routes/Gift.tsx
Normal file
226
src/routes/Gift.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import { createResource, createSignal, Match, Show, Switch } from "solid-js";
|
||||
import { useSearchParams } from "solid-start";
|
||||
|
||||
import treasureClosed from "~/assets/treasure-closed.png";
|
||||
import treasure from "~/assets/treasure.gif";
|
||||
import {
|
||||
AmountFiat,
|
||||
AmountSats,
|
||||
Button,
|
||||
ButtonLink,
|
||||
DefaultMain,
|
||||
FancyCard,
|
||||
FeesModal,
|
||||
InfoBox,
|
||||
Logo,
|
||||
MutinyWalletGuard,
|
||||
NavBar,
|
||||
NiceP,
|
||||
SafeArea,
|
||||
VStack
|
||||
} from "~/components";
|
||||
import { useI18n } from "~/i18n/context";
|
||||
import { Network } from "~/logic/mutinyWalletSetup";
|
||||
import { useMegaStore } from "~/state/megaStore";
|
||||
import { eify } from "~/utils";
|
||||
|
||||
export default function GiftPage() {
|
||||
const [state, _] = useMegaStore();
|
||||
const i18n = useI18n();
|
||||
|
||||
const [claimSuccess, setClaimSuccess] = createSignal(false);
|
||||
const [error, setError] = createSignal<Error>();
|
||||
const [loading, setLoading] = createSignal(false);
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const [inboundCapacity] = createResource(async () => {
|
||||
try {
|
||||
const channels = await state.mutiny_wallet?.list_channels();
|
||||
let inbound = 0;
|
||||
|
||||
for (const channel of channels) {
|
||||
inbound += channel.size - (channel.balance + channel.reserve);
|
||||
}
|
||||
|
||||
return inbound;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
const warningText = () => {
|
||||
const amount = Number(searchParams.amount);
|
||||
|
||||
if (isNaN(amount)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const network = state.mutiny_wallet?.get_network() as Network;
|
||||
|
||||
const threshold = network === "bitcoin" ? 50000 : 10000;
|
||||
const balance = state.balance?.lightning || 0n;
|
||||
|
||||
if (balance === 0n && amount < threshold) {
|
||||
return i18n.t("settings.gift.receive_too_small", {
|
||||
amount: network === "bitcoin" ? "50,000" : "10,000"
|
||||
});
|
||||
}
|
||||
|
||||
if (amount > (inboundCapacity() || 0)) {
|
||||
return i18n.t("settings.gift.setup_fee_lightning");
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
async function claim() {
|
||||
const amount = Number(searchParams.amount);
|
||||
const nwc = searchParams.nwc_uri;
|
||||
setLoading(true);
|
||||
try {
|
||||
const claimResult = await state.mutiny_wallet?.claim_single_use_nwc(
|
||||
BigInt(amount),
|
||||
nwc
|
||||
);
|
||||
console.log("claim result", claimResult);
|
||||
if (claimResult === "Already Claimed") {
|
||||
throw new Error(i18n.t("settings.gift.already_claimed"));
|
||||
}
|
||||
if (
|
||||
claimResult ===
|
||||
"Failed to pay invoice: We do not have enough balance to pay the given amount."
|
||||
) {
|
||||
throw new Error(i18n.t("settings.gift.sender_is_poor"));
|
||||
}
|
||||
// Fallback for any other errors
|
||||
if (claimResult) {
|
||||
throw new Error(
|
||||
i18n.t("settings.gift.sender_generic_error", {
|
||||
error: claimResult
|
||||
})
|
||||
);
|
||||
}
|
||||
setClaimSuccess(true);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setError(eify(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<MutinyWalletGuard>
|
||||
<SafeArea>
|
||||
<DefaultMain>
|
||||
<Show when={searchParams.nwc_uri && searchParams.amount}>
|
||||
<VStack>
|
||||
<FancyCard>
|
||||
<VStack>
|
||||
<div class="flex items-start justify-between">
|
||||
<VStack smallgap>
|
||||
<span class="text-3xl">
|
||||
<AmountSats
|
||||
denominationSize="xl"
|
||||
amountSats={Number(
|
||||
searchParams.amount
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
<span class="text-xl text-white/70">
|
||||
<AmountFiat
|
||||
denominationSize="xl"
|
||||
amountSats={Number(
|
||||
searchParams.amount
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
</VStack>
|
||||
<Logo />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="relative transition-all duration-500"
|
||||
classList={{
|
||||
"grayscale filter opacity-75":
|
||||
!claimSuccess()
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={treasureClosed}
|
||||
fetchpriority="high"
|
||||
class="mx-auto w-1/2"
|
||||
classList={{
|
||||
hidden: !!claimSuccess()
|
||||
}}
|
||||
/>
|
||||
<img
|
||||
src={treasure}
|
||||
fetchpriority="high"
|
||||
class="mx-auto w-1/2"
|
||||
classList={{
|
||||
hidden: !claimSuccess()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<h2 class="text-center text-3xl font-semibold">
|
||||
{i18n.t("settings.gift.receive_header")}
|
||||
</h2>
|
||||
<NiceP>
|
||||
{i18n.t(
|
||||
"settings.gift.receive_description"
|
||||
)}
|
||||
</NiceP>
|
||||
<Show
|
||||
when={warningText() && !claimSuccess()}
|
||||
>
|
||||
<InfoBox accent="blue">
|
||||
{warningText()} <FeesModal />
|
||||
</InfoBox>
|
||||
</Show>
|
||||
<Switch>
|
||||
<Match when={error()}>
|
||||
<InfoBox accent="red">
|
||||
{error()?.message}
|
||||
</InfoBox>
|
||||
<ButtonLink href="/" intent="red">
|
||||
{i18n.t("common.dangit")}
|
||||
</ButtonLink>
|
||||
</Match>
|
||||
<Match when={claimSuccess()}>
|
||||
<InfoBox accent="green">
|
||||
{i18n.t(
|
||||
"settings.gift.receive_claimed"
|
||||
)}
|
||||
</InfoBox>
|
||||
<ButtonLink
|
||||
href="/"
|
||||
intent="inactive"
|
||||
>
|
||||
{i18n.t("common.nice")}
|
||||
</ButtonLink>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<Button
|
||||
intent="inactive"
|
||||
onClick={claim}
|
||||
loading={loading()}
|
||||
>
|
||||
{i18n.t(
|
||||
"settings.gift.receive_cta"
|
||||
)}
|
||||
</Button>
|
||||
</Match>
|
||||
</Switch>
|
||||
</VStack>
|
||||
</FancyCard>
|
||||
</VStack>
|
||||
</Show>
|
||||
</DefaultMain>
|
||||
<NavBar activeTab="none" />
|
||||
</SafeArea>
|
||||
</MutinyWalletGuard>
|
||||
);
|
||||
}
|
||||
@@ -5,12 +5,11 @@ import { useNavigate } from "solid-start";
|
||||
|
||||
import { Button, Scanner as Reader, showToast } from "~/components";
|
||||
import { useI18n } from "~/i18n/context";
|
||||
import { toParsedParams } from "~/logic/waila";
|
||||
import { useMegaStore } from "~/state/megaStore";
|
||||
|
||||
export default function Scanner() {
|
||||
const i18n = useI18n();
|
||||
const [state, actions] = useMegaStore();
|
||||
const [_state, actions] = useMegaStore();
|
||||
const [scanResult, setScanResult] = createSignal<string>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -44,22 +43,16 @@ export default function Scanner() {
|
||||
// When we have a nice result we can head over to the send screen
|
||||
createEffect(() => {
|
||||
if (scanResult()) {
|
||||
const network = state.mutiny_wallet?.get_network() || "signet";
|
||||
const result = toParsedParams(scanResult() || "", network);
|
||||
if (!result.ok) {
|
||||
showToast(result.error);
|
||||
return;
|
||||
} else {
|
||||
if (
|
||||
result.value?.address ||
|
||||
result.value?.invoice ||
|
||||
result.value?.node_pubkey ||
|
||||
result.value?.lnurl
|
||||
) {
|
||||
actions.setScanResult(result.value);
|
||||
actions.handleIncomingString(
|
||||
scanResult()!,
|
||||
(error) => {
|
||||
showToast(error);
|
||||
},
|
||||
(result) => {
|
||||
actions.setScanResult(result);
|
||||
navigate("/send");
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
DefaultMain,
|
||||
ExternalLink,
|
||||
Fee,
|
||||
GiftLink,
|
||||
HStack,
|
||||
InfoBox,
|
||||
LargeHeader,
|
||||
@@ -44,12 +45,10 @@ import {
|
||||
} from "~/components";
|
||||
import { useI18n } from "~/i18n/context";
|
||||
import { Network } from "~/logic/mutinyWalletSetup";
|
||||
import { ParsedParams, toParsedParams } from "~/logic/waila";
|
||||
import { ParsedParams } from "~/logic/waila";
|
||||
import { useMegaStore } from "~/state/megaStore";
|
||||
import { eify, mempoolTxUrl, MutinyTagItem } from "~/utils";
|
||||
|
||||
import { FeedbackLink } from "./Feedback";
|
||||
|
||||
export type SendSource = "lightning" | "onchain";
|
||||
|
||||
// const TEST_DEST = "bitcoin:tb1pdh43en28jmhnsrhxkusja46aufdlae5qnfrhucw5jvefw9flce3sdxfcwe?amount=0.00001&label=heyo&lightning=lntbs10u1pjrwrdedq8dpjhjmcnp4qd60w268ve0jencwzhz048ruprkxefhj0va2uspgj4q42azdg89uupp5gngy2pqte5q5uvnwcxwl2t8fsdlla5s6xl8aar4xcsvxeus2w2pqsp5n5jp3pz3vpu92p3uswttxmw79a5lc566herwh3f2amwz2sp6f9tq9qyysgqcqpcxqrpwugv5m534ww5ukcf6sdw2m75f2ntjfh3gzeqay649256yvtecgnhjyugf74zakaf56sdh66ec9fqep2kvu6xv09gcwkv36rrkm38ylqsgpw3yfjl"
|
||||
@@ -379,25 +378,17 @@ export default function Send() {
|
||||
});
|
||||
|
||||
function parsePaste(text: string) {
|
||||
if (text) {
|
||||
const network = state.mutiny_wallet?.get_network() || "signet";
|
||||
const result = toParsedParams(text || "", network);
|
||||
if (!result.ok) {
|
||||
showToast(result.error);
|
||||
return;
|
||||
} else {
|
||||
if (
|
||||
result.value?.address ||
|
||||
result.value?.invoice ||
|
||||
result.value?.node_pubkey ||
|
||||
result.value?.lnurl
|
||||
) {
|
||||
setDestination(result.value);
|
||||
actions.handleIncomingString(
|
||||
text,
|
||||
(error) => {
|
||||
showToast(error);
|
||||
},
|
||||
(result) => {
|
||||
setDestination(result);
|
||||
// Important! we need to clear the scan result once we've used it
|
||||
actions.setScanResult(undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function handleDecode() {
|
||||
@@ -735,7 +726,9 @@ export default function Send() {
|
||||
</Button>
|
||||
</VStack>
|
||||
</Show>
|
||||
<FeedbackLink />
|
||||
<div class="flex justify-center">
|
||||
<GiftLink />
|
||||
</div>
|
||||
</VStack>
|
||||
</DefaultMain>
|
||||
<NavBar activeTab="send" />
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { Match, Show, Suspense, Switch } from "solid-js";
|
||||
import { Show, Suspense } from "solid-js";
|
||||
import { A } from "solid-start";
|
||||
|
||||
import scan from "~/assets/icons/scan.svg";
|
||||
import settings from "~/assets/icons/settings.svg";
|
||||
import pixelLogo from "~/assets/mutiny-pixel-logo.png";
|
||||
import plusLogo from "~/assets/mutiny-plus-logo.png";
|
||||
import {
|
||||
BalanceBox,
|
||||
BetaWarningModal,
|
||||
@@ -13,6 +12,7 @@ import {
|
||||
DefaultMain,
|
||||
LoadingIndicator,
|
||||
LoadingShimmer,
|
||||
Logo,
|
||||
NavBar,
|
||||
OnboardWarning,
|
||||
PendingNwc,
|
||||
@@ -34,24 +34,7 @@ export default function App() {
|
||||
<LoadingIndicator />
|
||||
<header class="mb-2 mt-4 flex w-full items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<Switch>
|
||||
<Match when={state.mutiny_plus}>
|
||||
<img
|
||||
id="mutiny-logo"
|
||||
src={plusLogo}
|
||||
class="h-[25px] w-[86px]"
|
||||
alt="Mutiny Plus logo"
|
||||
/>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<img
|
||||
id="mutiny-logo"
|
||||
src={pixelLogo}
|
||||
class="h-[25px] w-[75px]"
|
||||
alt="Mutiny logo"
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
<Logo />
|
||||
<Show
|
||||
when={
|
||||
!state.wallet_loading &&
|
||||
@@ -68,12 +51,24 @@ export default function App() {
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<A
|
||||
class="rounded-lg p-2 hover:bg-white/5 active:bg-m-blue md:hidden"
|
||||
href="/scanner"
|
||||
>
|
||||
<img src={scan} alt="Scan" class="h-6 w-6" />
|
||||
</A>
|
||||
<A
|
||||
class="rounded-lg p-2 hover:bg-white/5 active:bg-m-blue md:hidden"
|
||||
href="/settings"
|
||||
>
|
||||
<img src={settings} alt="Settings" class="h-6 w-6" />
|
||||
<img
|
||||
src={settings}
|
||||
alt="Settings"
|
||||
class="h-6 w-6"
|
||||
/>
|
||||
</A>
|
||||
</div>
|
||||
</header>
|
||||
<Show when={!state.wallet_loading}>
|
||||
<OnboardWarning />
|
||||
|
||||
@@ -139,18 +139,27 @@ function NwcDetails(props: {
|
||||
)}
|
||||
remaining={Number(props.profile.budget_remaining || 0)}
|
||||
/>
|
||||
<KeyValue key={"Budget"}>
|
||||
<KeyValue key={i18n.t("settings.connections.budget")}>
|
||||
<AmountSats amountSats={props.profile.budget_amount} />
|
||||
</KeyValue>
|
||||
<KeyValue key={"Resets every"}>
|
||||
{/* No interval for gifts */}
|
||||
<Show when={props.profile.budget_period}>
|
||||
<KeyValue key={i18n.t("settings.connections.resets_every")}>
|
||||
{props.profile.budget_period}
|
||||
</KeyValue>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
<Button layout="small" intent="green" onClick={props.onEdit}>
|
||||
{i18n.t("settings.connections.edit_budget")}
|
||||
</Button>
|
||||
|
||||
<Show
|
||||
when={
|
||||
props.profile.tag !== "Gift" &&
|
||||
props.profile.tag !== "Subscription"
|
||||
}
|
||||
>
|
||||
<Button layout="small" onClick={openInNostrClient}>
|
||||
{i18n.t("settings.connections.open_in_nostr_client")}
|
||||
</Button>
|
||||
@@ -158,6 +167,7 @@ function NwcDetails(props: {
|
||||
<Button layout="small" onClick={openInPrimal}>
|
||||
{i18n.t("settings.connections.open_in_primal")}
|
||||
</Button>
|
||||
</Show>
|
||||
|
||||
<Button layout="small" onClick={confirmDelete}>
|
||||
{i18n.t("settings.connections.delete_connection")}
|
||||
@@ -292,7 +302,7 @@ function Nwc() {
|
||||
<SettingsCard
|
||||
title={i18n.t("settings.connections.manage_connections")}
|
||||
>
|
||||
<For each={nwcProfiles()}>
|
||||
<For each={nwcProfiles()?.filter(p => p.tag !== "Gift")}>
|
||||
{(profile) => (
|
||||
<Collapser
|
||||
title={profile.name}
|
||||
|
||||
310
src/routes/settings/Gift.tsx
Normal file
310
src/routes/settings/Gift.tsx
Normal file
@@ -0,0 +1,310 @@
|
||||
import {
|
||||
createForm,
|
||||
getValue,
|
||||
required,
|
||||
reset,
|
||||
setValue,
|
||||
SubmitHandler
|
||||
} from "@modular-forms/solid";
|
||||
import { NwcProfile } from "@mutinywallet/mutiny-wasm";
|
||||
import {
|
||||
createEffect,
|
||||
createResource,
|
||||
createSignal,
|
||||
For,
|
||||
Match,
|
||||
Show,
|
||||
Suspense,
|
||||
Switch
|
||||
} from "solid-js";
|
||||
|
||||
import {
|
||||
AmountCard,
|
||||
BackPop,
|
||||
Button,
|
||||
Collapser,
|
||||
ConfirmDialog,
|
||||
DefaultMain,
|
||||
InfoBox,
|
||||
IntegratedQr,
|
||||
LargeHeader,
|
||||
LoadingSpinner,
|
||||
MutinyWalletGuard,
|
||||
NavBar,
|
||||
NiceP,
|
||||
SafeArea,
|
||||
SettingsCard,
|
||||
TextField,
|
||||
VStack
|
||||
} from "~/components";
|
||||
import { useI18n } from "~/i18n/context";
|
||||
import { useMegaStore } from "~/state/megaStore";
|
||||
import { eify } from "~/utils";
|
||||
import { createDeepSignal } from "~/utils/deepSignal";
|
||||
|
||||
type CreateGiftForm = {
|
||||
name: string;
|
||||
amount: string;
|
||||
};
|
||||
|
||||
export function SingleGift(props: { profile: NwcProfile; onDelete?: () => void }) {
|
||||
const i18n = useI18n();
|
||||
const [state, _actions] = useMegaStore();
|
||||
|
||||
const baseUrl = window.location.origin;
|
||||
const sharableUrl = () => baseUrl.concat(props.profile.url_suffix || "");
|
||||
const amount = () => props.profile.budget_amount?.toString() || "0";
|
||||
|
||||
const [confirmOpen, setConfirmOpen] = createSignal(false);
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
try {
|
||||
await state.mutiny_wallet?.delete_nwc_profile(props.profile.index);
|
||||
setConfirmOpen(false);
|
||||
props.onDelete && props.onDelete();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<VStack>
|
||||
<IntegratedQr
|
||||
amountSats={amount()}
|
||||
value={sharableUrl()}
|
||||
kind="gift"
|
||||
/>
|
||||
|
||||
<Button intent="red" onClick={() => setConfirmOpen(true)}>
|
||||
{i18n.t("settings.gift.send_delete_button")}
|
||||
</Button>
|
||||
|
||||
<ConfirmDialog
|
||||
loading={false}
|
||||
open={confirmOpen()}
|
||||
onConfirm={handleConfirmDelete}
|
||||
onCancel={() => setConfirmOpen(false)}
|
||||
>
|
||||
{i18n.t("settings.gift.send_delete_confirm")}
|
||||
</ConfirmDialog>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function ExistingGifts() {
|
||||
const [state, _actions] = useMegaStore();
|
||||
|
||||
const [giftNWCProfiles, { refetch }] = createResource(async () => {
|
||||
try {
|
||||
const profiles: NwcProfile[] =
|
||||
await state.mutiny_wallet?.get_nwc_profiles();
|
||||
|
||||
const filteredForGifts = profiles.filter((p) => p.tag === "Gift");
|
||||
|
||||
return filteredForGifts;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Show when={giftNWCProfiles() && giftNWCProfiles()!.length > 0}>
|
||||
<SettingsCard title={"Existing Gifts"}>
|
||||
<For each={giftNWCProfiles()}>
|
||||
{(profile) => (
|
||||
<Collapser title={profile.name} activityLight={"on"}>
|
||||
<SingleGift profile={profile} onDelete={refetch} />
|
||||
</Collapser>
|
||||
)}
|
||||
</For>
|
||||
</SettingsCard>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GiftPage() {
|
||||
const i18n = useI18n();
|
||||
const [state, _actions] = useMegaStore();
|
||||
|
||||
const [_error, setError] = createSignal<Error>();
|
||||
|
||||
const [giftResult, setGiftResult] = createSignal<NwcProfile>();
|
||||
|
||||
const [giftForm, { Form, Field }] = createForm<CreateGiftForm>({
|
||||
initialValues: {
|
||||
name: "",
|
||||
amount: "50000"
|
||||
}
|
||||
});
|
||||
|
||||
function resetGifting() {
|
||||
reset(giftForm);
|
||||
setGiftResult(undefined);
|
||||
}
|
||||
|
||||
const handleSubmit: SubmitHandler<CreateGiftForm> = async (
|
||||
f: CreateGiftForm
|
||||
) => {
|
||||
const nwc_name = f.name.trim();
|
||||
const amount = Number(f.amount);
|
||||
|
||||
try {
|
||||
const profile = await state.mutiny_wallet?.create_single_use_nwc(
|
||||
nwc_name,
|
||||
BigInt(amount)
|
||||
);
|
||||
|
||||
setGiftResult(profile);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setError(eify(e));
|
||||
}
|
||||
};
|
||||
|
||||
async function fetchProfile(gift?: NwcProfile) {
|
||||
if (!gift) return;
|
||||
try {
|
||||
const fresh = await state.mutiny_wallet?.get_nwc_profile(
|
||||
gift.index
|
||||
);
|
||||
return fresh;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
// If the gift is not found it means it's been deleted because it was redeemed
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const [freshProfile, { refetch }] = createResource(
|
||||
() => giftResult(),
|
||||
fetchProfile,
|
||||
{
|
||||
storage: createDeepSignal
|
||||
}
|
||||
);
|
||||
|
||||
createEffect(() => {
|
||||
// Should re-run after every sync
|
||||
if (!state.is_syncing) {
|
||||
refetch();
|
||||
}
|
||||
});
|
||||
|
||||
const lessThan50k = () => {
|
||||
return Number(getValue(giftForm, "amount")) < 50000;
|
||||
};
|
||||
|
||||
return (
|
||||
<MutinyWalletGuard>
|
||||
<SafeArea>
|
||||
<DefaultMain>
|
||||
<BackPop />
|
||||
<Show when={giftResult()}>
|
||||
<VStack>
|
||||
<Switch>
|
||||
<Match when={!freshProfile()}>
|
||||
<LargeHeader>
|
||||
{i18n.t(
|
||||
"settings.gift.send_header_claimed"
|
||||
)}
|
||||
</LargeHeader>
|
||||
<NiceP>
|
||||
{i18n.t("settings.gift.send_claimed")}
|
||||
</NiceP>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<LargeHeader>
|
||||
{i18n.t(
|
||||
"settings.gift.send_sharable_header"
|
||||
)}
|
||||
</LargeHeader>
|
||||
<NiceP>
|
||||
{i18n.t(
|
||||
"settings.gift.send_instructions"
|
||||
)}
|
||||
</NiceP>
|
||||
<SingleGift
|
||||
profile={freshProfile()!}
|
||||
onDelete={resetGifting}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
<Button intent="green" onClick={resetGifting}>
|
||||
{i18n.t("settings.gift.send_another")}
|
||||
</Button>
|
||||
</VStack>
|
||||
</Show>
|
||||
<Show when={!giftResult()}>
|
||||
<LargeHeader>
|
||||
{i18n.t("settings.gift.send_header")}
|
||||
</LargeHeader>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<VStack>
|
||||
<NiceP>
|
||||
{i18n.t("settings.gift.send_explainer")}
|
||||
</NiceP>
|
||||
<Field
|
||||
name="name"
|
||||
validate={[
|
||||
required(
|
||||
i18n.t(
|
||||
"settings.gift.send_name_required"
|
||||
)
|
||||
)
|
||||
]}
|
||||
>
|
||||
{(field, props) => (
|
||||
<TextField
|
||||
{...props}
|
||||
value={field.value}
|
||||
error={field.error}
|
||||
label={i18n.t(
|
||||
"settings.gift.send_name_label"
|
||||
)}
|
||||
placeholder="Satoshi Nakamoto"
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field name="amount">
|
||||
{(field) => (
|
||||
<>
|
||||
<AmountCard
|
||||
amountSats={field.value || "0"}
|
||||
isAmountEditable
|
||||
setAmountSats={(newAmount) =>
|
||||
setValue(
|
||||
giftForm,
|
||||
"amount",
|
||||
newAmount.toString()
|
||||
)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
<Show when={lessThan50k()}>
|
||||
<InfoBox accent="green">
|
||||
{i18n.t(
|
||||
"settings.gift.send_small_warning"
|
||||
)}
|
||||
</InfoBox>
|
||||
</Show>
|
||||
<Button
|
||||
intent="blue"
|
||||
type="submit"
|
||||
loading={giftForm.submitting}
|
||||
>
|
||||
{i18n.t("settings.gift.send_cta")}
|
||||
</Button>
|
||||
</VStack>
|
||||
</Form>
|
||||
<Suspense fallback={<LoadingSpinner />}>
|
||||
<ExistingGifts />
|
||||
</Suspense>
|
||||
</Show>
|
||||
</DefaultMain>
|
||||
<NavBar activeTab="settings" />
|
||||
</SafeArea>
|
||||
</MutinyWalletGuard>
|
||||
);
|
||||
}
|
||||
@@ -191,7 +191,7 @@ function PlusCTA() {
|
||||
<strong class="text-white">
|
||||
{i18n.t("settings.plus.title")}
|
||||
</strong>
|
||||
?{i18n.t("settings.plus.click_confirm")}
|
||||
? {i18n.t("settings.plus.click_confirm")}
|
||||
</p>
|
||||
</ConfirmDialog>
|
||||
</Show>
|
||||
|
||||
@@ -132,6 +132,13 @@ export default function Settings() {
|
||||
href: "/settings/connections",
|
||||
text: i18n.t("settings.connections.title")
|
||||
},
|
||||
{
|
||||
href: "/settings/gift",
|
||||
disabled: !state.mutiny_plus,
|
||||
|
||||
text: i18n.t("settings.gift.title"),
|
||||
caption: !state.mutiny_plus ? "Upgrade to Mutiny+ to enabled gifting" : undefined
|
||||
},
|
||||
{
|
||||
href: "/settings/lnurlauth",
|
||||
text: i18n.t("settings.lnurl_auth.title")
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
useContext
|
||||
} from "solid-js";
|
||||
import { createStore } from "solid-js/store";
|
||||
import { useSearchParams } from "solid-start";
|
||||
import { useNavigate, useSearchParams } from "solid-start";
|
||||
|
||||
import { Currency, FIAT_OPTIONS } from "~/components/ChooseCurrency";
|
||||
import { checkBrowserCompatibility } from "~/logic/browserCompatibility";
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
MutinyWalletSettingStrings,
|
||||
setupMutinyWallet
|
||||
} from "~/logic/mutinyWalletSetup";
|
||||
import { ParsedParams } from "~/logic/waila";
|
||||
import { ParsedParams, toParsedParams } from "~/logic/waila";
|
||||
import { eify, MutinyTagItem, subscriptionValid } from "~/utils";
|
||||
|
||||
const MegaStoreContext = createContext<MegaStore>();
|
||||
@@ -71,11 +71,17 @@ export type MegaStore = [
|
||||
setPreferredInvoiceType(
|
||||
type: "unified" | "lightning" | "onchain"
|
||||
): void;
|
||||
handleIncomingString(
|
||||
str: string,
|
||||
onError: (e: Error) => void,
|
||||
onSuccess: (value: ParsedParams) => void
|
||||
): void;
|
||||
}
|
||||
];
|
||||
|
||||
export const Provider: ParentComponent = (props) => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [state, setState] = createStore({
|
||||
mutiny_wallet: undefined as MutinyWallet | undefined,
|
||||
@@ -314,6 +320,41 @@ export const Provider: ParentComponent = (props) => {
|
||||
},
|
||||
setPreferredInvoiceType(type: "unified" | "lightning" | "onchain") {
|
||||
setState({ preferredInvoiceType: type });
|
||||
},
|
||||
handleIncomingString(
|
||||
str: string,
|
||||
onError: (e: Error) => void,
|
||||
onSuccess: (value: ParsedParams) => void
|
||||
): void {
|
||||
try {
|
||||
const url = new URL(str);
|
||||
if (url && url.pathname.startsWith("/gift")) {
|
||||
navigate(url.pathname + url.search);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
// If it's not a URL, we'll just continue with normal parsing
|
||||
}
|
||||
|
||||
const network = state.mutiny_wallet?.get_network() || "signet";
|
||||
const result = toParsedParams(str || "", network);
|
||||
if (!result.ok) {
|
||||
if (onError) {
|
||||
onError(result.error);
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
if (
|
||||
result.value?.address ||
|
||||
result.value?.invoice ||
|
||||
result.value?.node_pubkey ||
|
||||
result.value?.lnurl
|
||||
) {
|
||||
if (onSuccess) {
|
||||
onSuccess(result.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user