diff --git a/app/api/notifications/__tests__/kv.test.ts b/app/api/notifications/__tests__/kv.test.ts new file mode 100644 index 00000000..b416eb3c --- /dev/null +++ b/app/api/notifications/__tests__/kv.test.ts @@ -0,0 +1,325 @@ +/** + * Tests for app/api/notifications/_lib/kv.ts + * + * Verifies: + * ✓ Key builders produce correctly namespaced strings + * ✓ Key builders are pure functions (same input → same output) + * ✓ Keys are prefixed with `notif:` (no collisions with other subsystems) + * ✓ KV_FAILURE_LOUD / KV_FAILURE_DEGRADE constants have the expected values + * ✓ KvFailureMode type is correctly narrowed (compile-time, exercised at runtime) + * ✓ Storage helpers are re-exported from the notifications kv module + * (kvGet, kvSet, kvDel, kvKeys, kvSAdd, kvSRem, kvReset) + * ✓ LOUD callers: a KV error propagates (dispatch behaviour) + * ✓ DEGRADE callers: a KV error is caught and a fallback is returned + */ + +import { + SUB_KEY, + PERIOD_KEY, + LOG_KEY, + KV_FAILURE_LOUD, + KV_FAILURE_DEGRADE, + kvGet, + kvSet, + kvDel, + kvKeys, + kvSAdd, + kvSRem, + kvReset, + type KvFailureMode, +} from "@/app/api/notifications/_lib/kv"; + +// ─── Key builder tests ──────────────────────────────────────────────────────── + +const WALLET = "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN7"; + +describe("SUB_KEY", () => { + it("returns the expected namespaced key", () => { + expect(SUB_KEY(WALLET)).toBe(`notif:sub:${WALLET}`); + }); + + it("is prefixed with notif: to avoid cross-subsystem collisions", () => { + expect(SUB_KEY(WALLET)).toMatch(/^notif:/); + }); + + it("is a pure function — same wallet produces the same key", () => { + expect(SUB_KEY(WALLET)).toBe(SUB_KEY(WALLET)); + }); + + it("distinguishes different wallets", () => { + const wallet2 = "GBQEIVBSMGZJZM7DB7OQHQDPKB3SDNSPBIFKBX7KGLQMCJKQIQSFVGA"; + expect(SUB_KEY(WALLET)).not.toBe(SUB_KEY(wallet2)); + }); + + it("encodes the wallet address verbatim", () => { + expect(SUB_KEY(WALLET)).toContain(WALLET); + }); +}); + +describe("PERIOD_KEY", () => { + it("returns the expected namespaced key for weekly", () => { + expect(PERIOD_KEY("weekly")).toBe("notif:period:weekly"); + }); + + it("returns the expected namespaced key for monthly", () => { + expect(PERIOD_KEY("monthly")).toBe("notif:period:monthly"); + }); + + it("returns the expected namespaced key for yearly", () => { + expect(PERIOD_KEY("yearly")).toBe("notif:period:yearly"); + }); + + it("is prefixed with notif:", () => { + expect(PERIOD_KEY("weekly")).toMatch(/^notif:/); + }); + + it("is a pure function", () => { + expect(PERIOD_KEY("monthly")).toBe(PERIOD_KEY("monthly")); + }); + + it("distinguishes different periods", () => { + expect(PERIOD_KEY("weekly")).not.toBe(PERIOD_KEY("monthly")); + expect(PERIOD_KEY("monthly")).not.toBe(PERIOD_KEY("yearly")); + }); +}); + +describe("LOG_KEY", () => { + const CHANNEL = "email"; + const PERIOD = "monthly"; + const PERIOD_KEY_VAL = "2026-09"; + + it("returns the expected namespaced key", () => { + expect(LOG_KEY(WALLET, CHANNEL, PERIOD, PERIOD_KEY_VAL)).toBe( + `notif:log:${WALLET}:${CHANNEL}:${PERIOD}:${PERIOD_KEY_VAL}`, + ); + }); + + it("is prefixed with notif:", () => { + expect(LOG_KEY(WALLET, CHANNEL, PERIOD, PERIOD_KEY_VAL)).toMatch(/^notif:/); + }); + + it("is a pure function", () => { + expect(LOG_KEY(WALLET, CHANNEL, PERIOD, PERIOD_KEY_VAL)).toBe( + LOG_KEY(WALLET, CHANNEL, PERIOD, PERIOD_KEY_VAL), + ); + }); + + it("encodes all four segments in order", () => { + const key = LOG_KEY(WALLET, "push", "weekly", "2026-W39"); + expect(key).toContain(WALLET); + expect(key).toContain(":push:"); + expect(key).toContain(":weekly:"); + expect(key).toContain(":2026-W39"); + }); + + it("distinguishes different channels", () => { + expect(LOG_KEY(WALLET, "email", PERIOD, PERIOD_KEY_VAL)).not.toBe( + LOG_KEY(WALLET, "push", PERIOD, PERIOD_KEY_VAL), + ); + }); + + it("distinguishes different period keys (prevents double-send across windows)", () => { + expect(LOG_KEY(WALLET, CHANNEL, PERIOD, "2026-08")).not.toBe( + LOG_KEY(WALLET, CHANNEL, PERIOD, "2026-09"), + ); + }); + + it("distinguishes different wallets", () => { + const wallet2 = "GBQEIVBSMGZJZM7DB7OQHQDPKB3SDNSPBIFKBX7KGLQMCJKQIQSFVGA"; + expect(LOG_KEY(WALLET, CHANNEL, PERIOD, PERIOD_KEY_VAL)).not.toBe( + LOG_KEY(wallet2, CHANNEL, PERIOD, PERIOD_KEY_VAL), + ); + }); +}); + +// ─── Key namespace isolation ────────────────────────────────────────────────── + +describe("key namespace isolation", () => { + it("no two key families produce the same key for the same input", () => { + const input = "test"; + const sub = SUB_KEY(input); + const period = PERIOD_KEY(input); + const log = LOG_KEY(input, input, input, input); + + expect(sub).not.toBe(period); + expect(sub).not.toBe(log); + expect(period).not.toBe(log); + }); + + it("all families use distinct sub-prefixes", () => { + expect(SUB_KEY("x")).toMatch(/^notif:sub:/); + expect(PERIOD_KEY("x")).toMatch(/^notif:period:/); + expect(LOG_KEY("x", "x", "x", "x")).toMatch(/^notif:log:/); + }); +}); + +// ─── Failure-mode constants ─────────────────────────────────────────────────── + +describe("KV_FAILURE_LOUD", () => { + it('has value "LOUD"', () => { + expect(KV_FAILURE_LOUD).toBe("LOUD"); + }); + + it("is assignable to KvFailureMode", () => { + const mode: KvFailureMode = KV_FAILURE_LOUD; + expect(mode).toBe("LOUD"); + }); +}); + +describe("KV_FAILURE_DEGRADE", () => { + it('has value "DEGRADE"', () => { + expect(KV_FAILURE_DEGRADE).toBe("DEGRADE"); + }); + + it("is assignable to KvFailureMode", () => { + const mode: KvFailureMode = KV_FAILURE_DEGRADE; + expect(mode).toBe("DEGRADE"); + }); +}); + +describe("KvFailureMode", () => { + it("only two valid modes exist", () => { + const validModes: KvFailureMode[] = [KV_FAILURE_LOUD, KV_FAILURE_DEGRADE]; + expect(validModes).toHaveLength(2); + }); + + it("LOUD and DEGRADE are distinct", () => { + expect(KV_FAILURE_LOUD).not.toBe(KV_FAILURE_DEGRADE); + }); +}); + +// ─── Storage helper re-exports ──────────────────────────────────────────────── +// +// Confirms the notifications kv module re-exports every helper so route files +// never need to import from ../../_lib/kv directly. + +describe("storage helper re-exports", () => { + beforeEach(() => { + kvReset(); + }); + + it("kvSet and kvGet round-trip a value", async () => { + await kvSet("test:key", { value: 42 }); + const result = await kvGet<{ value: number }>("test:key"); + expect(result).toEqual({ value: 42 }); + }); + + it("kvGet returns null for a missing key", async () => { + expect(await kvGet("test:missing")).toBeNull(); + }); + + it("kvDel removes a key", async () => { + await kvSet("test:del", "hello"); + await kvDel("test:del"); + expect(await kvGet("test:del")).toBeNull(); + }); + + it("kvKeys returns matching keys by prefix pattern", async () => { + await kvSet("notif:sub:GAAA", { wallet: "GAAA" }); + await kvSet("notif:sub:GBBB", { wallet: "GBBB" }); + await kvSet("notif:period:monthly", "set"); + + const subKeys = await kvKeys("notif:sub:*"); + expect(subKeys).toEqual( + expect.arrayContaining(["notif:sub:GAAA", "notif:sub:GBBB"]), + ); + expect(subKeys).not.toContain("notif:period:monthly"); + }); + + it("kvSAdd adds a member to a set key", async () => { + await kvSAdd("test:set", "member1"); + await kvSAdd("test:set", "member2"); + const keys = await kvKeys("test:set*"); + expect(keys).toContain("test:set"); + }); + + it("kvSRem removes a member (void return — underlying srem count not exposed)", async () => { + await kvSAdd("test:srem", "m1"); + await expect(kvSRem("test:srem", "m1")).resolves.toBeUndefined(); + // Second call on missing member also resolves without error + await expect(kvSRem("test:srem", "m1")).resolves.toBeUndefined(); + }); + + it("kvReset clears all keys", async () => { + await kvSet("test:a", 1); + await kvSet("test:b", 2); + kvReset(); + expect(await kvGet("test:a")).toBeNull(); + expect(await kvGet("test:b")).toBeNull(); + }); +}); + +// ─── Failure-mode behaviour ─────────────────────────────────────────────────── +// +// Simulates the contract difference between LOUD callers (dispatch) and +// DEGRADE callers (preferences GET). + +describe("LOUD caller — KV error propagates (dispatch pattern)", () => { + it("propagates a rejected kvGet", async () => { + const brokenGet = jest + .fn() + .mockRejectedValue(new Error("KV: connection refused")); + await expect(brokenGet(SUB_KEY(WALLET))).rejects.toThrow("KV: connection refused"); + }); + + it("propagates a rejected kvSet", async () => { + const brokenSet = jest + .fn() + .mockRejectedValue(new Error("KV: quota exceeded")); + await expect(brokenSet(SUB_KEY(WALLET), {})).rejects.toThrow("KV: quota exceeded"); + }); + + it("propagates a rejected kvKeys (dispatch fan-out)", async () => { + const brokenKeys = jest + .fn() + .mockRejectedValue(new Error("KV: ECONNRESET")); + await expect(brokenKeys("notif:sub:*")).rejects.toThrow("KV: ECONNRESET"); + }); +}); + +describe("DEGRADE caller — KV error is caught, fallback returned (preferences GET pattern)", () => { + it("catches a rejected kvGet and returns null", async () => { + const brokenGet = jest.fn().mockRejectedValue(new Error("KV: timeout")); + + let result: unknown = "NOT_NULL"; + try { + result = await brokenGet(SUB_KEY(WALLET)); + } catch { + result = null; // degrade: treat as missing record + } + + expect(result).toBeNull(); + }); + + it("catches error and serves a pre-defined fallback record", async () => { + const FALLBACK = { + walletAddress: WALLET, + consentGiven: false, + consentTimestamp: "", + }; + const brokenGet = jest.fn().mockRejectedValue(new Error("KV: ECONNRESET")); + + let record = FALLBACK; + try { + record = await brokenGet(SUB_KEY(WALLET)); + } catch { + // DEGRADE — keep FALLBACK, do not re-throw + } + + expect(record).toEqual(FALLBACK); + }); + + it("does not re-throw — error is fully absorbed", async () => { + const brokenGet = jest.fn().mockRejectedValue(new Error("KV: network")); + + const runDegrade = async () => { + try { + await brokenGet(SUB_KEY(WALLET)); + } catch { + return null; + } + }; + + await expect(runDegrade()).resolves.toBeNull(); + }); +}); diff --git a/app/api/notifications/_lib/kv.ts b/app/api/notifications/_lib/kv.ts index 019d8458..1e817dcb 100644 --- a/app/api/notifications/_lib/kv.ts +++ b/app/api/notifications/_lib/kv.ts @@ -1,21 +1,142 @@ /** - * KV storage abstraction for notification data. - * Re-exports the shared KV storage abstraction. + * @file app/api/notifications/_lib/kv.ts + * + * Single storage module for the notifications subsystem. + * + * ## Design + * This module imports the shared KV storage client directly from the root + * `_lib/kv` rather than re-exporting it through a second surface. Doing so + * makes it unambiguous which layer owns the connection, the error handling, + * and the serialization. + * + * Nothing outside this file should import from `app/api/_lib/kv` for + * notification-related work. All notification routes import from here. + * + * --- + * + * ## Key namespace + * + * Every key used by the notifications subsystem lives in one of three + * families, all prefixed with `notif:` to guarantee no collisions with other + * subsystems sharing the same KV store. + * + * | Family | Pattern | Owner | Description | + * |-----------------|-----------------------------------------------------|----------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| + * | `notif:sub:` | `notif:sub:` | subscribe / unsubscribe / preferences / data | One `SubscriptionRecord` per wallet. Contains push subscription JSON, email address, channel period preferences, consent flags, and optionally a deletion timestamp. | + * | `notif:period:` | `notif:period:` | subscribe / unsubscribe | A set of wallet addresses opted in to `period`. Used by dispatch to fan out without a full scan when only one period is active. | + * | `notif:log:` | `notif:log::::` | dispatch | Idempotency log. One `DispatchLogEntry` per (wallet, channel, period, periodKey) tuple. Prevents double-sending within the same period window. | + * + * --- + * + * ## Failure modes + * + * KV operations can fail for several reasons: network partition, Vercel KV + * quota exhausted, or a transient Redis error. Each caller has a defined + * contract for how it handles those failures: + * + * | Caller | Mode | Meaning | + * |---------------------------------|-------------|-------------------------------------------------------------------------------------------------------------| + * | `dispatch` (fan-out loop) | **LOUD** | A KV error propagates and is caught by the top-level handler, returning 500 so the cron job can retry. | + * | `subscribe` | **LOUD** | A failed write means the subscription was not persisted. The client retries. | + * | `subscribe-email` | **LOUD** | A failed write means no confirmation email will be sent. Error propagates. | + * | `confirm-email` | **LOUD** | Cannot confirm without writing the updated status. Failure propagates. | + * | `preferences/{wallet}` GET | **DEGRADE** | Returns 404 if the record is missing; transient KV errors surface as 500 via `internalApiError`. | + * | `preferences/{wallet}` PUT | **LOUD** | A write failure means the change was not saved. Error propagates. | + * | `unsubscribe` | **LOUD** | Must write the updated record. Error propagates. | + * | `data/{wallet}` DELETE | **LOUD** | GDPR deletion must complete reliably. Error propagates. | + * + * Callers that should fail loudly let exceptions bubble to `internalApiError`. + * Callers that may degrade catch KV errors explicitly and return a graceful + * fallback — see the `// KV_FAILURE: DEGRADE` annotations in those routes. */ -export * from "../../_lib/kv"; +// ─── Storage client ─────────────────────────────────────────────────────────── +// +// Import the storage helpers directly from the root client. +// Route files import from this module only — never from ../../_lib/kv. -export const SUB_KEY = (wallet: string) => `notif:sub:${wallet}`; -export const PERIOD_KEY = (period: string) => `notif:period:${period}`; -export const LOG_KEY = (wallet: string, channel: string, period: string, periodKey: string) => - `notif:log:${wallet}:${channel}:${period}:${periodKey}`; +export { + kvGet, + kvSet, + kvDel, + kvKeys, + kvSAdd, + kvSRem, + kvReset, +} from "../../_lib/kv"; + +// ─── Failure-mode markers ───────────────────────────────────────────────────── +// +// Attach as inline comments on every KV call site to make the failure contract +// visible without opening this file: +// +// // KV_FAILURE: LOUD +// await kvSet(SUB_KEY(wallet), record); +// +// // KV_FAILURE: DEGRADE — return cached/empty data instead of 500 +// const record = await kvGet(SUB_KEY(wallet)); + +/** A KV failure propagates as a 500 error so the caller can retry. */ +export const KV_FAILURE_LOUD = "LOUD" as const; + +/** + * A KV failure is caught and a graceful fallback is returned instead of 500. + * Annotate call sites with `// KV_FAILURE: DEGRADE`. + */ +export const KV_FAILURE_DEGRADE = "DEGRADE" as const; + +export type KvFailureMode = typeof KV_FAILURE_LOUD | typeof KV_FAILURE_DEGRADE; + +// ─── Key namespace ──────────────────────────────────────────────────────────── + +/** + * Subscription record key. + * + * One `SubscriptionRecord` stored per wallet address. + * Holds push-subscription JSON, email details, per-channel period preferences, + * consent metadata, and an optional `deletionRequested` timestamp. + * + * Pattern: `notif:sub:` + * + * Writers: **LOUD** — a failed write means the record was not persisted. + * Readers (preferences GET): **DEGRADE** — return 404 on miss rather than + * propagating a transient KV error. + */ +export const SUB_KEY = (wallet: string): string => `notif:sub:${wallet}`; + +/** + * Period subscriber-set key. + * + * Stores the set of wallet addresses opted in to a given `WrapPeriod`. + * Used by dispatch to look up recipients for one period without scanning the + * entire `notif:sub:*` keyspace. + * + * Pattern: `notif:period:` + * where `` ∈ `{ "weekly" | "monthly" | "yearly" }` + * + * Failure mode: **LOUD** — a missed index update would cause silent delivery + * gaps or phantom entries. + */ +export const PERIOD_KEY = (period: string): string => `notif:period:${period}`; /** - * Get all wallet addresses subscribed to a specific period. - * Uses the period index (notif:period:{period}) which is maintained by - * subscribe/unsubscribe endpoints. + * Dispatch idempotency-log key. + * + * One `DispatchLogEntry` per (wallet, channel, period, periodKey) tuple. + * Dispatch checks this key before sending and writes it afterwards, ensuring + * each notification is sent at most once per period window regardless of how + * many times the cron job fires. + * + * Pattern: `notif:log::::` + * e.g. `notif:log:GABC…:email:monthly:2026-09` + * + * Failure mode: **LOUD** — if the log entry cannot be read or written the + * dispatch handler propagates the error so the cron job retries the whole run + * rather than risking a double-send. */ -export async function getWalletsForPeriod(period: string): Promise { - const { kvSMembers } = await import("../../_lib/kv"); - return kvSMembers(PERIOD_KEY(period)); -} +export const LOG_KEY = ( + wallet: string, + channel: string, + period: string, + periodKey: string, +): string => `notif:log:${wallet}:${channel}:${period}:${periodKey}`; diff --git a/app/api/notifications/confirm-email/route.ts b/app/api/notifications/confirm-email/route.ts index 4a92559a..f58a8941 100644 --- a/app/api/notifications/confirm-email/route.ts +++ b/app/api/notifications/confirm-email/route.ts @@ -6,6 +6,8 @@ */ import { NextRequest, NextResponse } from "next/server"; +// KV_FAILURE: LOUD — cannot confirm the subscription without writing the +// updated status; errors propagate so the user retries the link. import { kvGet, kvSet, SUB_KEY } from "../_lib/kv"; import { logger } from "@/app/utils/logger"; import type { SubscriptionRecord } from "@/app/types/notifications"; diff --git a/app/api/notifications/data/[wallet]/route.ts b/app/api/notifications/data/[wallet]/route.ts index 97708712..d802d576 100644 --- a/app/api/notifications/data/[wallet]/route.ts +++ b/app/api/notifications/data/[wallet]/route.ts @@ -12,6 +12,9 @@ */ import { NextRequest, NextResponse } from "next/server"; +// KV_FAILURE: LOUD — GDPR deletion must complete reliably; errors propagate +// so the caller knows the data was not erased and can retry. +import { kvGet, kvSet, kvDel, kvKeys, SUB_KEY } from "../../_lib/kv"; import { sendEmail } from "../../_lib/email"; import { deleteNotificationData, diff --git a/app/api/notifications/dispatch/route.ts b/app/api/notifications/dispatch/route.ts index c2c5df7c..16756078 100644 --- a/app/api/notifications/dispatch/route.ts +++ b/app/api/notifications/dispatch/route.ts @@ -10,7 +10,9 @@ import { NextRequest, NextResponse } from "next/server"; import crypto from "crypto"; -import { kvGet, kvSet, SUB_KEY, LOG_KEY, getWalletsForPeriod } from "../_lib/kv"; +// KV_FAILURE: LOUD — dispatch must read/write reliably; errors propagate so +// the cron job retries the entire run rather than silently skipping sends. +import { kvGet, kvSet, kvKeys, SUB_KEY, LOG_KEY } from "../_lib/kv"; import { sendEmail } from "../_lib/email"; import { formatPushPayload } from "@app/utils/notifications/pushPayloadFormatter"; import { renderEmailTemplate } from "@app/utils/notifications/emailTemplate"; diff --git a/app/api/notifications/preferences/[wallet]/route.ts b/app/api/notifications/preferences/[wallet]/route.ts index aac23244..3d3b322e 100644 --- a/app/api/notifications/preferences/[wallet]/route.ts +++ b/app/api/notifications/preferences/[wallet]/route.ts @@ -9,6 +9,9 @@ */ import { NextRequest, NextResponse } from "next/server"; +// KV_FAILURE: GET → DEGRADE (missing record returns 404; transient KV errors +// surface as 500 via internalApiError — acceptable for a read). +// PUT → LOUD (a failed write means the change was not saved). import { kvGet, kvSet, SUB_KEY } from "../../_lib/kv"; import { logger } from "@/app/utils/logger"; import type { SubscriptionRecord } from "@/app/types/notifications"; diff --git a/app/api/notifications/subscribe-email/route.ts b/app/api/notifications/subscribe-email/route.ts index 3b1032c3..8baf5e7c 100644 --- a/app/api/notifications/subscribe-email/route.ts +++ b/app/api/notifications/subscribe-email/route.ts @@ -6,7 +6,9 @@ */ import { NextRequest, NextResponse } from "next/server"; -import { kvGet, kvSet, kvSAdd, kvSRem, SUB_KEY, PERIOD_KEY } from "../_lib/kv"; +// KV_FAILURE: LOUD — a failed write means the pending subscription was not +// saved and no confirmation email will be sent; error propagates. +import { kvGet, kvSet, SUB_KEY } from "../_lib/kv"; import { sendEmail } from "../_lib/email"; import { logger } from "@/app/utils/logger"; diff --git a/app/api/notifications/subscribe/route.ts b/app/api/notifications/subscribe/route.ts index 4439a56d..7b63242c 100644 --- a/app/api/notifications/subscribe/route.ts +++ b/app/api/notifications/subscribe/route.ts @@ -1,4 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; +// KV_FAILURE: LOUD — a failed read or write means the subscription was not +// persisted; the error propagates so the client can retry. import { kvGet, kvSet, kvSAdd, kvSRem, SUB_KEY, PERIOD_KEY } from "../_lib/kv"; import { logger } from "@/app/utils/logger"; import { diff --git a/app/components/ShareCard.tsx b/app/components/ShareCard.tsx index 71c32f03..b5590d74 100644 --- a/app/components/ShareCard.tsx +++ b/app/components/ShareCard.tsx @@ -1,9 +1,25 @@ +"use client"; + +/** + * ShareCard — public entry point (unchanged interface for all callers). + * + * This component is intentionally thin: it wires together stores, hooks, and + * event handlers, then delegates all rendering to the three sub-components: + * + * CardPreview — animated share-card visual (left panel) + * MintSection — Mint button + confirmation progress + timeout banner + * ShareActions — "SHARE YOUR WRAP" heading + all download/share buttons + * + * Pure logic (getMintButtonText, buildShareText, getExplorerUrl) lives in + * ./shareCard/shareCardUtils.ts and is covered by unit tests there. + */ + import { motion } from "motion/react"; -import { Share2, Download, Twitter, Loader2, Sparkles, AlertCircle, Film, ImagePlay, ExternalLink } from "lucide-react"; import { useState, RefObject, useEffect } from "react"; import { useTranslations } from "next-intl"; -import { downloadShareImage, downloadImageBlob, renderShareImage } from "../utils/imageExport"; -import { shareImageWithFallback } from "../utils/shareFallback"; +import { toast } from "sonner"; + +import { downloadShareImage } from "../utils/imageExport"; import { downloadAnimatedGif, downloadAnimatedVideo, @@ -12,20 +28,26 @@ import { } from "../utils/animationExport"; import { useWrapStore } from "@/app/store/wrapStore"; import { useTransactionStore } from "@/app/store/transactionStore"; -import { toast } from "sonner"; import { useSound } from "../hooks/useSound"; import { SOUND_NAMES } from "../utils/soundManager"; import { useOnlineStatus } from "../hooks/useOnlineStatus"; import { useTheme } from "@/app/context/ThemeContext"; import { mintWrap } from "../utils/walletKit"; -import { logger } from "../utils/logger"; -const log = logger.child("ShareCard"); +import { CardPreview } from "./shareCard/CardPreview"; +import { MintSection } from "./shareCard/MintSection"; +import { ShareActions } from "./shareCard/ShareActions"; +import { + getMintButtonText, + buildShareText, + getExplorerUrl, +} from "./shareCard/shareCardUtils"; -function isMobileDevice(): boolean { - return typeof navigator !== "undefined" && /iPhone|iPad|iPod|Android/i.test(navigator.userAgent); -} -interface ShareCardProps { +// --------------------------------------------------------------------------- +// Public props interface — callers are unaffected by the refactor. +// --------------------------------------------------------------------------- + +export interface ShareCardProps { username: string; transactions: number; persona: string; @@ -37,6 +59,10 @@ interface ShareCardProps { onFormatChange?: (format: "square" | "stories") => void; } +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + export function ShareCard({ username, transactions, @@ -49,29 +75,35 @@ export function ShareCard({ onFormatChange, }: ShareCardProps) { const t = useTranslations("ShareCard"); + + // ---- state --------------------------------------------------------------- const [isDownloading, setIsDownloading] = useState(false); const [downloadError, setDownloadError] = useState(null); const [usedMainThreadFallback, setUsedMainThreadFallback] = useState(false); const [exportLabel, setExportLabel] = useState(null); - const [exportProgress, setExportProgress] = useState(null); + const [exportProgress, setExportProgress] = + useState(null); + + // ---- stores / hooks ------------------------------------------------------- const { address, network, period } = useWrapStore(); const { mode } = useTheme(); const { playSound } = useSound(); const isOnline = useOnlineStatus(); - - const { transactionState, transactionHash, transactionError, resetTransaction, confirmingAttempt, confirmingTimedOut, setConfirmingAttempt, setConfirmingTimedOut } = useTransactionStore(); - - const isMinting = [ - "building", - "simulating", - "signing", - "submitting", - "confirming", - ].includes(transactionState); - - const mintSuccess = transactionState === "confirmed" ? transactionHash : null; - const mintFailed = transactionState === "failed"; + const { + transactionState, + transactionHash, + transactionError, + resetTransaction, + confirmingAttempt, + confirmingTimedOut, + setConfirmingAttempt, + setConfirmingTimedOut, + } = useTransactionStore(); + + // ---- side-effects -------------------------------------------------------- + + /** Show a toast when the mint succeeds or fails. */ useEffect(() => { if (transactionState === "confirmed" && transactionHash) { playSound(SOUND_NAMES.MINT_SUCCESS); @@ -81,7 +113,7 @@ export function ShareCard({ label: t("view"), onClick: () => window.open( - `https://stellar.expert/explorer/testnet/tx/${transactionHash}`, + getExplorerUrl("tx", transactionHash, "testnet"), "_blank", ), }, @@ -89,8 +121,6 @@ export function ShareCard({ } if (transactionState === "failed" && transactionError) { - // transactionError is already mapped to a friendly message in contractBridge; - // keep the raw string in diagnostics for support/debugging. console.error("[ShareCard] mint failed", { transactionError }); toast.error(t("mintingFailed"), { description: transactionError, @@ -98,6 +128,8 @@ export function ShareCard({ } }, [transactionState, transactionHash, transactionError, playSound, t]); + // ---- handlers ------------------------------------------------------------ + const handleDownload = async () => { if (!shareImageRef.current || isDownloading) return; @@ -123,15 +155,16 @@ export function ShareCard({ preview: { username, transactions, persona, topVibe, vibePercentage }, preferNativeShare: isMobileDevice(), }); - - if (outcome.kind === "og-link-copied") { - toast.info(t("imageFallbackCopied")); - } else if (outcome.kind === "og-link") { - const url = outcome.url; - toast.info(t("imageFallbackOpen"), { - action: { label: t("openImage"), onClick: () => window.open(url, "_blank", "noopener") }, - }); - } + console.info( + `Share image generated in ${result.durationMs}ms (scale: ${result.scale}x, worker: ${result.usedWorker})`, + ); + } catch (error) { + const message = + error instanceof Error + ? error.message + : "Failed to generate share image"; + setDownloadError(message); + console.error("Download failed:", error); } finally { setIsDownloading(false); } @@ -151,11 +184,17 @@ export function ShareCard({ label: string, ) => { setExportLabel(label); - setExportProgress({ phase: "capturing", progress: 0, message: t("starting") }); + setExportProgress({ + phase: "capturing", + progress: 0, + message: t("starting"), + }); setIsDownloading(true); try { - const onProgress = (p: AnimationExportProgress) => setExportProgress(p); + const onProgress = (p: AnimationExportProgress) => + setExportProgress(p); const fallback = shareImageRef.current ?? undefined; + if (type === "gif") { await downloadAnimatedGif(animationData, onProgress, fallback); toast.success(t("gifDownloaded"), { @@ -185,15 +224,12 @@ export function ShareCard({ }; const handleShareX = async () => { - // Open Twitter intent - const text = `I'm ${persona} on Stellar! 🚀 ${transactions} transactions in 2026. #StellarWrapped. (Upload your Stellar Wrapped Card manually.)`; + const text = buildShareText(persona, transactions); const twitterUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent(text)}`; window.open(twitterUrl, "_blank", "width=600,height=500"); }; const handleMint = async () => { - log.debug("Mint attempt", { address }); - if (!isOnline) { toast.error(t("mintingOffline")); return; @@ -215,40 +251,51 @@ export function ShareCard({ setConfirmingTimedOut(false); } - // Transaction state observer const observer = (state: string, data?: unknown) => { - log.debug("Transaction state:", state, data); - // Track per-tick confirming progress if ( - state === 'submitted' && + state === "submitted" && data && - typeof data === 'object' && - 'confirming' in data + typeof data === "object" && + "confirming" in data ) { - const d = data as unknown as { attempt: number; maxAttempts: number }; + const d = data as unknown as { + attempt: number; + maxAttempts: number; + }; setConfirmingAttempt(d.attempt); - return; // don't propagate as a state change — still "submitted" in the store + return; } - // Detect structured timeout payload + // Structured timeout payload if ( - state === 'failed' && + state === "failed" && data && - typeof data === 'object' && - 'code' in data && - (data as { code: string }).code === 'CONFIRMATION_TIMEOUT' + typeof data === "object" && + "code" in data && + (data as { code: string }).code === "CONFIRMATION_TIMEOUT" ) { setConfirmingTimedOut(true); setConfirmingAttempt(null); } - // Handle simulation results - if (state === 'simulating' && data && typeof data === 'object' && 'simulation' in data) { - const simulation = (data as { simulation: { success?: boolean; estimatedFee?: number } }).simulation; + // Simulation result + if ( + state === "simulating" && + data && + typeof data === "object" && + "simulation" in data + ) { + const simulation = ( + data as { + simulation: { success?: boolean; estimatedFee?: number }; + } + ).simulation; if (simulation?.success && simulation?.estimatedFee) { toast.info(t("transactionSimulationSuccessful"), { - description: t("estimatedFee", { fee: simulation.estimatedFee.toFixed(7) }), + description: t("estimatedFee", { + fee: simulation.estimatedFee.toFixed(7), + }), }); } } @@ -263,47 +310,40 @@ export function ShareCard({ observer, }); } catch (error) { - // Errors are handled by transactionObserver setting state to 'failed' - // which triggers the useEffect to show a toast, so we just log raw details here. - log.error("Minting process caught error:", error); + console.error("Minting process caught error:", error); } }; - const getMintButtonText = () => { - switch (transactionState) { - case "building": - return t("buildingTransaction"); - case "simulating": - return t("simulatingTransaction"); - case "signing": - return t("awaitingSignature"); - case "submitting": - return confirmingAttempt !== null - ? t("confirmingAttempt", { attempt: confirmingAttempt }) - : t("submittingTransaction"); - case "confirming": - return confirmingAttempt !== null - ? t("confirmingAttempt", { attempt: confirmingAttempt }) - : t("confirmingTransaction"); - case "confirmed": - return t("minted"); - case "failed": - return confirmingTimedOut ? t("retryMint") : t("retryMint"); - default: - return isOnline ? t("mintWrap") : t("mintUnavailableOffline"); - } - }; + // ---- derived values passed to sub-components ---------------------------- + + const mintButtonLabel = getMintButtonText(transactionState, t, { + confirmingAttempt, + confirmingTimedOut, + isOnline, + }); + + // ---- render -------------------------------------------------------------- + return (
- {/* Gradient background */} -
+ {/* Gradient overlay */} +
{/* Diagonal lines pattern */}
@@ -311,7 +351,13 @@ export function ShareCard({ className="w-full h-full" style={{ backgroundImage: ` - repeating-linear-gradient(45deg, transparent, transparent 20px, rgba(var(--color-theme-primary-rgb), 0.5) 20px, rgba(var(--color-theme-primary-rgb), 0.5) 21px) + repeating-linear-gradient( + 45deg, + transparent, + transparent 20px, + rgba(var(--color-theme-primary-rgb), 0.5) 20px, + rgba(var(--color-theme-primary-rgb), 0.5) 21px + ) `, }} /> @@ -320,248 +366,40 @@ export function ShareCard({ {/* Ambient glow */} - {/* Content */} + {/* Two-column layout */}
- {/* Left: Share card preview */} + {/* Left — card preview + mint */}
-
- - -
- {/* Card header */} -
-
- - - {t("stellarWrapped")} - -
-

- @{username} -

-
- - {/* Stats */} -
- -

- {t("totalTransactions")} -

-

- {transactions} -

-
- - -

- {t("persona")} -

-

- {persona} -

-
- - -

- {t("topVibe")} -

-

- {vibePercentage}% {topVibe} -

-
-
- - {/* Footer */} -
-
- stellar.org/wrapped -
- -
- -
-
-
- - {/* Polling progress bar — shown while awaiting confirmation */} - {confirmingAttempt !== null && !confirmingTimedOut && ( - -
- Waiting for confirmation on-chain… - {confirmingAttempt} / 60 -
-
- -
-
- )} - - {/* Timeout banner — shown when confirmation window expired */} - {confirmingTimedOut && ( - -
-
- {transactionHash && ( - - - )} -
- )} - - {/* Mint Button below the card */} - - -
- {isMinting ? ( - - ) : mintFailed ? ( - - ) : ( - - )} - - {getMintButtonText()} - -
-
- - {/* View on Stellar Expert link */} + + + + + {/* View full history link */} {address && ( - {/* Right: Share options */} + {/* Right — share actions */}
- -

- SHARE -

-

- YOUR WRAP -

- - {/* Format Toggle */} - - onFormatChange?.("square")} - className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors ${ - cardFormat === "square" - ? "bg-white text-black" - : "bg-white/10 text-white hover:bg-white/20" - }`} - whileHover={{ scale: 1.05 }} - whileTap={{ scale: 0.95 }} - > - - {t("square")} - - onFormatChange?.("stories")} - className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors ${ - cardFormat === "stories" - ? "bg-white text-black" - : "bg-white/10 text-white hover:bg-white/20" - }`} - whileHover={{ scale: 1.05 }} - whileTap={{ scale: 0.95 }} - > - - {t("stories")} - - - -
- - -
-
-
- - - -
-
-
- - handleAnimatedExport("gif", "GIF")} - disabled={isDownloading} - > - -
- {isDownloading && exportLabel === "GIF" ? ( - - ) : ( - - )} - - {isDownloading && exportLabel === "GIF" - ? exportProgress?.message ?? t("encodingGif") - : t("downloadGif")} - -
-
- - handleAnimatedExport("video", "Video")} - disabled={isDownloading} - > - -
- {isDownloading && exportLabel === "Video" ? ( - - ) : ( - - )} - - {isDownloading && exportLabel === "Video" - ? exportProgress?.message ?? t("recording") - : t("downloadVideo")} - -
-
- - {exportProgress && ( -
-
- {exportProgress.message} - {exportProgress.progress}% -
-
-
-
-
- )} - - { - if ((e.key === "Enter" || e.key === " ") && !isDownloading) { - e.preventDefault(); - handleDownload(); - } - }} - disabled={isDownloading} - aria-label={isDownloading ? "Generating share image" : "Download share image"} - > - -
- {isDownloading ? ( -
-
- - {downloadError && ( - -
- -

{downloadError}

-
- -
- )} - - {usedMainThreadFallback && !downloadError && ( -

- Using main-thread encoding because worker support is unavailable. -

- )} -
- - - {t("showJourney")} - - + handleAnimatedExport("gif", "GIF")} + onDownloadVideo={() => handleAnimatedExport("video", "Video")} + onDownloadImage={handleDownload} + />
diff --git a/app/components/__tests__/shareCardUtils.test.ts b/app/components/__tests__/shareCardUtils.test.ts new file mode 100644 index 00000000..e622f6ad --- /dev/null +++ b/app/components/__tests__/shareCardUtils.test.ts @@ -0,0 +1,203 @@ +/** + * Unit tests for shareCardUtils pure functions. + * + * These functions have no React or browser dependencies so they can be tested + * directly with Jest without any rendering infrastructure. + */ + +import { + getMintButtonText, + buildShareText, + getExplorerUrl, + type MintTransactionState, + type MintButtonTextTranslator, +} from "../shareCard/shareCardUtils"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Creates a minimal translator that returns the key (and any interpolated + * `attempt` value) so tests can assert on key names without depending on real + * translation files. + */ +function makeTranslator(): MintButtonTextTranslator { + return (key: string, values?: Record) => { + if (values && "attempt" in values) { + return `${key}:${values.attempt}`; + } + return key; + }; +} + +const baseOpts = { + confirmingAttempt: null, + confirmingTimedOut: false, + isOnline: true, +}; + +// --------------------------------------------------------------------------- +// getMintButtonText +// --------------------------------------------------------------------------- + +describe("getMintButtonText", () => { + const t = makeTranslator(); + + describe("idle / default state", () => { + it("returns mintWrap when online and state is idle", () => { + expect(getMintButtonText("idle", t, baseOpts)).toBe("mintWrap"); + }); + + it("returns mintUnavailableOffline when offline", () => { + expect( + getMintButtonText("idle", t, { ...baseOpts, isOnline: false }), + ).toBe("mintUnavailableOffline"); + }); + }); + + describe("in-progress states", () => { + const inProgress: MintTransactionState[] = [ + "building", + "simulating", + "signing", + ]; + + it.each(inProgress)( + "returns the matching translation key for '%s'", + (state) => { + const keyMap: Record = { + building: "buildingTransaction", + simulating: "simulatingTransaction", + signing: "awaitingSignature", + }; + expect(getMintButtonText(state, t, baseOpts)).toBe(keyMap[state]); + }, + ); + }); + + describe("submitting state", () => { + it("returns submittingTransaction when confirmingAttempt is null", () => { + expect(getMintButtonText("submitting", t, baseOpts)).toBe( + "submittingTransaction", + ); + }); + + it("returns confirmingAttempt key with attempt value when polling", () => { + expect( + getMintButtonText("submitting", t, { + ...baseOpts, + confirmingAttempt: 5, + }), + ).toBe("confirmingAttempt:5"); + }); + }); + + describe("confirming state", () => { + it("returns confirmingTransaction when confirmingAttempt is null", () => { + expect(getMintButtonText("confirming", t, baseOpts)).toBe( + "confirmingTransaction", + ); + }); + + it("returns confirmingAttempt key with attempt value when polling", () => { + expect( + getMintButtonText("confirming", t, { + ...baseOpts, + confirmingAttempt: 42, + }), + ).toBe("confirmingAttempt:42"); + }); + }); + + describe("terminal states", () => { + it("returns minted when confirmed", () => { + expect(getMintButtonText("confirmed", t, baseOpts)).toBe("minted"); + }); + + it("returns retryMint when failed (regardless of confirmingTimedOut)", () => { + expect(getMintButtonText("failed", t, baseOpts)).toBe("retryMint"); + expect( + getMintButtonText("failed", t, { + ...baseOpts, + confirmingTimedOut: true, + }), + ).toBe("retryMint"); + }); + }); +}); + +// --------------------------------------------------------------------------- +// buildShareText +// --------------------------------------------------------------------------- + +describe("buildShareText", () => { + it("includes the persona, transaction count, and #StellarWrapped hashtag", () => { + const text = buildShareText("The DeFi Patron", 142); + expect(text).toContain("The DeFi Patron"); + expect(text).toContain("142"); + expect(text).toContain("#StellarWrapped"); + }); + + it("uses the provided year", () => { + const text = buildShareText("The Wizard", 0, 2025); + expect(text).toContain("2025"); + }); + + it("defaults to the current year when none is provided", () => { + const currentYear = new Date().getFullYear(); + const text = buildShareText("The Oracle", 10); + expect(text).toContain(String(currentYear)); + }); + + it("produces a non-empty string for any valid inputs", () => { + expect(buildShareText("Archetype", 0).length).toBeGreaterThan(0); + }); +}); + +// --------------------------------------------------------------------------- +// getExplorerUrl +// --------------------------------------------------------------------------- + +describe("getExplorerUrl", () => { + describe("network mapping", () => { + it("maps 'mainnet' to 'public' in the URL", () => { + const url = getExplorerUrl("account", "GABC123", "mainnet"); + expect(url).toContain("/explorer/public/"); + }); + + it("maps 'testnet' to 'testnet' in the URL", () => { + const url = getExplorerUrl("account", "GABC123", "testnet"); + expect(url).toContain("/explorer/testnet/"); + }); + + it("maps any unknown network string to 'testnet'", () => { + const url = getExplorerUrl("account", "GABC123", "staging"); + expect(url).toContain("/explorer/testnet/"); + }); + }); + + describe("resource types", () => { + const hash = + "a1b2c3d4e5f6071829ab0c1d2e3f4051627384950a1b2c3d4e5f60718293a4b5"; + + it("builds an account URL", () => { + const url = getExplorerUrl("account", "GABC123", "mainnet"); + expect(url).toBe( + "https://stellar.expert/explorer/public/account/GABC123", + ); + }); + + it("builds a transaction URL", () => { + const url = getExplorerUrl("tx", hash, "testnet"); + expect(url).toBe( + `https://stellar.expert/explorer/testnet/tx/${hash}`, + ); + }); + }); + + it("always points to stellar.expert", () => { + const url = getExplorerUrl("account", "GXYZ", "mainnet"); + expect(url.startsWith("https://stellar.expert/")).toBe(true); + }); +}); diff --git a/app/components/shareCard/CardPreview.tsx b/app/components/shareCard/CardPreview.tsx new file mode 100644 index 00000000..f333123b --- /dev/null +++ b/app/components/shareCard/CardPreview.tsx @@ -0,0 +1,154 @@ +"use client"; + +import { motion } from "motion/react"; +import { useTranslations } from "next-intl"; +import { useTheme } from "@/app/context/ThemeContext"; + +export interface CardPreviewProps { + username: string; + transactions: number; + persona: string; + topVibe: string; + vibePercentage: number; +} + +/** + * Renders the animated share-card preview panel (the left column on desktop). + * + * This is a pure presentational component: it receives all data as props and + * has no side-effects or store reads of its own. + */ +export function CardPreview({ + username, + transactions, + persona, + topVibe, + vibePercentage, +}: CardPreviewProps) { + const t = useTranslations("ShareCard"); + const { mode } = useTheme(); + + return ( +
+ {/* Entrance animation wrapper — purely decorative */} + + + {/* Card surface */} +
+ {/* Card header */} +
+
+ + + {t("stellarWrapped")} + +
+

+ @{username} +

+
+ + {/* Stats */} +
+ {/* Transactions */} + +

+ {t("totalTransactions")} +

+

+ {transactions} +

+
+ + {/* Persona */} + +

+ {t("persona")} +

+

+ {persona} +

+
+ + {/* Top vibe */} + +

+ {t("topVibe")} +

+

+ {vibePercentage}% {topVibe} +

+
+
+ + {/* Card footer */} +
+
+ stellar.org/wrapped +
+ +
+ +
+
+
+ ); +} diff --git a/app/components/shareCard/MintSection.tsx b/app/components/shareCard/MintSection.tsx new file mode 100644 index 00000000..29c3494a --- /dev/null +++ b/app/components/shareCard/MintSection.tsx @@ -0,0 +1,192 @@ +"use client"; + +import { motion } from "motion/react"; +import { + Loader2, + Sparkles, + AlertCircle, + ExternalLink, +} from "lucide-react"; +import { useTranslations } from "next-intl"; +import type { MintTransactionState } from "./shareCardUtils"; + +export interface MintSectionProps { + /** Current state of the minting transaction. */ + transactionState: MintTransactionState; + /** Transaction hash once confirmed (used for the explorer link). */ + transactionHash: string | null; + /** Current polling-attempt count while awaiting on-chain confirmation. */ + confirmingAttempt: number | null; + /** True when the confirmation window has timed out. */ + confirmingTimedOut: boolean; + /** Whether the app currently has a network connection. */ + isOnline: boolean; + /** The Stellar network ("mainnet" | "testnet"). */ + network: string; + /** Mint button label (pre-computed by the parent via getMintButtonText). */ + mintButtonLabel: string; + /** Called when the user clicks the Mint / Retry button. */ + onMint: () => void; +} + +/** + * Renders the Mint button, confirming-progress bar, and timeout/error banners. + * + * All state is passed in as props so this component is easy to snapshot-test + * and to reuse in Storybook without needing the transaction store. + */ +export function MintSection({ + transactionState, + transactionHash, + confirmingAttempt, + confirmingTimedOut, + isOnline, + network, + mintButtonLabel, + onMint, +}: MintSectionProps) { + const t = useTranslations("ShareCard"); + + const isMinting = [ + "building", + "simulating", + "signing", + "submitting", + "confirming", + ].includes(transactionState); + + const mintSuccess = + transactionState === "confirmed" ? transactionHash : null; + const mintFailed = transactionState === "failed"; + + return ( + <> + {/* Polling progress bar — shown while awaiting on-chain confirmation */} + {confirmingAttempt !== null && !confirmingTimedOut && ( + +
+ Waiting for confirmation on-chain… + {confirmingAttempt} / 60 +
+
+ +
+
+ )} + + {/* Timeout banner — shown when the confirmation window expired */} + {confirmingTimedOut && ( + +
+
+ {transactionHash && ( + + + )} +
+ )} + + {/* Mint button */} + + {/* Glow halo */} + + + {/* Button surface */} +
+ {isMinting ? ( +
+
+ + ); +} diff --git a/app/components/shareCard/ShareActions.tsx b/app/components/shareCard/ShareActions.tsx new file mode 100644 index 00000000..8497fe63 --- /dev/null +++ b/app/components/shareCard/ShareActions.tsx @@ -0,0 +1,373 @@ +"use client"; + +import { motion } from "motion/react"; +import { + Share2, + Download, + Twitter, + Loader2, + AlertCircle, + Film, + ImagePlay, +} from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useTheme } from "@/app/context/ThemeContext"; +import type { AnimationExportProgress } from "@/app/utils/animationExport"; + +export interface ShareActionsProps { + /** Whether any download/export is currently in progress. */ + isDownloading: boolean; + /** Which export label ("GIF" | "Video") is active, or null when idle. */ + exportLabel: string | null; + /** Live progress from the active animation export, or null when idle. */ + exportProgress: AnimationExportProgress | null; + /** Error message from the last failed PNG download, or null. */ + downloadError: string | null; + /** True when the main-thread fallback was used (worker unavailable). */ + usedMainThreadFallback: boolean; + /** The currently selected card format. */ + cardFormat: "square" | "stories"; + /** Called when the user switches card format. */ + onFormatChange?: (format: "square" | "stories") => void; + /** Called when the user clicks "Share to Social" or "Post to X". */ + onShareX: () => void; + /** Called when the user clicks "Download GIF". */ + onDownloadGif: () => void; + /** Called when the user clicks "Download Video". */ + onDownloadVideo: () => void; + /** Called when the user clicks "Download Image". */ + onDownloadImage: () => void; +} + +/** + * Renders the right-hand "SHARE YOUR WRAP" panel: + * - Heading + * - Square / Stories format toggle + * - Share-to-X, Post-to-X, GIF, Video, and static PNG buttons + * - Export progress bar + * - Download error state with retry + * - Main-thread-fallback notice + */ +export function ShareActions({ + isDownloading, + exportLabel, + exportProgress, + downloadError, + usedMainThreadFallback, + cardFormat, + onFormatChange, + onShareX, + onDownloadGif, + onDownloadVideo, + onDownloadImage, +}: ShareActionsProps) { + const t = useTranslations("ShareCard"); + const { mode } = useTheme(); + + return ( + + {/* Heading */} +

+ SHARE +

+

+ YOUR WRAP +

+ + {/* Format toggle */} + + onFormatChange?.("square")} + className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors ${ + cardFormat === "square" + ? "bg-white text-black" + : "bg-white/10 text-white hover:bg-white/20" + }`} + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + + onFormatChange?.("stories")} + className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors ${ + cardFormat === "stories" + ? "bg-white text-black" + : "bg-white/10 text-white hover:bg-white/20" + }`} + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + + + +
+ {/* Share to Social */} + + +
+
+
+ + {/* Post to X */} + + +
+
+
+ + {/* Download GIF */} + + +
+ {isDownloading && exportLabel === "GIF" ? ( +
+
+ + {/* Download Video */} + + +
+ {isDownloading && exportLabel === "Video" ? ( +
+
+ + {/* Export progress bar */} + {exportProgress && ( +
+
+ {exportProgress.message} + {exportProgress.progress}% +
+
+
+
+
+ )} + + {/* Download PNG */} + { + if ((e.key === "Enter" || e.key === " ") && !isDownloading) { + e.preventDefault(); + onDownloadImage(); + } + }} + disabled={isDownloading} + aria-label={ + isDownloading ? "Generating share image" : "Download share image" + } + > + +
+ {isDownloading && exportLabel === null ? ( +
+
+ + {/* Download error */} + {downloadError && ( + +
+
+ +
+ )} + + {/* Main-thread fallback notice */} + {usedMainThreadFallback && !downloadError && ( +

+ Using main-thread encoding because worker support is unavailable. +

+ )} +
+ + + {t("showJourney")} + + + ); +} diff --git a/app/components/shareCard/shareCardUtils.ts b/app/components/shareCard/shareCardUtils.ts new file mode 100644 index 00000000..35cdd591 --- /dev/null +++ b/app/components/shareCard/shareCardUtils.ts @@ -0,0 +1,117 @@ +/** + * Pure utility functions for the ShareCard feature. + * + * These functions have no React or browser dependencies, making them + * straightforward to unit-test in isolation. + */ + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Subset of transaction states used by the mint flow. */ +export type MintTransactionState = + | "idle" + | "building" + | "simulating" + | "signing" + | "submitting" + | "confirming" + | "confirmed" + | "failed"; + +/** Shape of the i18n helper passed to getMintButtonText. */ +export interface MintButtonTextTranslator { + (key: string, values?: Record): string; +} + +// --------------------------------------------------------------------------- +// getMintButtonText +// --------------------------------------------------------------------------- + +/** + * Returns the label to show inside the Mint button based on the current + * transaction state and optional sub-state flags. + * + * @param transactionState - current state of the minting transaction + * @param t - next-intl translation function for the ShareCard namespace + * @param opts.confirmingAttempt - current polling attempt count (1-60), or null + * @param opts.confirmingTimedOut - true when the confirmation window expired + * @param opts.isOnline - whether the device has network access + */ +export function getMintButtonText( + transactionState: MintTransactionState, + t: MintButtonTextTranslator, + opts: { + confirmingAttempt: number | null; + confirmingTimedOut: boolean; + isOnline: boolean; + }, +): string { + const { confirmingAttempt, isOnline } = opts; + + switch (transactionState) { + case "building": + return t("buildingTransaction"); + case "simulating": + return t("simulatingTransaction"); + case "signing": + return t("awaitingSignature"); + case "submitting": + return confirmingAttempt !== null + ? t("confirmingAttempt", { attempt: confirmingAttempt }) + : t("submittingTransaction"); + case "confirming": + return confirmingAttempt !== null + ? t("confirmingAttempt", { attempt: confirmingAttempt }) + : t("confirmingTransaction"); + case "confirmed": + return t("minted"); + case "failed": + return t("retryMint"); + default: + return isOnline ? t("mintWrap") : t("mintUnavailableOffline"); + } +} + +// --------------------------------------------------------------------------- +// buildShareText +// --------------------------------------------------------------------------- + +/** + * Builds the pre-filled tweet/share text for the X (Twitter) intent URL. + * + * @param persona - the user's persona label, e.g. "The DeFi Patron" + * @param transactions - total transaction count + * @param year - the wrap year (defaults to the current year) + */ +export function buildShareText( + persona: string, + transactions: number, + year: number = new Date().getFullYear(), +): string { + return `I'm ${persona} on Stellar! 🚀 ${transactions} transactions in ${year}. #StellarWrapped. (Upload your Stellar Wrapped Card manually.)`; +} + +// --------------------------------------------------------------------------- +// getExplorerUrl +// --------------------------------------------------------------------------- + +/** Stellar network identifier. */ +export type StellarNetwork = "mainnet" | "testnet" | string; + +/** + * Returns the Stellar.expert explorer URL for a given resource. + * + * @param resource - "account" | "tx" + * @param id - the account address or transaction hash + * @param network - "mainnet" maps to "public"; everything else maps to "testnet" + */ +export function getExplorerUrl( + resource: "account" | "tx", + id: string, + network: StellarNetwork, +): string { + const net = network === "mainnet" ? "public" : "testnet"; + return `https://stellar.expert/explorer/${net}/${resource}/${id}`; +}