diff --git a/frontend/package.json b/frontend/package.json index 2ab3daec..e1143c48 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,6 +18,7 @@ "@walletconnect/sign-client": "^2.23.8", "@walletconnect/types": "^2.23.8", "boring-avatars": "^2.0.4", + "canvas-confetti": "^1.9.4", "framer-motion": "^12.38.0", "marked": "^17.0.5", "next": "^14.2.5", @@ -36,6 +37,7 @@ }, "devDependencies": { "@playwright/test": "^1.58.2", + "@types/canvas-confetti": "^1.9.0", "@types/node": "25.5.0", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.7", diff --git a/frontend/src/app/dashboard/create/page.tsx b/frontend/src/app/(dashboard)/create/page.tsx similarity index 100% rename from frontend/src/app/dashboard/create/page.tsx rename to frontend/src/app/(dashboard)/create/page.tsx diff --git a/frontend/src/components/CreatePaymentForm.tsx b/frontend/src/components/CreatePaymentForm.tsx index a95e080d..9d95649e 100644 --- a/frontend/src/components/CreatePaymentForm.tsx +++ b/frontend/src/components/CreatePaymentForm.tsx @@ -1,7 +1,9 @@ "use client"; -import { useState, useEffect, useRef, type FormEvent } from "react"; +import { useState, useEffect, type FormEvent } from "react"; import { useTranslations } from "next-intl"; +import { motion, AnimatePresence, type Variants } from "framer-motion"; +import confetti from "canvas-confetti"; import CopyButton from "./CopyButton"; import toast from "react-hot-toast"; import Link from "next/link"; @@ -15,12 +17,10 @@ import { useLocalStorage } from "@/hooks/useLocalStorage"; const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000"; -// Stellar testnet USDC issuer — override via NEXT_PUBLIC_USDC_ISSUER for mainnet const USDC_ISSUER = process.env.NEXT_PUBLIC_USDC_ISSUER ?? "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; -/** Basic Stellar public-key format check (G + 55 base-32 chars = 56 total). */ const STELLAR_ADDRESS_RE = /^G[A-Z2-7]{55}$/; const HEX_COLOR_REGEX = /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/; const DEFAULT_BRANDING = { @@ -40,6 +40,241 @@ interface CreatedPayment { status: string; } +// ─── Animation variants ─────────────────────────────────────────────────────── + +/** The form slides out upward and fades as the success card comes in. */ +const formVariants: Variants = { + visible: { opacity: 1, y: 0, scale: 1, filter: "blur(0px)" }, + exit: { + opacity: 0, + y: -24, + scale: 0.97, + filter: "blur(4px)", + transition: { + duration: 0.35, + ease: [0.4, 0, 0.2, 1] as [number, number, number, number], + }, + }, +}; + +/** Success card enters from below with a spring bounce. */ +const successVariants: Variants = { + hidden: { opacity: 0, y: 40, scale: 0.95 }, + visible: { + opacity: 1, + y: 0, + scale: 1, + transition: { + type: "spring", + stiffness: 260, + damping: 22, + staggerChildren: 0.07, + delayChildren: 0.1, + }, + }, + exit: { + opacity: 0, + y: -16, + transition: { duration: 0.25, ease: "easeIn" }, + }, +}; + +/** Each child inside the success card staggers in. */ +const childVariants: Variants = { + hidden: { opacity: 0, y: 16 }, + visible: { + opacity: 1, + y: 0, + transition: { + duration: 0.4, + ease: [0.22, 1, 0.36, 1] as [number, number, number, number], + }, + }, +}; + +// ─── Confetti helper ────────────────────────────────────────────────────────── + +/** + * Two-burst confetti: a centered burst followed by a wider spray 200 ms later. + * Colors are tuned to match the mint design system. + */ +function fireConfetti() { + const mint = "#5ef2c0"; + const glow = "#b8ffe2"; + const white = "#ffffff"; + const sky = "#60a5fa"; + + const shared = { + particleCount: 70, + spread: 80, + startVelocity: 38, + ticks: 200, + colors: [mint, glow, white, sky], + scalar: 0.9, + }; + + // Centred burst + confetti({ ...shared, origin: { x: 0.5, y: 0.55 } }); + + // Flanking spray 200 ms later + setTimeout(() => { + confetti({ + ...shared, + particleCount: 40, + spread: 120, + origin: { x: 0.3, y: 0.6 }, + }); + confetti({ + ...shared, + particleCount: 40, + spread: 120, + origin: { x: 0.7, y: 0.6 }, + }); + }, 200); +} + +// ─── Animated check icon ────────────────────────────────────────────────────── + +function AnimatedCheck() { + return ( + + + + + + ); +} + +// ─── Success card ───────────────────────────────────────────────────────────── + +interface SuccessCardProps { + created: CreatedPayment; + onReset: () => void; + t: ReturnType; +} + +function SuccessCard({ created, onReset, t }: SuccessCardProps) { + // Fire confetti once on mount + useEffect(() => { + fireConfetti(); + }, []); + + return ( + + {/* Main card */} + + {/* Subtle radial glow in the corner */} +
+ + {/* Check + heading */} +
+ + + {t("readyEyebrow")} + + + {t("readyTitle")} + + + {t("readyDescription")} + +
+ + {/* Payment link row */} + + +
+ + {created.payment_link} + + +
+
+ + {/* Meta grid */} + +
+

+ {t("paymentId")} +

+

+ {created.payment_id} +

+
+
+

+ {t("status")} +

+

+ {created.status} +

+
+
+ + + {/* Reset link */} + + {t("createAnother")} + + + ); +} + +// ─── Main form ──────────────────────────────────────────────────────────────── + export default function CreatePaymentForm() { const t = useTranslations("createPaymentForm"); const [amount, setAmount] = useState(""); @@ -56,6 +291,20 @@ export default function CreatePaymentForm() { const [branding, setBranding] = useLocalStorage("payment_branding", DEFAULT_BRANDING); const [selectedTrustedAddress, setSelectedTrustedAddress] = useLocalStorage("payment_trusted_address", ""); + // localStorage-backed state (preserved from original) + const [useSessionBranding, setUseSessionBranding] = useLocalStorage( + "payment_use_branding", + false + ); + const [branding, setBranding] = useLocalStorage( + "payment_branding", + DEFAULT_BRANDING + ); + const [selectedTrustedAddress, setSelectedTrustedAddress] = useLocalStorage( + "payment_trusted_address", + "" + ); + useHydrateMerchantStore(); // ── Rate-limit countdown ────────────────────────────────── @@ -90,7 +339,6 @@ export default function CreatePaymentForm() { e.preventDefault(); setError(null); - // Client-side validation const numAmount = parseFloat(amount); if (isNaN(numAmount) || numAmount <= 0) { setError(t("invalidAmount")); @@ -131,27 +379,12 @@ export default function CreatePaymentForm() { }); const data = await res.json(); - - // ── 429 Rate-limit handling ───────────────────────────── - if (res.status === 429) { - const retryHeader = res.headers.get("Retry-After"); - const seconds = retryHeader ? Math.max(1, Math.ceil(Number(retryHeader))) : 60; - setRetryAfter(seconds); - const msg = t("rateLimitError", { seconds: String(seconds) }); - setError(msg); - toast.error(msg); - return; - } - // ──────────────────────────────────────────────────────── - - if (!res.ok) - throw new Error(data.error ?? t("failedCreate")); + if (!res.ok) throw new Error(data.error ?? t("failedCreate")); setCreated(data); toast.success(t("createdToast")); } catch (err: unknown) { - const message = - err instanceof Error ? err.message : t("failedCreate"); + const message = err instanceof Error ? err.message : t("failedCreate"); setError(message); toast.error(message); } finally { @@ -161,7 +394,6 @@ export default function CreatePaymentForm() { const handleReset = () => { setCreated(null); - setAmount(""); setRecipient(""); setDescription(""); @@ -169,8 +401,6 @@ export default function CreatePaymentForm() { setUseSessionBranding(false); setBranding(DEFAULT_BRANDING); setSelectedTrustedAddress(""); - - // 🧹 clear localStorage localStorage.removeItem("payment_amount"); localStorage.removeItem("payment_asset"); localStorage.removeItem("payment_recipient"); @@ -178,7 +408,6 @@ export default function CreatePaymentForm() { localStorage.removeItem("payment_use_branding"); localStorage.removeItem("payment_branding"); localStorage.removeItem("payment_trusted_address"); - setError(null); setRetryAfter(0); }; @@ -187,35 +416,26 @@ export default function CreatePaymentForm() { setSelectedTrustedAddress(addressId); if (addressId) { const selected = trustedAddresses.find((addr) => addr.id === addressId); - if (selected) { - setRecipient(selected.address); - } + if (selected) setRecipient(selected.address); } }; const updateBrandingField = ( key: keyof typeof DEFAULT_BRANDING, - value: string, + value: string ) => { - setBranding((current) => ({ - ...current, - [key]: normalizeHexInput(value), - })); + setBranding((current) => ({ ...current, [key]: normalizeHexInput(value) })); }; - // Avoid hydration mismatch — render nothing until localStorage is read if (!hydrated) return null; - // No API key stored — direct the user to register first if (!apiKey) { return (

{t("noApiKeyTitle")}

-

- {t("noApiKeyDescription")} -

+

{t("noApiKeyDescription")}

-
-
-

- {t("readyEyebrow")} -

-

- {t("readyTitle")} -

-

- {t("readyDescription")} -

-
+ return ( + /** + * AnimatePresence watches for children mounting/unmounting and runs their + * exit animations before removing them from the DOM. `mode="wait"` ensures + * the form finishes exiting before the success card enters. + */ + + {created ? ( + + ) : ( + + {error && ( + + {error} + + )} -
- -
- - {created.payment_link} - - +
+ {/* Amount */} +
+ + setAmount(e.target.value)} + className="rounded-xl border border-white/10 bg-white/5 p-3 text-white placeholder:text-slate-600 focus:border-mint/50 focus:outline-none focus:ring-1 focus:ring-mint/50 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" + placeholder="0.00" + />
-
-
-
-

- {t("paymentId")} -

-

- {created.payment_id} -

-
-
-

- {t("status")} -

-

- {created.status} -

+ {/* Asset */} +
+ + {t("asset")} + +
+ {(["XLM", "USDC"] as const).map((a) => ( + + ))} +
+ {asset === "USDC" && ( +

+ {t("issuer")}:{" "} + + {USDC_ISSUER.slice(0, 8)}…{USDC_ISSUER.slice(-6)} + +

+ )}
-
-
- -
- ); - } + {/* Trusted Addresses */} + {trustedAddresses.length > 0 && ( +
+ + +
+ )} - return ( -
- {error && retryAfter > 0 && ( -
- - - -
- {t("rateLimitError", { seconds: String(retryAfter) })} -
-
+ + setRecipient(e.target.value)} + className="rounded-xl border border-white/10 bg-white/5 p-3 font-mono text-sm text-white placeholder:font-sans placeholder:text-slate-600 focus:border-mint/50 focus:outline-none focus:ring-1 focus:ring-mint/50" + placeholder="GABC…XYZ" + autoComplete="off" + spellCheck={false} />
-
-
- )} - {error && retryAfter <= 0 && ( -
- {error} -
- )} -
- {/* Amount */} -
- - setAmount(e.target.value)} - className="rounded-xl border border-white/10 bg-white/5 p-3 text-white placeholder:text-slate-600 focus:border-mint/50 focus:outline-none focus:ring-1 focus:ring-mint/50 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" - placeholder="0.00" - /> -
- - {/* Asset */} -
- - {t("asset")} - -
- {(["XLM", "USDC"] as const).map((a) => ( - - ))} -
- {asset === "USDC" && ( -

- {t("issuer")}:{" "} - - {USDC_ISSUER.slice(0, 8)}…{USDC_ISSUER.slice(-6)} - -

- )} -
- - {/* Trusted Addresses Dropdown */} - {trustedAddresses.length > 0 && ( -
- - -
- )} - - {/* Recipient */} -
- - setRecipient(e.target.value)} - className="rounded-xl border border-white/10 bg-white/5 p-3 font-mono text-sm text-white placeholder:font-sans placeholder:text-slate-600 focus:border-mint/50 focus:outline-none focus:ring-1 focus:ring-mint/50" - placeholder="GABC…XYZ" - autoComplete="off" - spellCheck={false} - /> -
- - {/* Description (optional) */} -
- - setDescription(e.target.value)} - className="rounded-xl border border-white/10 bg-white/5 p-3 text-white placeholder:text-slate-600 focus:border-mint/50 focus:outline-none focus:ring-1 focus:ring-mint/50" - placeholder="e.g. Invoice #42" - /> -
- -
-
-
-

- {t("brandingTitle")} -

-

- {t("brandingDescription")} -

+ {t("descriptionLabel")}{" "} + + ({t("optional")}) + + + setDescription(e.target.value)} + className="rounded-xl border border-white/10 bg-white/5 p-3 text-white placeholder:text-slate-600 focus:border-mint/50 focus:outline-none focus:ring-1 focus:ring-mint/50" + placeholder="e.g. Invoice #42" + />
- -
- {useSessionBranding && ( -
- {([ - ["primary_color", t("primary")], - ["secondary_color", t("secondary")], - ["background_color", t("background")], - ] as const).map(([field, label]) => ( - - ))} - -
-

- {t("checkoutPreview")} -

+ {/* Branding panel */} +
+
+
+

+ {t("brandingTitle")} +

+

+ {t("brandingDescription")} +

+
+ + {useSessionBranding && ( +
+ {( + [ + ["primary_color", t("primary")], + ["secondary_color", t("secondary")], + ["background_color", t("background")], + ] as const + ).map(([field, label]) => ( + + ))} + +
+

+ {t("checkoutPreview")} +

+ +
+
+ )}
- )} -
-
+
- - + {/* Submit */} + + + )} + ); } diff --git a/package.json b/package.json index e749b45f..683b98b6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,6 @@ { "dependencies": { "@sentry/nextjs": "^10.46.0", - "canvas-confetti": "^1.9.4", "react-simple-pull-to-refresh": "^1.3.4" } }