@withpotter/components 0.13.0-next.82 → 0.13.0-next.85
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/dashboard/activity-feed.d.ts +2 -0
- package/dist/dashboard/activity-feed.js +3 -1
- package/dist/dashboard/activity-feed.js.map +1 -1
- package/dist/dashboard/activity-paid.d.ts +10 -0
- package/dist/dashboard/activity-paid.js +7 -0
- package/dist/dashboard/activity-paid.js.map +1 -0
- package/dist/dashboard/home/ask-controls.js +1 -0
- package/dist/dashboard/home/ask-controls.js.map +1 -1
- package/dist/dashboard/home/ask-mode-chip.js +4 -4
- package/dist/dashboard/home/ask-mode-chip.js.map +1 -1
- package/dist/dashboard/home/home-ask.js +3 -3
- package/dist/dashboard/home/home-ask.js.map +1 -1
- package/dist/dashboard/home/home-quick-actions.js +9 -1
- package/dist/dashboard/home/home-quick-actions.js.map +1 -1
- package/dist/dashboard/home/host-quick-actions.d.ts +19 -0
- package/dist/dashboard/home/host-quick-actions.js +12 -0
- package/dist/dashboard/home/host-quick-actions.js.map +1 -0
- package/dist/dashboard/home/widgets/activity-widget.js +56 -43
- package/dist/dashboard/home/widgets/activity-widget.js.map +1 -1
- package/dist/dashboard/home/widgets/home-widgets.js +3 -2
- package/dist/dashboard/home/widgets/home-widgets.js.map +1 -1
- package/dist/dashboard/home/widgets/host-widgets.d.ts +6 -0
- package/dist/dashboard/home/widgets/host-widgets.js +16 -0
- package/dist/dashboard/home/widgets/host-widgets.js.map +1 -0
- package/dist/dashboard/home/widgets/member-label.d.ts +11 -0
- package/dist/dashboard/home/widgets/member-label.js +10 -0
- package/dist/dashboard/home/widgets/member-label.js.map +1 -0
- package/dist/dashboard/home/widgets/middle-ellipsis.d.ts +5 -0
- package/dist/dashboard/home/widgets/middle-ellipsis.js +11 -0
- package/dist/dashboard/home/widgets/middle-ellipsis.js.map +1 -0
- package/dist/dashboard/home/widgets/money-widgets.js +9 -4
- package/dist/dashboard/home/widgets/money-widgets.js.map +1 -1
- package/dist/dashboard/home/widgets/registry.d.ts +6 -3
- package/dist/dashboard/home/widgets/registry.js +12 -2
- package/dist/dashboard/home/widgets/registry.js.map +1 -1
- package/dist/dashboard/home/widgets/revenue-series.d.ts +12 -0
- package/dist/dashboard/home/widgets/revenue-series.js +26 -0
- package/dist/dashboard/home/widgets/revenue-series.js.map +1 -0
- package/dist/dashboard/home/widgets/team-widget.js +2 -1
- package/dist/dashboard/home/widgets/team-widget.js.map +1 -1
- package/dist/dashboard/home/widgets/types.d.ts +13 -1
- package/dist/dashboard/home/widgets/types.js.map +1 -1
- package/dist/dashboard/home/widgets/use-home-layout.js +9 -4
- package/dist/dashboard/home/widgets/use-home-layout.js.map +1 -1
- package/dist/dashboard/home/widgets/website-widget.js +9 -1
- package/dist/dashboard/home/widgets/website-widget.js.map +1 -1
- package/dist/features/gift-card-artwork.js +1 -1
- package/dist/features/gift-card-artwork.js.map +1 -1
- package/dist/features/gift-cards/gift-card-logic.d.ts +41 -0
- package/dist/features/gift-cards/gift-card-logic.js +65 -0
- package/dist/features/gift-cards/gift-card-logic.js.map +1 -0
- package/dist/features/gift-cards/gift-cards-feature.js +656 -518
- package/dist/features/gift-cards/gift-cards-feature.js.map +1 -1
- package/dist/features/gift-cards/hint.d.ts +9 -0
- package/dist/features/gift-cards/hint.js +25 -0
- package/dist/features/gift-cards/hint.js.map +1 -0
- package/dist/features/product-form.js +1 -1
- package/dist/features/product-form.js.map +1 -1
- package/dist/sidebar/nav-renderers.js +2 -0
- package/dist/sidebar/nav-renderers.js.map +1 -1
- package/dist/sidebar/nav-section.js +5 -3
- package/dist/sidebar/nav-section.js.map +1 -1
- package/package.json +4 -4
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../src/dashboard/home/widgets/website-widget.tsx"],"sourcesContent":["'use client';\n\nimport { useMemo } from 'react';\nimport {\n ArrowRight,\n ArrowUpRight,\n Eye,\n Globe,\n Lock,\n ShoppingBag,\n TrendingDown,\n TrendingUp,\n} from 'lucide-react';\nimport type { LucideIcon } from 'lucide-react';\nimport { useAuth } from '@withpotter/lib/auth-context';\nimport { analyticsApi } from '@withpotter/lib/api/analytics';\nimport { useApiQuery } from '@withpotter/hooks/use-api-query';\nimport { Panel, PanelAction } from '../panel';\n\n/**\n * The storefront, once there is one.\n *\n * WHY THIS WIDGET EXISTS. Deleting the \"Finish setting up\" panel only works if\n * every chore it held has somewhere better to be. Three of the four did:\n * \"add your first product\" belongs to Today, \"take payments\" is already in\n * Blocked because it stops money outright, and both are boxes whose empty state\n * the chore can occupy. \"Design your site\" and \"get a domain\" had nowhere — no\n * widget on the board was ever going to show their result — so the panel could\n * only have been relocated, not removed. This is the box those two fill.\n */\n/**\n * Views over the trailing week.\n *\n * IT FETCHES ITS OWN, a deliberate exception to the board's rule against\n * per-widget requests. That rule exists because six widgets reading money would\n * mean six copies of one request, which is why `MoneyBoardProvider` exists.\n * Nothing else on the board wants page views, so a provider for one consumer\n * would be ceremony — and `getHomeSummary`, which the money widgets share, does\n * not carry them.\n *\n * SEVEN DAYS, not the money board's window, and the two do not have to agree.\n * The money widgets share one range precisely so revenue-over-30 never appears\n * beside orders-over-7; this card is not part of that sentence.\n */\ninterface Traffic {\n /** Visits over the trailing seven days. */\n views: number;\n /** The same window, one week earlier, for the change. */\n prior: number;\n /** Distinct people, as opposed to page loads. */\n visitors: number;\n /** One point per day, oldest first. Drives the sparkline. */\n series: number[];\n /** Where most of them came from, and what share that was. */\n topSource?: { source: string; percentage: number };\n /** The page they looked at most. */\n topPage?: string;\n /** Sessions that reached a cart, and sessions that bought. */\n carts: number;\n orders: number;\n}\n\nfunction useWeeklyTraffic(enabled: boolean) {\n /*\n * Two windows, so the number can say which way it is going. A visit count\n * with no comparison is a fact you cannot act on: 40 is good or bad only\n * against last week.\n *\n * Through the query cache rather than an effect. `/analytics/dashboard` is a\n * twenty-odd-field payload and this asked for two of them on every mount of\n * the home, with nothing holding the answer — dev's double invoke made it\n * four requests for a card most merchants scroll past. Keyed by the day,\n * which is the resolution a week-long comparison has, so returning to the\n * home inside the same day costs nothing.\n */\n const iso = (d: Date) => d.toISOString().slice(0, 10);\n const today = iso(new Date());\n const windows = useMemo(() => {\n const end = new Date(`${today}T00:00:00.000Z`);\n const days = (n: number) => new Date(end.getTime() - n * 86_400_000);\n return {\n now: { startDate: iso(days(6)), endDate: today },\n before: { startDate: iso(days(13)), endDate: iso(days(7)) },\n };\n }, [today]);\n\n // Silent on failure, both of them: this card's job is the storefront, and a\n // failed traffic read must not turn a working Website card into an error.\n const { data: now } = useApiQuery(\n ['home-traffic', today],\n (slug) => analyticsApi.getDashboard(slug, windows.now),\n { enabled, retry: false },\n );\n const { data: before } = useApiQuery(\n ['home-traffic-previous', today],\n (slug) => analyticsApi.getDashboard(slug, windows.before),\n { enabled, retry: false },\n );\n\n return useMemo<Traffic | null>(() => {\n if (!now) return null;\n // All of this arrives in the SAME response as the view count, so the card\n // gets four more facts for no extra request. It would have been a waste to\n // fetch `overview` and throw the rest of the payload away.\n const sources = now.trafficSources ?? [];\n const pages = now.topPages ?? [];\n return {\n views: now.overview?.totalPageViews ?? 0,\n prior: before?.overview?.totalPageViews ?? 0,\n visitors: now.overview?.uniqueVisitors ?? 0,\n series: (now.pageViewsTimeSeries ?? []).map((p) => p.value ?? 0),\n topSource: sources.length\n ? { source: sources[0].source, percentage: sources[0].percentage }\n : undefined,\n topPage: pages.length ? pages[0].pageUrl : undefined,\n carts: now.funnel?.addToCart ?? 0,\n orders: now.funnel?.checkoutComplete ?? 0,\n };\n }, [now, before]);\n}\n\n/**\n * Week on week, and it stays silent when it cannot be honest.\n *\n * A first week has no prior to compare against, and rendering \"+100%\" against\n * zero is a number that means nothing dressed as a number that means something.\n * The analytics rule is that a figure ships only if it can change what someone\n * does next; a delta off no baseline cannot.\n */\nfunction TrafficDelta({ now, prior }: { now: number; prior: number }) {\n if (prior <= 0 || now === prior) return null;\n const pct = Math.round(((now - prior) / prior) * 100);\n const up = pct > 0;\n return (\n <span\n className={`ml-auto inline-flex items-center gap-0.5 text-sm font-medium tabular-nums ${\n up ? 'text-[var(--success)]' : 'text-[var(--text-tertiary)]'\n }`}\n >\n {up ? (\n <TrendingUp className=\"h-[11px] w-[11px]\" />\n ) : (\n <TrendingDown className=\"h-[11px] w-[11px]\" />\n )}\n {Math.abs(pct)}%\n </span>\n );\n}\n\n/**\n * Below this, a conversion rate is arithmetic on noise. One sale in nine visits\n * is 11.1%, which reads as a triumph and means nothing.\n */\nconst MIN_FOR_RATE = 30;\n\n/** One step of the funnel: the count, then what it counts. */\nfunction Step({ value, label }: { value: number; label: string }) {\n return (\n <span className=\"shrink-0\">\n <span className=\"font-medium tabular-nums text-[var(--text-secondary)]\">\n {value.toLocaleString()}\n </span>{' '}\n {label}\n </span>\n );\n}\n\n/**\n * A headline count and what it counts, for the pre-conversion state.\n *\n * THE GLYPH LEADS, and it is the one its own screen already uses: `Eye` is\n * what the Traffic tab marks views with, `ShoppingBag` is what the Activity\n * widget draws beside `order_placed`. Two counts sitting side by side are\n * told apart at a glance by shape long before anyone reads either label —\n * which is the whole reason to have them — and picking a third icon for a\n * concept the app has already settled would cost that.\n *\n * `self-center` because the row is baseline-aligned: the number and its label\n * share a baseline, which is the detail that makes them read as one phrase,\n * and a 16px glyph hung off that same line sits low against a 24px figure.\n */\nfunction Count({\n icon: Icon,\n value,\n label,\n}: {\n icon: LucideIcon;\n value: number;\n label: string;\n}) {\n return (\n <p className=\"flex items-baseline gap-2\">\n <Icon\n aria-hidden\n className=\"size-4 shrink-0 self-center text-[var(--text-muted)]\"\n />\n {/* 17px, not 20. Two of these sit side by side now, and at `text-2xl`\n they were the loudest thing in a card whose subject is the site\n above them — a week's visit count competing with the storefront it\n describes. One step down the scale (John, 2026-08-25) still reads as\n a figure against a 13px label. */}\n <span className=\"text-xl font-semibold tabular-nums tracking-[-0.02em] text-[var(--text-primary)]\">\n {value.toLocaleString()}\n </span>\n <span className=\"text-sm text-[var(--text-tertiary)]\">{label}</span>\n </p>\n );\n}\n\nexport function WebsiteWidget({\n storefrontUrl,\n hasCustomDomain,\n siteImage,\n businessName,\n}: {\n storefrontUrl?: string | null;\n hasCustomDomain: boolean;\n /** The business's own image. Absent is normal and draws a mark instead. */\n siteImage?: string | null;\n businessName?: string;\n}) {\n const { tenant } = useAuth();\n const isOpen = tenant?.status === 'active';\n const host = storefrontUrl?.replace(/^https?:\\/\\//, '').replace(/\\/$/, '');\n // Only asked for once the site is published: there is no traffic to a\n // storefront that does not exist, and asking would spend a request to be\n // told zero.\n const traffic = useWeeklyTraffic(!!storefrontUrl);\n\n return (\n <Panel\n title=\"Website\"\n className=\"h-full\"\n bodyClassName=\"flex flex-col min-h-0\"\n >\n {/*\n IT IS A LINK PREVIEW — the card that appears when somebody pastes your\n address into WhatsApp or Slack.\n\n Two designs came before this and both did too much. The first was four\n unrelated rows: a pill, a number, a link, a button. The second made the\n card a browser window — chrome, viewport, the image standing upright\n inside it — which looked like a site but answered nothing, because a\n merchant does not need to be shown that websites live in browsers.\n\n The link preview is the right frame because it is the one a merchant\n already knows and the one that matters: it is how the business appears\n the moment anyone shares it. Horizontal, image left, name and address\n right — so the card is literally a rehearsal of the thing it describes.\n\n Three facts, in the order they are wanted: what it looks like shared,\n where it lives, and whether anyone came. Nothing else.\n */}\n <a\n href={storefrontUrl ?? '#'}\n target=\"_blank\"\n rel=\"noreferrer\"\n /* THE PREVIEW IS GREY, not the panel's own fill. It carried\n `surface-interactive`, whose background is `--card-bg` — the exact\n fill of the `Panel` it sits inside — so the only thing separating\n the two was a hairline, and the card read as having no background at\n all. `--bg-muted` is the grey either side of that: #efefef on white,\n #202023 on the dark ground. The chip stands ON the panel, which is\n what a thing you can click should look like. */\n className=\"surface-interactive group/site flex items-stretch gap-0 overflow-hidden bg-[var(--bg-muted)]\"\n >\n {/* Square, and fixed. A share card's thumbnail never grows with the\n title; letting it would make two businesses' cards different shapes\n for no reason a reader could name. */}\n <span className=\"grid h-[68px] w-[68px] shrink-0 place-items-center overflow-hidden bg-[var(--bg-base)]\">\n {siteImage ? (\n // eslint-disable-next-line @next/next/no-img-element\n <img\n src={siteImage}\n alt=\"\"\n className=\"h-full w-full object-cover transition-transform duration-[var(--motion-slow)] ease-out group-hover/site:scale-105\"\n />\n ) : (\n <Globe className=\"size-5 text-[var(--text-muted)]\"\n strokeWidth={1.5}\n />\n )}\n </span>\n\n <span className=\"flex min-w-0 flex-1 flex-col justify-center gap-0.5 px-3 py-2\">\n <span className=\"truncate text-md font-medium text-[var(--text-primary)]\">\n {businessName ?? 'Your website'}\n </span>\n <span className=\"flex min-w-0 items-center gap-1\">\n {isOpen && host ? (\n <>\n <Lock\n aria-hidden\n className=\"h-[9px] w-[9px] shrink-0 text-[var(--success)]\"\n />\n <span className=\"min-w-0 truncate text-sm text-[var(--text-tertiary)]\">\n {host}\n </span>\n </>\n ) : (\n /* \"Not published yet\" was a FACT with no way to act on it, and\n the only control that could open the site was a modal that\n appeared once and never again. A merchant who dismissed it had\n no route to publishing from the dashboard at all.\n\n Shopify's own onboarding guidance is dismissible cards rather\n than modals, and this is already the card about the site. So\n the state and the act that resolves it are in one place. */\n /* The address exists before the site is open, and showing it\n under a padlock said the opposite of the truth. While it is\n shut the line says so; the address is no use until it is. */\n <span className=\"min-w-0 truncate text-sm text-[var(--text-tertiary)]\">\n Not open yet\n </span>\n )}\n </span>\n </span>\n\n <span className=\"grid w-8 shrink-0 place-items-center text-[var(--text-muted)] transition-colors group-hover/site:text-[var(--text-primary)]\">\n <ArrowUpRight />\n </span>\n </a>\n\n {/* NO CONTROL UNDER THE PREVIEW.\n\n There was an \"Open my site\" button here, then — after John cut it —\n the same act surviving as an \"Open it now\" text link. Both are gone\n on his call, 2026-08-25. The card's job is to SHOW the storefront;\n \"Not open yet\" is the line that states it, and publishing is a\n once-ever act that was holding a permanent row on a board where\n every other card leads with content. Opening the site lives on the\n Website hub, which the preview itself links into. */}\n\n {/* THE QUESTION TRAFFIC IS ACTUALLY ASKING.\n A visit count is a vanity number: 480 is good or bad depending\n entirely on what the 480 did, and a merchant cannot act on it. What\n they can act on is the CONVERSION — the share of people who came and\n bought — because a high number says do more of whatever brought them\n and a low one says the site is not closing.\n\n So conversion leads and visits are the context under it, which is the\n inverse of what this card did. The funnel arrives in the same request\n as the view count and was being thrown away.\n\n Only when there is enough traffic to divide by. A rate off nine\n visits is noise with a decimal point, and reporting \"11.1%\" from one\n sale in nine would be worse than reporting nothing. */}\n {traffic && traffic.views >= MIN_FOR_RATE ? (\n <div className=\"mt-3\">\n <p className=\"flex items-baseline gap-2\">\n <span className=\"text-3xl font-semibold leading-none tracking-[-0.02em] tabular-nums text-[var(--text-primary)]\">\n {((traffic.orders / traffic.views) * 100).toFixed(1)}%\n </span>\n <span className=\"text-sm text-[var(--text-tertiary)]\">bought</span>\n <TrafficDelta now={traffic.views} prior={traffic.prior} />\n </p>\n\n {/* The drop-off, as three counts on one line. Where people leave is\n the whole diagnosis: visits high and carts low is a catalogue\n problem, carts high and orders low is a checkout problem, and one\n rate alone cannot tell those apart. */}\n <p className=\"mt-2 flex items-center gap-2 text-sm text-[var(--text-tertiary)]\">\n <Step value={traffic.views} label=\"visits\" />\n <ArrowRight aria-hidden className=\"h-[11px] w-[11px] shrink-0\" />\n <Step value={traffic.carts} label=\"carts\" />\n <ArrowRight aria-hidden className=\"h-[11px] w-[11px] shrink-0\" />\n <Step value={traffic.orders} label=\"orders\" />\n </p>\n\n {traffic.topSource && (\n <p className=\"mt-2 truncate text-sm text-[var(--text-muted)]\">\n Most from{' '}\n <span className=\"font-medium text-[var(--text-secondary)]\">\n {traffic.topSource.source}\n </span>{' '}\n · {Math.round(traffic.topSource.percentage)}%\n </p>\n )}\n </div>\n ) : (\n traffic && (\n // Too little traffic to divide by, so it reports the count and says\n // so rather than dressing a tiny sample as a rate.\n <div className=\"mt-3\">\n {/* TWO COUNTS, not one. Visits alone says people arrived and\n stops there; the merchant's actual question underneath it is\n whether any of them bought. Below `MIN_FOR_RATE` the two\n cannot honestly be divided into a percentage — that is what\n the line beneath says — but they can both be stated, and a\n week of 14 visits and 2 orders is a different week from 14\n and 0 in every way that matters. Same window, same request:\n `checkoutComplete` off the funnel already in the payload. */}\n {/* NO \"not enough traffic to measure conversion\" UNDER THIS.\n Cut on John's call, 2026-08-25. It explained an absence — why\n the card was not showing a percentage — to someone who had not\n asked for one and could not act on the answer. The counts are\n the card's whole claim at this stage; a line apologising for\n the number it is not showing makes a week of real visits read\n as a shortfall. The rate appears on its own once there is\n enough traffic to divide by, and needs no trailer before then. */}\n <div className=\"flex flex-wrap items-baseline gap-x-6 gap-y-1\">\n <Count\n icon={Eye}\n value={traffic.views}\n label={`${traffic.views === 1 ? 'visit' : 'visits'} this week`}\n />\n <Count\n icon={ShoppingBag}\n value={traffic.orders}\n label={traffic.orders === 1 ? 'order' : 'orders'}\n />\n </div>\n </div>\n )\n )}\n\n {/* No \"View site\" button.\n The share-card preview above IS the link — it is an `<a>` to the\n storefront and always has been — so a button underneath was a second\n control for the same click, on a card whose whole argument is that it\n shows you the thing rather than describing it. John, looking at it:\n *\"the open site button on the website card is not needed, people can\n legit click on section that shows the site\"*.\n\n \"Add your own domain\" stays, and is not the same case: it is the one\n job on this card the preview cannot do. */}\n {!hasCustomDomain && (\n <div className=\"mt-auto pt-3\">\n <PanelAction href=\"/dashboard/business/domains\">\n Add your own domain\n </PanelAction>\n </div>\n )}\n </Panel>\n );\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../../../src/dashboard/home/widgets/website-widget.tsx"],"sourcesContent":["'use client';\n\nimport { useMemo } from 'react';\nimport {\n ArrowRight,\n ArrowUpRight,\n Eye,\n Globe,\n Lock,\n ShoppingBag,\n TrendingDown,\n TrendingUp,\n} from 'lucide-react';\nimport type { LucideIcon } from 'lucide-react';\nimport { useAuth } from '@withpotter/lib/auth-context';\nimport { analyticsApi } from '@withpotter/lib/api/analytics';\nimport { useApiQuery } from '@withpotter/hooks/use-api-query';\nimport { middleEllipsis } from './middle-ellipsis';\nimport { Panel, PanelAction } from '../panel';\n\n/**\n * The storefront, once there is one.\n *\n * WHY THIS WIDGET EXISTS. Deleting the \"Finish setting up\" panel only works if\n * every chore it held has somewhere better to be. Three of the four did:\n * \"add your first product\" belongs to Today, \"take payments\" is already in\n * Blocked because it stops money outright, and both are boxes whose empty state\n * the chore can occupy. \"Design your site\" and \"get a domain\" had nowhere — no\n * widget on the board was ever going to show their result — so the panel could\n * only have been relocated, not removed. This is the box those two fill.\n */\n/**\n * Views over the trailing week.\n *\n * IT FETCHES ITS OWN, a deliberate exception to the board's rule against\n * per-widget requests. That rule exists because six widgets reading money would\n * mean six copies of one request, which is why `MoneyBoardProvider` exists.\n * Nothing else on the board wants page views, so a provider for one consumer\n * would be ceremony — and `getHomeSummary`, which the money widgets share, does\n * not carry them.\n *\n * SEVEN DAYS, not the money board's window, and the two do not have to agree.\n * The money widgets share one range precisely so revenue-over-30 never appears\n * beside orders-over-7; this card is not part of that sentence.\n */\ninterface Traffic {\n /** Visits over the trailing seven days. */\n views: number;\n /** The same window, one week earlier, for the change. */\n prior: number;\n /** Distinct people, as opposed to page loads. */\n visitors: number;\n /** One point per day, oldest first. Drives the sparkline. */\n series: number[];\n /** Where most of them came from, and what share that was. */\n topSource?: { source: string; percentage: number };\n /** The page they looked at most. */\n topPage?: string;\n /** Sessions that reached a cart, and sessions that bought. */\n carts: number;\n orders: number;\n}\n\nfunction useWeeklyTraffic(enabled: boolean) {\n /*\n * Two windows, so the number can say which way it is going. A visit count\n * with no comparison is a fact you cannot act on: 40 is good or bad only\n * against last week.\n *\n * Through the query cache rather than an effect. `/analytics/dashboard` is a\n * twenty-odd-field payload and this asked for two of them on every mount of\n * the home, with nothing holding the answer — dev's double invoke made it\n * four requests for a card most merchants scroll past. Keyed by the day,\n * which is the resolution a week-long comparison has, so returning to the\n * home inside the same day costs nothing.\n */\n const iso = (d: Date) => d.toISOString().slice(0, 10);\n const today = iso(new Date());\n const windows = useMemo(() => {\n const end = new Date(`${today}T00:00:00.000Z`);\n const days = (n: number) => new Date(end.getTime() - n * 86_400_000);\n return {\n now: { startDate: iso(days(6)), endDate: today },\n before: { startDate: iso(days(13)), endDate: iso(days(7)) },\n };\n }, [today]);\n\n // Silent on failure, both of them: this card's job is the storefront, and a\n // failed traffic read must not turn a working Website card into an error.\n const { data: now } = useApiQuery(\n ['home-traffic', today],\n (slug) => analyticsApi.getDashboard(slug, windows.now),\n { enabled, retry: false },\n );\n const { data: before } = useApiQuery(\n ['home-traffic-previous', today],\n (slug) => analyticsApi.getDashboard(slug, windows.before),\n { enabled, retry: false },\n );\n\n return useMemo<Traffic | null>(() => {\n if (!now) return null;\n // All of this arrives in the SAME response as the view count, so the card\n // gets four more facts for no extra request. It would have been a waste to\n // fetch `overview` and throw the rest of the payload away.\n const sources = now.trafficSources ?? [];\n const pages = now.topPages ?? [];\n return {\n views: now.overview?.totalPageViews ?? 0,\n prior: before?.overview?.totalPageViews ?? 0,\n visitors: now.overview?.uniqueVisitors ?? 0,\n series: (now.pageViewsTimeSeries ?? []).map((p) => p.value ?? 0),\n topSource: sources.length\n ? { source: sources[0].source, percentage: sources[0].percentage }\n : undefined,\n topPage: pages.length ? pages[0].pageUrl : undefined,\n carts: now.funnel?.addToCart ?? 0,\n orders: now.funnel?.checkoutComplete ?? 0,\n };\n }, [now, before]);\n}\n\n/**\n * Week on week, and it stays silent when it cannot be honest.\n *\n * A first week has no prior to compare against, and rendering \"+100%\" against\n * zero is a number that means nothing dressed as a number that means something.\n * The analytics rule is that a figure ships only if it can change what someone\n * does next; a delta off no baseline cannot.\n */\nfunction TrafficDelta({ now, prior }: { now: number; prior: number }) {\n if (prior <= 0 || now === prior) return null;\n const pct = Math.round(((now - prior) / prior) * 100);\n const up = pct > 0;\n return (\n <span\n className={`ml-auto inline-flex items-center gap-0.5 text-sm font-medium tabular-nums ${\n up ? 'text-[var(--success)]' : 'text-[var(--text-tertiary)]'\n }`}\n >\n {up ? (\n <TrendingUp className=\"h-[11px] w-[11px]\" />\n ) : (\n <TrendingDown className=\"h-[11px] w-[11px]\" />\n )}\n {Math.abs(pct)}%\n </span>\n );\n}\n\n/**\n * Below this, a conversion rate is arithmetic on noise. One sale in nine visits\n * is 11.1%, which reads as a triumph and means nothing.\n */\nconst MIN_FOR_RATE = 30;\n\n/** One step of the funnel: the count, then what it counts. */\nfunction Step({ value, label }: { value: number; label: string }) {\n return (\n <span className=\"shrink-0\">\n <span className=\"font-medium tabular-nums text-[var(--text-secondary)]\">\n {value.toLocaleString()}\n </span>{' '}\n {label}\n </span>\n );\n}\n\n/**\n * A headline count and what it counts, for the pre-conversion state.\n *\n * THE GLYPH LEADS, and it is the one its own screen already uses: `Eye` is\n * what the Traffic tab marks views with, `ShoppingBag` is what the Activity\n * widget draws beside `order_placed`. Two counts sitting side by side are\n * told apart at a glance by shape long before anyone reads either label —\n * which is the whole reason to have them — and picking a third icon for a\n * concept the app has already settled would cost that.\n *\n * `self-center` because the row is baseline-aligned: the number and its label\n * share a baseline, which is the detail that makes them read as one phrase,\n * and a 16px glyph hung off that same line sits low against a 24px figure.\n */\nfunction Count({\n icon: Icon,\n value,\n label,\n}: {\n icon: LucideIcon;\n value: number;\n label: string;\n}) {\n return (\n <p className=\"flex items-baseline gap-2\">\n <Icon\n aria-hidden\n className=\"size-4 shrink-0 self-center text-[var(--text-muted)]\"\n />\n {/* 17px, not 20. Two of these sit side by side now, and at `text-2xl`\n they were the loudest thing in a card whose subject is the site\n above them — a week's visit count competing with the storefront it\n describes. One step down the scale (John, 2026-08-25) still reads as\n a figure against a 13px label. */}\n <span className=\"text-xl font-semibold tabular-nums tracking-[-0.02em] text-[var(--text-primary)]\">\n {value.toLocaleString()}\n </span>\n <span className=\"text-sm text-[var(--text-tertiary)]\">{label}</span>\n </p>\n );\n}\n\nexport function WebsiteWidget({\n storefrontUrl,\n hasCustomDomain,\n siteImage,\n businessName,\n}: {\n storefrontUrl?: string | null;\n hasCustomDomain: boolean;\n /** The business's own image. Absent is normal and draws a mark instead. */\n siteImage?: string | null;\n businessName?: string;\n}) {\n const { tenant } = useAuth();\n const isOpen = tenant?.status === 'active';\n const host = storefrontUrl?.replace(/^https?:\\/\\//, '').replace(/\\/$/, '');\n // Only asked for once the site is published: there is no traffic to a\n // storefront that does not exist, and asking would spend a request to be\n // told zero.\n const traffic = useWeeklyTraffic(!!storefrontUrl);\n\n return (\n <Panel\n title=\"Website\"\n className=\"h-full\"\n bodyClassName=\"flex flex-col min-h-0\"\n >\n {/*\n IT IS A LINK PREVIEW — the card that appears when somebody pastes your\n address into WhatsApp or Slack.\n\n Two designs came before this and both did too much. The first was four\n unrelated rows: a pill, a number, a link, a button. The second made the\n card a browser window — chrome, viewport, the image standing upright\n inside it — which looked like a site but answered nothing, because a\n merchant does not need to be shown that websites live in browsers.\n\n The link preview is the right frame because it is the one a merchant\n already knows and the one that matters: it is how the business appears\n the moment anyone shares it. Horizontal, image left, name and address\n right — so the card is literally a rehearsal of the thing it describes.\n\n Three facts, in the order they are wanted: what it looks like shared,\n where it lives, and whether anyone came. Nothing else.\n */}\n <a\n href={storefrontUrl ?? '#'}\n target=\"_blank\"\n rel=\"noreferrer\"\n /* THE PREVIEW IS GREY, not the panel's own fill. It carried\n `surface-interactive`, whose background is `--card-bg` — the exact\n fill of the `Panel` it sits inside — so the only thing separating\n the two was a hairline, and the card read as having no background at\n all. `--bg-muted` is the grey either side of that: #efefef on white,\n #202023 on the dark ground. The chip stands ON the panel, which is\n what a thing you can click should look like. */\n className=\"surface-interactive group/site flex items-stretch gap-0 overflow-hidden bg-[var(--bg-muted)]\"\n >\n {/* Square, and fixed. A share card's thumbnail never grows with the\n title; letting it would make two businesses' cards different shapes\n for no reason a reader could name. */}\n <span className=\"grid h-[68px] w-[68px] shrink-0 place-items-center overflow-hidden bg-[var(--bg-base)]\">\n {siteImage ? (\n // eslint-disable-next-line @next/next/no-img-element\n <img\n src={siteImage}\n alt=\"\"\n className=\"h-full w-full object-cover transition-transform duration-[var(--motion-slow)] ease-out group-hover/site:scale-105\"\n />\n ) : (\n <Globe className=\"size-5 text-[var(--text-muted)]\"\n strokeWidth={1.5}\n />\n )}\n </span>\n\n <span className=\"flex min-w-0 flex-1 flex-col justify-center gap-0.5 px-3 py-2\">\n <span className=\"truncate text-md font-medium text-[var(--text-primary)]\">\n {businessName ?? 'Your website'}\n </span>\n <span className=\"flex min-w-0 items-center gap-1\">\n {isOpen && host ? (\n <>\n <Lock\n aria-hidden\n className=\"h-[9px] w-[9px] shrink-0 text-[var(--success)]\"\n />\n <span\n title={host}\n className=\"min-w-0 truncate text-sm text-[var(--text-tertiary)]\"\n >\n {middleEllipsis(host)}\n </span>\n </>\n ) : (\n /* \"Not published yet\" was a FACT with no way to act on it, and\n the only control that could open the site was a modal that\n appeared once and never again. A merchant who dismissed it had\n no route to publishing from the dashboard at all.\n\n Shopify's own onboarding guidance is dismissible cards rather\n than modals, and this is already the card about the site. So\n the state and the act that resolves it are in one place. */\n /* The address exists before the site is open, and showing it\n under a padlock said the opposite of the truth. While it is\n shut the line says so; the address is no use until it is. */\n <span className=\"min-w-0 truncate text-sm text-[var(--text-tertiary)]\">\n Not open yet\n </span>\n )}\n </span>\n </span>\n\n <span className=\"grid w-8 shrink-0 place-items-center text-[var(--text-muted)] transition-colors group-hover/site:text-[var(--text-primary)]\">\n <ArrowUpRight />\n </span>\n </a>\n\n {/* NO CONTROL UNDER THE PREVIEW.\n\n There was an \"Open my site\" button here, then — after John cut it —\n the same act surviving as an \"Open it now\" text link. Both are gone\n on his call, 2026-08-25. The card's job is to SHOW the storefront;\n \"Not open yet\" is the line that states it, and publishing is a\n once-ever act that was holding a permanent row on a board where\n every other card leads with content. Opening the site lives on the\n Website hub, which the preview itself links into. */}\n\n {/* THE QUESTION TRAFFIC IS ACTUALLY ASKING.\n A visit count is a vanity number: 480 is good or bad depending\n entirely on what the 480 did, and a merchant cannot act on it. What\n they can act on is the CONVERSION — the share of people who came and\n bought — because a high number says do more of whatever brought them\n and a low one says the site is not closing.\n\n So conversion leads and visits are the context under it, which is the\n inverse of what this card did. The funnel arrives in the same request\n as the view count and was being thrown away.\n\n Only when there is enough traffic to divide by. A rate off nine\n visits is noise with a decimal point, and reporting \"11.1%\" from one\n sale in nine would be worse than reporting nothing. */}\n {traffic && traffic.views >= MIN_FOR_RATE ? (\n <div className=\"mt-3\">\n <p className=\"flex items-baseline gap-2\">\n <span className=\"text-3xl font-semibold leading-none tracking-[-0.02em] tabular-nums text-[var(--text-primary)]\">\n {((traffic.orders / traffic.views) * 100).toFixed(1)}%\n </span>\n <span className=\"text-sm text-[var(--text-tertiary)]\">bought</span>\n <TrafficDelta now={traffic.views} prior={traffic.prior} />\n </p>\n\n {/* The drop-off, as three counts on one line. Where people leave is\n the whole diagnosis: visits high and carts low is a catalogue\n problem, carts high and orders low is a checkout problem, and one\n rate alone cannot tell those apart. */}\n <p className=\"mt-2 flex items-center gap-2 text-sm text-[var(--text-tertiary)]\">\n <Step value={traffic.views} label=\"visits\" />\n <ArrowRight aria-hidden className=\"h-[11px] w-[11px] shrink-0\" />\n <Step value={traffic.carts} label=\"carts\" />\n <ArrowRight aria-hidden className=\"h-[11px] w-[11px] shrink-0\" />\n <Step value={traffic.orders} label=\"orders\" />\n </p>\n\n {traffic.topSource && (\n <p className=\"mt-2 truncate text-sm text-[var(--text-muted)]\">\n Most from{' '}\n <span className=\"font-medium text-[var(--text-secondary)]\">\n {traffic.topSource.source}\n </span>{' '}\n · {Math.round(traffic.topSource.percentage)}%\n </p>\n )}\n </div>\n ) : (\n traffic && (\n // Too little traffic to divide by, so it reports the count and says\n // so rather than dressing a tiny sample as a rate.\n <div className=\"mt-3\">\n {/* TWO COUNTS, not one. Visits alone says people arrived and\n stops there; the merchant's actual question underneath it is\n whether any of them bought. Below `MIN_FOR_RATE` the two\n cannot honestly be divided into a percentage — that is what\n the line beneath says — but they can both be stated, and a\n week of 14 visits and 2 orders is a different week from 14\n and 0 in every way that matters. Same window, same request:\n `checkoutComplete` off the funnel already in the payload. */}\n {/* NO \"not enough traffic to measure conversion\" UNDER THIS.\n Cut on John's call, 2026-08-25. It explained an absence — why\n the card was not showing a percentage — to someone who had not\n asked for one and could not act on the answer. The counts are\n the card's whole claim at this stage; a line apologising for\n the number it is not showing makes a week of real visits read\n as a shortfall. The rate appears on its own once there is\n enough traffic to divide by, and needs no trailer before then. */}\n <div className=\"flex flex-wrap items-baseline gap-x-6 gap-y-1\">\n <Count\n icon={Eye}\n value={traffic.views}\n label={`${traffic.views === 1 ? 'visit' : 'visits'} this week`}\n />\n <Count\n icon={ShoppingBag}\n value={traffic.orders}\n label={traffic.orders === 1 ? 'order' : 'orders'}\n />\n </div>\n </div>\n )\n )}\n\n {/* No \"View site\" button.\n The share-card preview above IS the link — it is an `<a>` to the\n storefront and always has been — so a button underneath was a second\n control for the same click, on a card whose whole argument is that it\n shows you the thing rather than describing it. John, looking at it:\n *\"the open site button on the website card is not needed, people can\n legit click on section that shows the site\"*.\n\n \"Add your own domain\" stays, and is not the same case: it is the one\n job on this card the preview cannot do. */}\n {!hasCustomDomain && (\n <div className=\"mt-auto pt-3\">\n <PanelAction href=\"/dashboard/business/domains\">\n Add your own domain\n </PanelAction>\n </div>\n )}\n </Panel>\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/features/gift-card-artwork.tsx"],"sourcesContent":["import { Gift } from 'lucide-react';\nimport type { GiftCardDesignSnapshot } from '@withpotter/lib/api';\n\nexport const GIFT_CARD_PRESETS: GiftCardDesignSnapshot[] = [\n {\n designId: 'gold',\n name: 'Gold',\n imageUrl: null,\n palette: {\n background: '#713f12',\n foreground: '#fffbeb',\n accent: '#fbbf24',\n },\n occasion: 'general',\n },\n {\n designId: 'platinum',\n name: 'Platinum',\n imageUrl: null,\n palette: {\n background: '#334155',\n foreground: '#f8fafc',\n accent: '#cbd5e1',\n },\n occasion: 'general',\n },\n {\n designId: 'bronze',\n name: 'Bronze',\n imageUrl: null,\n palette: {\n background: '#7c2d12',\n foreground: '#fff7ed',\n accent: '#fb923c',\n },\n occasion: 'general',\n },\n {\n designId: 'silver',\n name: 'Silver',\n imageUrl: null,\n palette: {\n background: '#475569',\n foreground: '#f8fafc',\n accent: '#e2e8f0',\n },\n occasion: 'general',\n },\n];\n\ninterface GiftCardArtworkProps {\n design: GiftCardDesignSnapshot;\n merchantName: string;\n amount?: string;\n recipientName?: string;\n senderName?: string;\n code?: string;\n className?: string;\n}\n\nexport interface GiftCardArtworkData\n extends Omit<GiftCardArtworkProps, 'className'> {}\n\nfunction escapeXml(value: string): string {\n return value\n .replaceAll('&', '&')\n .replaceAll('<', '<')\n .replaceAll('>', '>')\n .replaceAll('\"', '"')\n .replaceAll(\"'\", ''');\n}\n\nexport function createGiftCardSvg({\n design,\n merchantName,\n amount,\n recipientName,\n senderName,\n code,\n}: GiftCardArtworkData): string {\n const foreground = design.palette.foreground;\n const background = design.palette.background;\n const accent = design.palette.accent;\n\n return `<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1200\" height=\"756\" viewBox=\"0 0 1200 756\">\n <defs><pattern id=\"lines\" width=\"72\" height=\"72\" patternUnits=\"userSpaceOnUse\" patternTransform=\"rotate(45)\"><line x1=\"0\" y1=\"0\" x2=\"0\" y2=\"72\" stroke=\"${accent}\" stroke-opacity=\"0.14\" stroke-width=\"3\"/></pattern></defs>\n <rect width=\"1200\" height=\"756\" rx=\"32\" fill=\"${background}\"/>\n <rect width=\"1200\" height=\"756\" rx=\"32\" fill=\"url(#lines)\"/>\n <rect width=\"1200\" height=\"12\" rx=\"6\" fill=\"${accent}\"/>\n <circle cx=\"78\" cy=\"82\" r=\"28\" fill=\"${accent}\"/>\n <text x=\"126\" y=\"72\" fill=\"${foreground}\" font-family=\"Arial, sans-serif\" font-size=\"30\" font-weight=\"700\">${escapeXml(merchantName)}</text>\n <text x=\"126\" y=\"104\" fill=\"${foreground}\" fill-opacity=\"0.7\" font-family=\"Arial, sans-serif\" font-size=\"20\">GIFT CARD</text>\n ${amount ? `<text x=\"1120\" y=\"82\" text-anchor=\"end\" fill=\"${foreground}\" fill-opacity=\"0.82\" font-family=\"Arial, sans-serif\" font-size=\"26\" font-weight=\"600\">${escapeXml(amount)}</text>` : ''}\n <text x=\"72\" y=\"330\" fill=\"${foreground}\" font-family=\"Arial, sans-serif\" font-size=\"58\" font-weight=\"700\">${escapeXml(design.name)}</text>\n <text x=\"72\" y=\"470\" fill=\"${foreground}\" fill-opacity=\"0.58\" font-family=\"Arial, sans-serif\" font-size=\"18\" font-weight=\"700\">TO</text>\n <text x=\"72\" y=\"515\" fill=\"${foreground}\" font-family=\"Arial, sans-serif\" font-size=\"34\" font-weight=\"600\">${escapeXml(recipientName || 'Someone special')}</text>\n <text x=\"600\" y=\"470\" fill=\"${foreground}\" fill-opacity=\"0.58\" font-family=\"Arial, sans-serif\" font-size=\"18\" font-weight=\"700\">FROM</text>\n <text x=\"600\" y=\"515\" fill=\"${foreground}\" font-family=\"Arial, sans-serif\" font-size=\"34\" font-weight=\"600\">${escapeXml(senderName || merchantName)}</text>\n ${code ? `<text x=\"72\" y=\"664\" fill=\"${foreground}\" fill-opacity=\"0.58\" font-family=\"Arial, sans-serif\" font-size=\"18\" font-weight=\"700\">REDEEM WITH</text><text x=\"72\" y=\"704\" fill=\"${foreground}\" font-family=\"monospace\" font-size=\"30\" font-weight=\"700\">${escapeXml(code)}</text>` : ''}\n</svg>`;\n}\n\nexport async function downloadGiftCardArtwork(\n data: GiftCardArtworkData,\n): Promise<void> {\n const svgBlob = new Blob([createGiftCardSvg(data)], {\n type: 'image/svg+xml;charset=utf-8',\n });\n const svgUrl = URL.createObjectURL(svgBlob);\n const image = new Image();\n await new Promise<void>((resolve, reject) => {\n image.onload = () => resolve();\n image.onerror = () => reject(new Error('Unable to render gift card image'));\n image.src = svgUrl;\n });\n\n const canvas = document.createElement('canvas');\n canvas.width = 1200;\n canvas.height = 756;\n const context = canvas.getContext('2d');\n if (!context) {\n URL.revokeObjectURL(svgUrl);\n throw new Error('Unable to create gift card image');\n }\n context.drawImage(image, 0, 0, canvas.width, canvas.height);\n URL.revokeObjectURL(svgUrl);\n\n const pngBlob = await new Promise<Blob>((resolve, reject) => {\n canvas.toBlob(\n (blob) =>\n blob\n ? resolve(blob)\n : reject(new Error('Unable to encode gift card image')),\n 'image/png',\n );\n });\n const url = URL.createObjectURL(pngBlob);\n const link = document.createElement('a');\n link.href = url;\n link.download = `gift-card-${data.code || 'preview'}.png`;\n document.body.appendChild(link);\n link.click();\n link.remove();\n setTimeout(() => URL.revokeObjectURL(url), 0);\n}\n\nexport function createGiftCardEmailHtml(data: GiftCardArtworkData): string {\n const { design, merchantName, amount, recipientName, senderName, code } = data;\n return `<div style=\"margin:0;padding:32px 16px;background:#f4f4f5;font-family:Arial,sans-serif;color:#18181b\">\n <div style=\"max-width:620px;margin:0 auto\">\n <p style=\"margin:0 0 16px;font-size:16px\">${escapeXml(senderName || merchantName)} sent you a gift card.</p>\n <div style=\"padding:28px;border-radius:12px;background:${design.palette.background};color:${design.palette.foreground};border-top:6px solid ${design.palette.accent}\">\n <div style=\"font-size:20px;font-weight:700\">${escapeXml(merchantName)}</div>\n <div style=\"margin-top:4px;font-size:12px;opacity:.7\">GIFT CARD</div>\n <div style=\"margin-top:56px;font-size:30px;font-weight:700\">${escapeXml(design.name)}</div>\n <table role=\"presentation\" style=\"width:100%;margin-top:40px;color:inherit\"><tr>\n <td><div style=\"font-size:11px;opacity:.6\">TO</div><div style=\"margin-top:6px;font-size:18px;font-weight:600\">${escapeXml(recipientName || 'Someone special')}</div></td>\n <td><div style=\"font-size:11px;opacity:.6\">FROM</div><div style=\"margin-top:6px;font-size:18px;font-weight:600\">${escapeXml(senderName || merchantName)}</div></td>\n ${amount ? `<td style=\"text-align:right;font-size:16px;font-weight:600\">${escapeXml(amount)}</td>` : ''}\n </tr></table>\n ${code ? `<div style=\"margin-top:36px;font-size:11px;opacity:.6\">REDEEM WITH</div><div style=\"margin-top:6px;font-family:monospace;font-size:20px;font-weight:700\">${escapeXml(code)}</div>` : ''}\n </div>\n </div>\n</div>`;\n}\n\nexport function GiftCardArtwork({\n design,\n merchantName,\n amount,\n recipientName,\n senderName,\n code,\n className = '',\n}: GiftCardArtworkProps) {\n return (\n <div\n className={`relative aspect-[1.586/1] w-full overflow-hidden rounded-md p-5 ${className}`}\n style={{\n color: design.palette.foreground,\n backgroundColor: design.palette.background,\n backgroundImage: `repeating-linear-gradient(135deg, transparent 0 18px, ${design.palette.accent}22 18px 19px)`,\n }}\n >\n {design.imageUrl && (\n <img\n src={design.imageUrl}\n alt=\"\"\n className=\"absolute inset-0 h-full w-full object-cover\"\n />\n )}\n <div\n className=\"absolute inset-x-0 top-0 h-1\"\n style={{ backgroundColor: design.palette.accent }}\n />\n <div className=\"relative flex h-full flex-col justify-between\">\n <div className=\"flex items-center justify-between gap-3\">\n <div className=\"flex min-w-0 items-center gap-2\">\n <span\n className=\"flex h-7 w-7 items-center justify-center rounded-full\"\n style={{\n color: design.palette.background,\n backgroundColor: design.palette.accent,\n }}\n >\n <Gift className=\"h-4 w-4\" />\n </span>\n <div className=\"min-w-0\">\n <p className=\"truncate text-sm font-semibold\">{merchantName}</p>\n <p className=\"text-[10px] font-medium uppercase opacity-65\">\n Gift card\n </p>\n </div>\n </div>\n {amount && (\n <span className=\"shrink-0 text-sm font-medium opacity-80\">\n {amount}\n </span>\n )}\n </div>\n <div>\n <p className=\"text-lg font-semibold\">{design.name}</p>\n <div className=\"mt-3 grid grid-cols-2 gap-3\">\n <div className=\"min-w-0\">\n <p className=\"text-[10px] font-semibold uppercase opacity-55\">To</p>\n <p className=\"truncate text-sm font-medium\">\n {recipientName || 'Someone special'}\n </p>\n </div>\n <div className=\"min-w-0\">\n <p className=\"text-[10px] font-semibold uppercase opacity-55\">\n From\n </p>\n <p className=\"truncate text-sm font-medium\">\n {senderName || merchantName}\n </p>\n </div>\n </div>\n {code && <p className=\"mt-2 truncate font-mono text-xs opacity-70\">{code}</p>}\n </div>\n </div>\n </div>\n );\n}"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/features/gift-card-artwork.tsx"],"sourcesContent":["import { Gift } from 'lucide-react';\nimport type { GiftCardDesignSnapshot } from '@withpotter/lib/api';\n\nexport const GIFT_CARD_PRESETS: GiftCardDesignSnapshot[] = [\n {\n designId: 'gold',\n name: 'Gold',\n imageUrl: null,\n palette: {\n background: '#a16207',\n foreground: '#fffbeb',\n accent: '#fbbf24',\n },\n occasion: 'general',\n },\n {\n designId: 'platinum',\n name: 'Platinum',\n imageUrl: null,\n palette: {\n background: '#334155',\n foreground: '#f8fafc',\n accent: '#cbd5e1',\n },\n occasion: 'general',\n },\n {\n designId: 'bronze',\n name: 'Bronze',\n imageUrl: null,\n palette: {\n background: '#7c2d12',\n foreground: '#fff7ed',\n accent: '#fb923c',\n },\n occasion: 'general',\n },\n {\n designId: 'silver',\n name: 'Silver',\n imageUrl: null,\n palette: {\n background: '#475569',\n foreground: '#f8fafc',\n accent: '#e2e8f0',\n },\n occasion: 'general',\n },\n];\n\ninterface GiftCardArtworkProps {\n design: GiftCardDesignSnapshot;\n merchantName: string;\n amount?: string;\n recipientName?: string;\n senderName?: string;\n code?: string;\n className?: string;\n}\n\nexport interface GiftCardArtworkData\n extends Omit<GiftCardArtworkProps, 'className'> {}\n\nfunction escapeXml(value: string): string {\n return value\n .replaceAll('&', '&')\n .replaceAll('<', '<')\n .replaceAll('>', '>')\n .replaceAll('\"', '"')\n .replaceAll(\"'\", ''');\n}\n\nexport function createGiftCardSvg({\n design,\n merchantName,\n amount,\n recipientName,\n senderName,\n code,\n}: GiftCardArtworkData): string {\n const foreground = design.palette.foreground;\n const background = design.palette.background;\n const accent = design.palette.accent;\n\n return `<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1200\" height=\"756\" viewBox=\"0 0 1200 756\">\n <defs><pattern id=\"lines\" width=\"72\" height=\"72\" patternUnits=\"userSpaceOnUse\" patternTransform=\"rotate(45)\"><line x1=\"0\" y1=\"0\" x2=\"0\" y2=\"72\" stroke=\"${accent}\" stroke-opacity=\"0.14\" stroke-width=\"3\"/></pattern></defs>\n <rect width=\"1200\" height=\"756\" rx=\"32\" fill=\"${background}\"/>\n <rect width=\"1200\" height=\"756\" rx=\"32\" fill=\"url(#lines)\"/>\n <rect width=\"1200\" height=\"12\" rx=\"6\" fill=\"${accent}\"/>\n <circle cx=\"78\" cy=\"82\" r=\"28\" fill=\"${accent}\"/>\n <text x=\"126\" y=\"72\" fill=\"${foreground}\" font-family=\"Arial, sans-serif\" font-size=\"30\" font-weight=\"700\">${escapeXml(merchantName)}</text>\n <text x=\"126\" y=\"104\" fill=\"${foreground}\" fill-opacity=\"0.7\" font-family=\"Arial, sans-serif\" font-size=\"20\">GIFT CARD</text>\n ${amount ? `<text x=\"1120\" y=\"82\" text-anchor=\"end\" fill=\"${foreground}\" fill-opacity=\"0.82\" font-family=\"Arial, sans-serif\" font-size=\"26\" font-weight=\"600\">${escapeXml(amount)}</text>` : ''}\n <text x=\"72\" y=\"330\" fill=\"${foreground}\" font-family=\"Arial, sans-serif\" font-size=\"58\" font-weight=\"700\">${escapeXml(design.name)}</text>\n <text x=\"72\" y=\"470\" fill=\"${foreground}\" fill-opacity=\"0.58\" font-family=\"Arial, sans-serif\" font-size=\"18\" font-weight=\"700\">TO</text>\n <text x=\"72\" y=\"515\" fill=\"${foreground}\" font-family=\"Arial, sans-serif\" font-size=\"34\" font-weight=\"600\">${escapeXml(recipientName || 'Someone special')}</text>\n <text x=\"600\" y=\"470\" fill=\"${foreground}\" fill-opacity=\"0.58\" font-family=\"Arial, sans-serif\" font-size=\"18\" font-weight=\"700\">FROM</text>\n <text x=\"600\" y=\"515\" fill=\"${foreground}\" font-family=\"Arial, sans-serif\" font-size=\"34\" font-weight=\"600\">${escapeXml(senderName || merchantName)}</text>\n ${code ? `<text x=\"72\" y=\"664\" fill=\"${foreground}\" fill-opacity=\"0.58\" font-family=\"Arial, sans-serif\" font-size=\"18\" font-weight=\"700\">REDEEM WITH</text><text x=\"72\" y=\"704\" fill=\"${foreground}\" font-family=\"monospace\" font-size=\"30\" font-weight=\"700\">${escapeXml(code)}</text>` : ''}\n</svg>`;\n}\n\nexport async function downloadGiftCardArtwork(\n data: GiftCardArtworkData,\n): Promise<void> {\n const svgBlob = new Blob([createGiftCardSvg(data)], {\n type: 'image/svg+xml;charset=utf-8',\n });\n const svgUrl = URL.createObjectURL(svgBlob);\n const image = new Image();\n await new Promise<void>((resolve, reject) => {\n image.onload = () => resolve();\n image.onerror = () => reject(new Error('Unable to render gift card image'));\n image.src = svgUrl;\n });\n\n const canvas = document.createElement('canvas');\n canvas.width = 1200;\n canvas.height = 756;\n const context = canvas.getContext('2d');\n if (!context) {\n URL.revokeObjectURL(svgUrl);\n throw new Error('Unable to create gift card image');\n }\n context.drawImage(image, 0, 0, canvas.width, canvas.height);\n URL.revokeObjectURL(svgUrl);\n\n const pngBlob = await new Promise<Blob>((resolve, reject) => {\n canvas.toBlob(\n (blob) =>\n blob\n ? resolve(blob)\n : reject(new Error('Unable to encode gift card image')),\n 'image/png',\n );\n });\n const url = URL.createObjectURL(pngBlob);\n const link = document.createElement('a');\n link.href = url;\n link.download = `gift-card-${data.code || 'preview'}.png`;\n document.body.appendChild(link);\n link.click();\n link.remove();\n setTimeout(() => URL.revokeObjectURL(url), 0);\n}\n\nexport function createGiftCardEmailHtml(data: GiftCardArtworkData): string {\n const { design, merchantName, amount, recipientName, senderName, code } = data;\n return `<div style=\"margin:0;padding:32px 16px;background:#f4f4f5;font-family:Arial,sans-serif;color:#18181b\">\n <div style=\"max-width:620px;margin:0 auto\">\n <p style=\"margin:0 0 16px;font-size:16px\">${escapeXml(senderName || merchantName)} sent you a gift card.</p>\n <div style=\"padding:28px;border-radius:12px;background:${design.palette.background};color:${design.palette.foreground};border-top:6px solid ${design.palette.accent}\">\n <div style=\"font-size:20px;font-weight:700\">${escapeXml(merchantName)}</div>\n <div style=\"margin-top:4px;font-size:12px;opacity:.7\">GIFT CARD</div>\n <div style=\"margin-top:56px;font-size:30px;font-weight:700\">${escapeXml(design.name)}</div>\n <table role=\"presentation\" style=\"width:100%;margin-top:40px;color:inherit\"><tr>\n <td><div style=\"font-size:11px;opacity:.6\">TO</div><div style=\"margin-top:6px;font-size:18px;font-weight:600\">${escapeXml(recipientName || 'Someone special')}</div></td>\n <td><div style=\"font-size:11px;opacity:.6\">FROM</div><div style=\"margin-top:6px;font-size:18px;font-weight:600\">${escapeXml(senderName || merchantName)}</div></td>\n ${amount ? `<td style=\"text-align:right;font-size:16px;font-weight:600\">${escapeXml(amount)}</td>` : ''}\n </tr></table>\n ${code ? `<div style=\"margin-top:36px;font-size:11px;opacity:.6\">REDEEM WITH</div><div style=\"margin-top:6px;font-family:monospace;font-size:20px;font-weight:700\">${escapeXml(code)}</div>` : ''}\n </div>\n </div>\n</div>`;\n}\n\nexport function GiftCardArtwork({\n design,\n merchantName,\n amount,\n recipientName,\n senderName,\n code,\n className = '',\n}: GiftCardArtworkProps) {\n return (\n <div\n className={`relative aspect-[1.586/1] w-full overflow-hidden rounded-md p-5 ${className}`}\n style={{\n color: design.palette.foreground,\n backgroundColor: design.palette.background,\n backgroundImage: `repeating-linear-gradient(135deg, transparent 0 18px, ${design.palette.accent}22 18px 19px)`,\n }}\n >\n {design.imageUrl && (\n <img\n src={design.imageUrl}\n alt=\"\"\n className=\"absolute inset-0 h-full w-full object-cover\"\n />\n )}\n <div\n className=\"absolute inset-x-0 top-0 h-1\"\n style={{ backgroundColor: design.palette.accent }}\n />\n <div className=\"relative flex h-full flex-col justify-between\">\n <div className=\"flex items-center justify-between gap-3\">\n <div className=\"flex min-w-0 items-center gap-2\">\n <span\n className=\"flex h-7 w-7 items-center justify-center rounded-full\"\n style={{\n color: design.palette.background,\n backgroundColor: design.palette.accent,\n }}\n >\n <Gift className=\"h-4 w-4\" />\n </span>\n <div className=\"min-w-0\">\n <p className=\"truncate text-sm font-semibold\">{merchantName}</p>\n <p className=\"text-[10px] font-medium uppercase opacity-65\">\n Gift card\n </p>\n </div>\n </div>\n {amount && (\n <span className=\"shrink-0 text-sm font-medium opacity-80\">\n {amount}\n </span>\n )}\n </div>\n <div>\n <p className=\"text-lg font-semibold\">{design.name}</p>\n <div className=\"mt-3 grid grid-cols-2 gap-3\">\n <div className=\"min-w-0\">\n <p className=\"text-[10px] font-semibold uppercase opacity-55\">To</p>\n <p className=\"truncate text-sm font-medium\">\n {recipientName || 'Someone special'}\n </p>\n </div>\n <div className=\"min-w-0\">\n <p className=\"text-[10px] font-semibold uppercase opacity-55\">\n From\n </p>\n <p className=\"truncate text-sm font-medium\">\n {senderName || merchantName}\n </p>\n </div>\n </div>\n {code && <p className=\"mt-2 truncate font-mono text-xs opacity-70\">{code}</p>}\n </div>\n </div>\n </div>\n );\n}"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { GiftCardStatus } from '@withpotter/lib/api';
|
|
2
|
+
/**
|
|
3
|
+
* One vocabulary for a card's state, used by the filter chips, the status badge
|
|
4
|
+
* in the list and the hints. "Used" and "Redeemed" used to describe two
|
|
5
|
+
* different things with nearly the same word: a card whose balance is all
|
|
6
|
+
* spent, and the amount spent across every card. The state is now "Fully
|
|
7
|
+
* spent" and the amount is "Spent".
|
|
8
|
+
*/
|
|
9
|
+
export declare const STATUS_LABEL: Record<GiftCardStatus, string>;
|
|
10
|
+
export declare const STATUS_HINT: Record<GiftCardStatus, string>;
|
|
11
|
+
export declare const ALL_HINT = "Every card, whatever its state.";
|
|
12
|
+
export interface StatusFilter {
|
|
13
|
+
value: GiftCardStatus | '';
|
|
14
|
+
label: string;
|
|
15
|
+
hint: string;
|
|
16
|
+
}
|
|
17
|
+
export declare const STATUS_FILTERS: StatusFilter[];
|
|
18
|
+
/** The two lines behind the info icon beside the page title. */
|
|
19
|
+
export declare const PAGE_HINT_LINES: string[];
|
|
20
|
+
/**
|
|
21
|
+
* True when there is nothing to show and nothing narrowing the list: no cards
|
|
22
|
+
* have ever been issued. A filtered list that comes back empty is not first
|
|
23
|
+
* run, and neither is a page still loading.
|
|
24
|
+
*/
|
|
25
|
+
export declare function isFirstRun(input: {
|
|
26
|
+
loading: boolean;
|
|
27
|
+
search: string;
|
|
28
|
+
status: GiftCardStatus | '';
|
|
29
|
+
count: number;
|
|
30
|
+
totalIssued: number | null | undefined;
|
|
31
|
+
}): boolean;
|
|
32
|
+
/** The symbol a currency is written with, falling back to its code. */
|
|
33
|
+
export declare function currencySymbol(currency: string): string;
|
|
34
|
+
/**
|
|
35
|
+
* Shares one request between callers asking for the same thing at the same
|
|
36
|
+
* time. A key is forgotten the moment its request settles, so a later call
|
|
37
|
+
* (a reload, a poll) still goes to the network.
|
|
38
|
+
*/
|
|
39
|
+
export declare function createInFlightDeduper(): {
|
|
40
|
+
run<T>(key: string, start: () => Promise<T>): Promise<T>;
|
|
41
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
const STATUS_LABEL = {
|
|
2
|
+
active: "Active",
|
|
3
|
+
used: "Fully spent",
|
|
4
|
+
expired: "Expired",
|
|
5
|
+
cancelled: "Cancelled"
|
|
6
|
+
};
|
|
7
|
+
const STATUS_HINT = {
|
|
8
|
+
active: "Can still be spent.",
|
|
9
|
+
used: "The whole balance has been spent.",
|
|
10
|
+
expired: "Passed its expiry date.",
|
|
11
|
+
cancelled: "Voided by you. The record is kept."
|
|
12
|
+
};
|
|
13
|
+
const ALL_HINT = "Every card, whatever its state.";
|
|
14
|
+
const STATUS_FILTERS = [
|
|
15
|
+
{ value: "", label: "All", hint: ALL_HINT },
|
|
16
|
+
...["active", "used", "expired", "cancelled"].map((value) => ({
|
|
17
|
+
value,
|
|
18
|
+
label: STATUS_LABEL[value],
|
|
19
|
+
hint: STATUS_HINT[value]
|
|
20
|
+
}))
|
|
21
|
+
];
|
|
22
|
+
const PAGE_HINT_LINES = [
|
|
23
|
+
"Credit a customer pays for now and spends later. Share the code with them.",
|
|
24
|
+
"Open a card to email it or redeem some of its balance. Check balance looks up a code."
|
|
25
|
+
];
|
|
26
|
+
function isFirstRun(input) {
|
|
27
|
+
return !input.loading && !input.search.trim() && !input.status && input.count === 0 && (input.totalIssued ?? 0) === 0;
|
|
28
|
+
}
|
|
29
|
+
function currencySymbol(currency) {
|
|
30
|
+
try {
|
|
31
|
+
const part = new Intl.NumberFormat("en", {
|
|
32
|
+
style: "currency",
|
|
33
|
+
currency,
|
|
34
|
+
currencyDisplay: "narrowSymbol"
|
|
35
|
+
}).formatToParts(0).find((p) => p.type === "currency");
|
|
36
|
+
return part?.value ?? currency;
|
|
37
|
+
} catch {
|
|
38
|
+
return currency;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function createInFlightDeduper() {
|
|
42
|
+
const inFlight = /* @__PURE__ */ new Map();
|
|
43
|
+
return {
|
|
44
|
+
run(key, start) {
|
|
45
|
+
const existing = inFlight.get(key);
|
|
46
|
+
if (existing) return existing;
|
|
47
|
+
const promise = start().finally(() => {
|
|
48
|
+
if (inFlight.get(key) === promise) inFlight.delete(key);
|
|
49
|
+
});
|
|
50
|
+
inFlight.set(key, promise);
|
|
51
|
+
return promise;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
export {
|
|
56
|
+
ALL_HINT,
|
|
57
|
+
PAGE_HINT_LINES,
|
|
58
|
+
STATUS_FILTERS,
|
|
59
|
+
STATUS_HINT,
|
|
60
|
+
STATUS_LABEL,
|
|
61
|
+
createInFlightDeduper,
|
|
62
|
+
currencySymbol,
|
|
63
|
+
isFirstRun
|
|
64
|
+
};
|
|
65
|
+
//# sourceMappingURL=gift-card-logic.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../src/features/gift-cards/gift-card-logic.ts"],"sourcesContent":["import type { GiftCardStatus } from '@withpotter/lib/api';\n\n/**\n * One vocabulary for a card's state, used by the filter chips, the status badge\n * in the list and the hints. \"Used\" and \"Redeemed\" used to describe two\n * different things with nearly the same word: a card whose balance is all\n * spent, and the amount spent across every card. The state is now \"Fully\n * spent\" and the amount is \"Spent\".\n */\nexport const STATUS_LABEL: Record<GiftCardStatus, string> = {\n active: 'Active',\n used: 'Fully spent',\n expired: 'Expired',\n cancelled: 'Cancelled',\n};\n\nexport const STATUS_HINT: Record<GiftCardStatus, string> = {\n active: 'Can still be spent.',\n used: 'The whole balance has been spent.',\n expired: 'Passed its expiry date.',\n cancelled: 'Voided by you. The record is kept.',\n};\n\nexport const ALL_HINT = 'Every card, whatever its state.';\n\nexport interface StatusFilter {\n value: GiftCardStatus | '';\n label: string;\n hint: string;\n}\n\nexport const STATUS_FILTERS: StatusFilter[] = [\n { value: '', label: 'All', hint: ALL_HINT },\n ...(['active', 'used', 'expired', 'cancelled'] as const).map((value) => ({\n value,\n label: STATUS_LABEL[value],\n hint: STATUS_HINT[value],\n })),\n];\n\n/** The two lines behind the info icon beside the page title. */\nexport const PAGE_HINT_LINES = [\n 'Credit a customer pays for now and spends later. Share the code with them.',\n 'Open a card to email it or redeem some of its balance. Check balance looks up a code.',\n];\n\n/**\n * True when there is nothing to show and nothing narrowing the list: no cards\n * have ever been issued. A filtered list that comes back empty is not first\n * run, and neither is a page still loading.\n */\nexport function isFirstRun(input: {\n loading: boolean;\n search: string;\n status: GiftCardStatus | '';\n count: number;\n totalIssued: number | null | undefined;\n}): boolean {\n return (\n !input.loading &&\n !input.search.trim() &&\n !input.status &&\n input.count === 0 &&\n (input.totalIssued ?? 0) === 0\n );\n}\n\n/** The symbol a currency is written with, falling back to its code. */\nexport function currencySymbol(currency: string): string {\n try {\n const part = new Intl.NumberFormat('en', {\n style: 'currency',\n currency,\n currencyDisplay: 'narrowSymbol',\n })\n .formatToParts(0)\n .find((p) => p.type === 'currency');\n return part?.value ?? currency;\n } catch {\n return currency;\n }\n}\n\n/**\n * Shares one request between callers asking for the same thing at the same\n * time. A key is forgotten the moment its request settles, so a later call\n * (a reload, a poll) still goes to the network.\n */\nexport function createInFlightDeduper() {\n const inFlight = new Map<string, Promise<unknown>>();\n return {\n run<T>(key: string, start: () => Promise<T>): Promise<T> {\n const existing = inFlight.get(key);\n if (existing) return existing as Promise<T>;\n const promise = start().finally(() => {\n if (inFlight.get(key) === promise) inFlight.delete(key);\n });\n inFlight.set(key, promise);\n return promise;\n },\n };\n}\n"],"mappings":"AASO,MAAM,eAA+C;AAAA,EAC1D,QAAQ;AAAA,EACR,MAAM;AAAA,EACN,SAAS;AAAA,EACT,WAAW;AACb;AAEO,MAAM,cAA8C;AAAA,EACzD,QAAQ;AAAA,EACR,MAAM;AAAA,EACN,SAAS;AAAA,EACT,WAAW;AACb;AAEO,MAAM,WAAW;AAQjB,MAAM,iBAAiC;AAAA,EAC5C,EAAE,OAAO,IAAI,OAAO,OAAO,MAAM,SAAS;AAAA,EAC1C,GAAI,CAAC,UAAU,QAAQ,WAAW,WAAW,EAAY,IAAI,CAAC,WAAW;AAAA,IACvE;AAAA,IACA,OAAO,aAAa,KAAK;AAAA,IACzB,MAAM,YAAY,KAAK;AAAA,EACzB,EAAE;AACJ;AAGO,MAAM,kBAAkB;AAAA,EAC7B;AAAA,EACA;AACF;AAOO,SAAS,WAAW,OAMf;AACV,SACE,CAAC,MAAM,WACP,CAAC,MAAM,OAAO,KAAK,KACnB,CAAC,MAAM,UACP,MAAM,UAAU,MACf,MAAM,eAAe,OAAO;AAEjC;AAGO,SAAS,eAAe,UAA0B;AACvD,MAAI;AACF,UAAM,OAAO,IAAI,KAAK,aAAa,MAAM;AAAA,MACvC,OAAO;AAAA,MACP;AAAA,MACA,iBAAiB;AAAA,IACnB,CAAC,EACE,cAAc,CAAC,EACf,KAAK,CAAC,MAAM,EAAE,SAAS,UAAU;AACpC,WAAO,MAAM,SAAS;AAAA,EACxB,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAOO,SAAS,wBAAwB;AACtC,QAAM,WAAW,oBAAI,IAA8B;AACnD,SAAO;AAAA,IACL,IAAO,KAAa,OAAqC;AACvD,YAAM,WAAW,SAAS,IAAI,GAAG;AACjC,UAAI,SAAU,QAAO;AACrB,YAAM,UAAU,MAAM,EAAE,QAAQ,MAAM;AACpC,YAAI,SAAS,IAAI,GAAG,MAAM,QAAS,UAAS,OAAO,GAAG;AAAA,MACxD,CAAC;AACD,eAAS,IAAI,KAAK,OAAO;AACzB,aAAO;AAAA,IACT;AAAA,EACF;AACF;","names":[]}
|