@forgecart/cli 2.202608121449.0 → 2.202608200713.0
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/src/commands/init.d.ts +16 -0
- package/dist/src/commands/init.js +18 -7
- package/dist/src/commands/init.js.map +1 -1
- package/package.json +1 -1
- package/templates/storefront/README.md +145 -61
- package/templates/storefront/next.config.js +20 -0
- package/templates/storefront/package.json +10 -2
- package/templates/storefront/postcss.config.js +1 -2
- package/templates/storefront/src/app/%5F%5Ffc/track/route.ts +30 -12
- package/templates/storefront/src/app/__forge_beacon/route.ts +1 -2
- package/templates/storefront/src/app/api/%5F%5Fbackend/methods/route.ts +27 -0
- package/templates/storefront/src/app/cart/page.tsx +33 -6
- package/templates/storefront/src/app/checkout/page.tsx +40 -0
- package/templates/storefront/src/app/error.tsx +21 -0
- package/templates/storefront/src/app/global-error.tsx +23 -0
- package/templates/storefront/src/app/globals.css +105 -8
- package/templates/storefront/src/app/layout.tsx +45 -17
- package/templates/storefront/src/app/page.tsx +153 -43
- package/templates/storefront/src/app/ping/route.ts +1 -2
- package/templates/storefront/src/app/products/[slug]/not-found.tsx +3 -8
- package/templates/storefront/src/app/products/[slug]/page.tsx +69 -23
- package/templates/storefront/src/app/products/page.tsx +52 -9
- package/templates/storefront/src/components/CartView.tsx +244 -117
- package/templates/storefront/src/components/ForgeTracker.tsx +131 -26
- package/templates/storefront/src/components/Header.tsx +8 -10
- package/templates/storefront/src/components/ProductCard.tsx +25 -13
- package/templates/storefront/src/components/ProductPurchase.tsx +13 -18
- package/templates/storefront/src/components/checkout/AddressStep.tsx +288 -0
- package/templates/storefront/src/components/checkout/CheckoutFlow.tsx +543 -0
- package/templates/storefront/src/components/checkout/CheckoutGate.tsx +45 -0
- package/templates/storefront/src/components/checkout/PaymentElementForm.tsx +138 -0
- package/templates/storefront/src/components/checkout/PaymentFormEmbed.tsx +89 -0
- package/templates/storefront/src/components/checkout/RatesStep.tsx +113 -0
- package/templates/storefront/src/instrumentation.ts +34 -0
- package/templates/storefront/src/lib/action-result.ts +30 -0
- package/templates/storefront/src/lib/backend-actions.ts +20 -0
- package/templates/storefront/src/lib/backend-client.ts +47 -0
- package/templates/storefront/src/lib/cart-context.tsx +157 -22
- package/templates/storefront/src/lib/checkout-session.ts +185 -0
- package/templates/storefront/src/lib/error-messages.ts +24 -0
- package/templates/storefront/src/lib/experiments.ts +42 -44
- package/templates/storefront/src/lib/forgecart.ts +61 -78
- package/templates/storefront/src/lib/format.ts +91 -0
- package/templates/storefront/src/lib/session-actions.ts +54 -0
- package/templates/storefront/src/lib/shop-config.ts +44 -0
- package/templates/storefront/src/lib/shop-session.ts +114 -0
- package/templates/storefront/src/lib/track-forward.ts +14 -1
- package/templates/storefront/src/lib/uuid.ts +19 -0
- package/templates/storefront/src/server/app.module.ts +18 -0
- package/templates/storefront/src/server/backend-api.ts +26 -0
- package/templates/storefront/src/server/backend-method.decorator.ts +23 -0
- package/templates/storefront/src/server/bootstrap.ts +122 -0
- package/templates/storefront/src/server/customer-extras/customer-extras.module.ts +13 -0
- package/templates/storefront/src/server/customer-extras/service/customer-extras.service.ts +58 -0
- package/templates/storefront/src/server/customer-extras/type/customer-extras.types.ts +11 -0
- package/templates/storefront/src/server/forgecart/forgecart-client.factory.ts +69 -0
- package/templates/storefront/src/server/forgecart/forgecart.module.ts +9 -0
- package/templates/storefront/src/server/runner.ts +91 -0
- package/templates/storefront/src/server/types.ts +36 -0
- package/templates/storefront/tsconfig.json +2 -0
- package/templates/storefront/.env.example +0 -12
- package/templates/storefront/src/lib/cart-actions.ts +0 -139
- package/templates/storefront/tailwind.config.js +0 -8
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
import { usePathname } from 'next/navigation';
|
|
4
4
|
import { useEffect } from 'react';
|
|
5
5
|
|
|
6
|
+
import { mintUuid } from '../lib/uuid';
|
|
7
|
+
|
|
6
8
|
/**
|
|
7
9
|
* Storefront event tracker (design doc S7). Renders nothing.
|
|
8
10
|
*
|
|
@@ -28,12 +30,14 @@ import { useEffect } from 'react';
|
|
|
28
30
|
* one on refocus) — liveness plumbing only, excluded from aggregates.
|
|
29
31
|
*
|
|
30
32
|
* Delivery: events buffer and flush as one batch at {@link FLUSH_MAX_EVENTS}
|
|
31
|
-
* events or after
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
33
|
+
* events or after an age timer via `fetch(keepalive)` — the FIRST batch waits
|
|
34
|
+
* {@link ESTABLISHMENT_GRACE_MS}, every later batch {@link FLUSH_INTERVAL_MS}
|
|
35
|
+
* (see the grace rationale on the constant); `sendBeacon` is used ONLY for the
|
|
36
|
+
* `pagehide` flush (its unload-time queueing guarantee is the one thing
|
|
37
|
+
* keepalive fetch lacks). Every event carries a client-minted `eventId`
|
|
38
|
+
* (`crypto.randomUUID`) — the server dedupes on it, so an accidental
|
|
39
|
+
* double-send is harmless. Delivery is fire-and-forget: failures are
|
|
40
|
+
* swallowed and never retried; analytics must never break the storefront.
|
|
37
41
|
*
|
|
38
42
|
* Deliberately NO 30-second same-path dedup (the old `ForgeAnalytics`
|
|
39
43
|
* behavior): it was live-map-correct but funnel-lossy — A→B→A inside 30s
|
|
@@ -54,8 +58,27 @@ const HEARTBEAT_MIN_SPACING_MS = 30_000;
|
|
|
54
58
|
/** Batch flush thresholds: whichever of size / age is hit first sends. */
|
|
55
59
|
const FLUSH_MAX_EVENTS = 10;
|
|
56
60
|
const FLUSH_INTERVAL_MS = 500;
|
|
61
|
+
/**
|
|
62
|
+
* Age timer for the FIRST batch only. On a cookie-less first touch the relay
|
|
63
|
+
* and the first cart Server Action race to mint the shopper session, and the
|
|
64
|
+
* browser keeps whichever `Set-Cookie` lands last — if the tracker's ~500ms
|
|
65
|
+
* page_view batch wins that race against a fast add-to-cart, the cookie flips
|
|
66
|
+
* away from the session holding the just-created cart. Holding the initial
|
|
67
|
+
* flush for this grace lets the cart's cookie land first, so the relay batch
|
|
68
|
+
* then CARRIES it and rides the cart's session instead of minting a rival
|
|
69
|
+
* (design doc S7: the first tracked interaction establishes the identity the
|
|
70
|
+
* cart later reuses — establishment still happens, just not mid-race).
|
|
71
|
+
* Events are timestamped at enqueue (`occurredAt`), so deferral loses nothing.
|
|
72
|
+
*/
|
|
73
|
+
const ESTABLISHMENT_GRACE_MS = 2_000;
|
|
57
74
|
/** Same-signal suppression window (per click slug; per page_view path). */
|
|
58
75
|
const SUPPRESS_MS = 300;
|
|
76
|
+
/**
|
|
77
|
+
* Minimum VISIBLE dwell before a route's scroll_depth summary is worth a
|
|
78
|
+
* row (#1022 phase 2b) — filters bounce-through navigations and StrictMode's
|
|
79
|
+
* dev-only phantom unmount.
|
|
80
|
+
*/
|
|
81
|
+
const ENGAGEMENT_MIN_DWELL_MS = 250;
|
|
59
82
|
const TRACK_ENDPOINT = '/__fc/track';
|
|
60
83
|
|
|
61
84
|
/** One buffered event — the relay forwards these fields to `trackEvent`. */
|
|
@@ -71,6 +94,8 @@ interface TrackedEvent {
|
|
|
71
94
|
// exactly one tracker per page.
|
|
72
95
|
let buffer: TrackedEvent[] = [];
|
|
73
96
|
let flushTimer: number | null = null;
|
|
97
|
+
/** True once any batch has gone out — ends the establishment grace. */
|
|
98
|
+
let firstBatchSent = false;
|
|
74
99
|
const lastClickAtBySlug = new Map<string, number>();
|
|
75
100
|
let lastPageView: { path: string; at: number } | null = null;
|
|
76
101
|
let previousPathname: string | null = null;
|
|
@@ -87,19 +112,12 @@ function hasTrackingConsent(): boolean {
|
|
|
87
112
|
}
|
|
88
113
|
|
|
89
114
|
/**
|
|
90
|
-
* Client-side idempotency key
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
* hand, because a thrown TypeError inside a click listener would violate the
|
|
94
|
-
* "analytics never breaks the storefront" invariant.
|
|
115
|
+
* Client-side idempotency key via the shared insecure-context-safe minter
|
|
116
|
+
* (lib/uuid.ts) — a thrown TypeError inside a click listener would violate
|
|
117
|
+
* the "analytics never breaks the storefront" invariant.
|
|
95
118
|
*/
|
|
96
119
|
function mintEventId(): string {
|
|
97
|
-
|
|
98
|
-
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
99
|
-
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
|
100
|
-
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
101
|
-
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
102
|
-
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
120
|
+
return mintUuid();
|
|
103
121
|
}
|
|
104
122
|
|
|
105
123
|
/** Stamp and buffer one event; flush on size, else arm the age timer. */
|
|
@@ -118,7 +136,8 @@ function enqueue(eventType: string, properties?: Record<string, unknown>): void
|
|
|
118
136
|
return;
|
|
119
137
|
}
|
|
120
138
|
if (flushTimer === null) {
|
|
121
|
-
|
|
139
|
+
const delay = firstBatchSent ? FLUSH_INTERVAL_MS : ESTABLISHMENT_GRACE_MS;
|
|
140
|
+
flushTimer = window.setTimeout(flush, delay);
|
|
122
141
|
}
|
|
123
142
|
}
|
|
124
143
|
|
|
@@ -130,6 +149,9 @@ function drain(): TrackedEvent[] {
|
|
|
130
149
|
}
|
|
131
150
|
const events = buffer;
|
|
132
151
|
buffer = [];
|
|
152
|
+
// The grace ends once a batch actually goes out — from then on the age
|
|
153
|
+
// timer runs at the normal cadence.
|
|
154
|
+
if (events.length > 0) firstBatchSent = true;
|
|
133
155
|
return events;
|
|
134
156
|
}
|
|
135
157
|
|
|
@@ -166,15 +188,13 @@ function flushOnPagehide(): void {
|
|
|
166
188
|
}).catch(() => undefined);
|
|
167
189
|
}
|
|
168
190
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
}) {
|
|
191
|
+
/**
|
|
192
|
+
* `enabled` is computed SERVER-side (env presence) and passed as a boolean —
|
|
193
|
+
* the tracker only needs to know whether the relay is configured; the channel
|
|
194
|
+
* token itself must never serialize into the RSC payload.
|
|
195
|
+
*/
|
|
196
|
+
export function ForgeTracker({ enabled }: { enabled: boolean }) {
|
|
176
197
|
const pathname = usePathname();
|
|
177
|
-
const enabled = Boolean(shopApiUrl && channelToken);
|
|
178
198
|
|
|
179
199
|
// page_view — initial load + every route change.
|
|
180
200
|
useEffect(() => {
|
|
@@ -263,5 +283,90 @@ export function ForgeTracker({
|
|
|
263
283
|
};
|
|
264
284
|
}, [enabled]);
|
|
265
285
|
|
|
286
|
+
// scroll_depth — ONE engagement summary per route (#1022 phase 2b): the
|
|
287
|
+
// max scroll depth reached (percent, 0-100) and the VISIBLE dwell time,
|
|
288
|
+
// emitted when the route unmounts (client-side navigation) or the page
|
|
289
|
+
// hides. Rides the dormant `scroll_depth` event type already in the
|
|
290
|
+
// default shop allowlist; the server hoists both properties into the
|
|
291
|
+
// typed `scrollDepth`/`durationMs` ClickHouse columns (CH 005).
|
|
292
|
+
useEffect(() => {
|
|
293
|
+
if (!enabled) return;
|
|
294
|
+
if (window.parent !== window) return;
|
|
295
|
+
|
|
296
|
+
let maxDepthPct = 0;
|
|
297
|
+
let visibleMs = 0;
|
|
298
|
+
let visibleSince: number | null =
|
|
299
|
+
document.visibilityState === 'visible' ? performance.now() : null;
|
|
300
|
+
let ticking = false;
|
|
301
|
+
let emitted = false;
|
|
302
|
+
|
|
303
|
+
const measure = (): void => {
|
|
304
|
+
ticking = false;
|
|
305
|
+
const doc = document.documentElement;
|
|
306
|
+
// A page shorter than the viewport is fully seen — 100 by definition;
|
|
307
|
+
// otherwise percent of total document height brought into view.
|
|
308
|
+
const pct =
|
|
309
|
+
doc.scrollHeight <= window.innerHeight
|
|
310
|
+
? 100
|
|
311
|
+
: Math.min(
|
|
312
|
+
100,
|
|
313
|
+
Math.round(((window.scrollY + window.innerHeight) / doc.scrollHeight) * 100),
|
|
314
|
+
);
|
|
315
|
+
if (pct > maxDepthPct) maxDepthPct = pct;
|
|
316
|
+
};
|
|
317
|
+
const onScroll = (): void => {
|
|
318
|
+
if (ticking) return;
|
|
319
|
+
ticking = true;
|
|
320
|
+
requestAnimationFrame(measure);
|
|
321
|
+
};
|
|
322
|
+
const onVisibility = (): void => {
|
|
323
|
+
const now = performance.now();
|
|
324
|
+
if (document.visibilityState === 'visible') {
|
|
325
|
+
visibleSince ??= now;
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
if (visibleSince !== null) {
|
|
329
|
+
visibleMs += now - visibleSince;
|
|
330
|
+
visibleSince = null;
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
const emit = (): void => {
|
|
334
|
+
if (emitted) return;
|
|
335
|
+
if (visibleSince !== null) {
|
|
336
|
+
visibleMs += performance.now() - visibleSince;
|
|
337
|
+
visibleSince = null;
|
|
338
|
+
}
|
|
339
|
+
// Dwell floor: a route that never accumulated meaningful visible time
|
|
340
|
+
// has no engagement story — this also swallows StrictMode's dev-only
|
|
341
|
+
// mount/unmount phantom, which would otherwise emit a zero-dwell row.
|
|
342
|
+
if (visibleMs < ENGAGEMENT_MIN_DWELL_MS) return;
|
|
343
|
+
emitted = true;
|
|
344
|
+
enqueue('scroll_depth', {
|
|
345
|
+
path: window.location.pathname,
|
|
346
|
+
scrollDepth: maxDepthPct,
|
|
347
|
+
durationMs: Math.round(visibleMs),
|
|
348
|
+
});
|
|
349
|
+
};
|
|
350
|
+
const onPagehide = (): void => {
|
|
351
|
+
// Emit BEFORE the flush so the summary rides the pagehide beacon —
|
|
352
|
+
// flushOnPagehide drains-and-no-ops on empty, so the sibling pagehide
|
|
353
|
+
// listener's own call stays harmless.
|
|
354
|
+
emit();
|
|
355
|
+
flushOnPagehide();
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
measure(); // above-the-fold baseline before any scroll fires
|
|
359
|
+
|
|
360
|
+
window.addEventListener('scroll', onScroll, { passive: true });
|
|
361
|
+
document.addEventListener('visibilitychange', onVisibility);
|
|
362
|
+
window.addEventListener('pagehide', onPagehide);
|
|
363
|
+
return () => {
|
|
364
|
+
window.removeEventListener('scroll', onScroll);
|
|
365
|
+
document.removeEventListener('visibilitychange', onVisibility);
|
|
366
|
+
window.removeEventListener('pagehide', onPagehide);
|
|
367
|
+
emit(); // client-side route change: summarize the route being left
|
|
368
|
+
};
|
|
369
|
+
}, [enabled, pathname]);
|
|
370
|
+
|
|
266
371
|
return null;
|
|
267
372
|
}
|
|
@@ -20,24 +20,22 @@ export function Header() {
|
|
|
20
20
|
}, []);
|
|
21
21
|
|
|
22
22
|
return (
|
|
23
|
-
<header className="border-b border-
|
|
24
|
-
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-
|
|
25
|
-
<Link href="/" className="text-lg font-bold tracking-tight
|
|
23
|
+
<header className="sticky top-0 z-30 border-b border-base-300 bg-base-100">
|
|
24
|
+
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-3">
|
|
25
|
+
<Link href="/" className="text-lg font-bold tracking-tight">
|
|
26
26
|
Storefront
|
|
27
27
|
</Link>
|
|
28
|
-
<nav className="flex items-center gap-
|
|
29
|
-
<Link href="/" className="
|
|
28
|
+
<nav aria-label="Store" className="flex items-center gap-1 text-sm font-medium">
|
|
29
|
+
<Link href="/" className="btn btn-ghost btn-sm">
|
|
30
30
|
Home
|
|
31
31
|
</Link>
|
|
32
|
-
<Link href="/products" className="
|
|
32
|
+
<Link href="/products" className="btn btn-ghost btn-sm">
|
|
33
33
|
Products
|
|
34
34
|
</Link>
|
|
35
|
-
<Link href="/cart" data-fc-track="header-cart" className="
|
|
35
|
+
<Link href="/cart" data-fc-track="header-cart" className="btn btn-ghost btn-sm">
|
|
36
36
|
Cart
|
|
37
37
|
{mounted && itemCount > 0 && (
|
|
38
|
-
<span className="
|
|
39
|
-
{itemCount}
|
|
40
|
-
</span>
|
|
38
|
+
<span className="badge badge-primary badge-sm font-semibold">{itemCount}</span>
|
|
41
39
|
)}
|
|
42
40
|
</Link>
|
|
43
41
|
</nav>
|
|
@@ -1,38 +1,50 @@
|
|
|
1
1
|
import Link from 'next/link';
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import type { Product } from '../lib/forgecart';
|
|
4
|
+
import { formatPrice, getStartingPrice } from '../lib/format';
|
|
4
5
|
|
|
5
|
-
/**
|
|
6
|
-
|
|
6
|
+
/**
|
|
7
|
+
* A product tile linking to its detail page — canonical daisyUI card
|
|
8
|
+
* (figure + card-body + card-title). Used by the home and grid pages;
|
|
9
|
+
* `emphasis` renders the home grid's large lead tile with a bigger title.
|
|
10
|
+
*/
|
|
11
|
+
export function ProductCard({
|
|
12
|
+
product,
|
|
13
|
+
emphasis = false,
|
|
14
|
+
}: {
|
|
15
|
+
product: Product;
|
|
16
|
+
emphasis?: boolean;
|
|
17
|
+
}) {
|
|
7
18
|
const startingPrice = getStartingPrice(product);
|
|
8
19
|
|
|
9
20
|
return (
|
|
10
21
|
<Link
|
|
11
22
|
href={`/products/${product.slug}`}
|
|
12
23
|
data-fc-track="product-card"
|
|
13
|
-
className="
|
|
24
|
+
className="card card-border group h-full bg-base-100 transition-shadow hover:shadow-md"
|
|
14
25
|
>
|
|
15
|
-
<
|
|
26
|
+
<figure className="aspect-square bg-base-200">
|
|
16
27
|
{product.featuredAsset ? (
|
|
17
28
|
// Plain <img> keeps the template dependency-free of next/image config.
|
|
18
29
|
// eslint-disable-next-line @next/next/no-img-element
|
|
19
30
|
<img
|
|
20
31
|
src={product.featuredAsset.preview}
|
|
21
32
|
alt={product.name}
|
|
22
|
-
|
|
33
|
+
loading="lazy"
|
|
34
|
+
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
|
23
35
|
/>
|
|
24
36
|
) : (
|
|
25
|
-
<div className="flex h-full w-full items-center justify-center text-sm text-
|
|
37
|
+
<div className="flex h-full w-full items-center justify-center text-sm text-base-content/40">
|
|
26
38
|
No image
|
|
27
39
|
</div>
|
|
28
40
|
)}
|
|
29
|
-
</
|
|
30
|
-
<div className="
|
|
31
|
-
<h3 className=
|
|
41
|
+
</figure>
|
|
42
|
+
<div className="card-body gap-1 p-4">
|
|
43
|
+
<h3 className={`card-title font-medium ${emphasis ? 'text-lg' : 'text-base'}`}>
|
|
44
|
+
{product.name}
|
|
45
|
+
</h3>
|
|
32
46
|
{startingPrice !== null && (
|
|
33
|
-
<p className="text-sm text-
|
|
34
|
-
From {formatPrice(startingPrice)}
|
|
35
|
-
</p>
|
|
47
|
+
<p className="text-sm text-base-content/60">From {formatPrice(startingPrice)}</p>
|
|
36
48
|
)}
|
|
37
49
|
</div>
|
|
38
50
|
</Link>
|
|
@@ -3,16 +3,13 @@
|
|
|
3
3
|
import { useEffect, useMemo, useState } from 'react';
|
|
4
4
|
|
|
5
5
|
import { useCart } from '../lib/cart-context';
|
|
6
|
+
import type { Product, ProductVariant, SellingPlan, SellingPlanGroup } from '../lib/forgecart';
|
|
6
7
|
import {
|
|
7
8
|
formatPrice,
|
|
8
9
|
getPlanCadenceLabel,
|
|
9
10
|
getPlanPreviewPrice,
|
|
10
11
|
getPlanSavingsLabel,
|
|
11
|
-
|
|
12
|
-
type ProductVariant,
|
|
13
|
-
type SellingPlan,
|
|
14
|
-
type SellingPlanGroup,
|
|
15
|
-
} from '../lib/forgecart';
|
|
12
|
+
} from '../lib/format';
|
|
16
13
|
|
|
17
14
|
/**
|
|
18
15
|
* Variant picker + purchase-options + add-to-cart for the product detail page.
|
|
@@ -127,14 +124,14 @@ export function ProductPurchase({
|
|
|
127
124
|
return (
|
|
128
125
|
<div className="space-y-4">
|
|
129
126
|
{variant && previewPrice !== null ? (
|
|
130
|
-
<p className="text-xl font-semibold text-
|
|
127
|
+
<p className="text-xl font-semibold text-base-content">{formatPrice(previewPrice)}</p>
|
|
131
128
|
) : (
|
|
132
|
-
<p className="text-sm text-
|
|
129
|
+
<p className="text-sm text-base-content/60">Select options to see the price.</p>
|
|
133
130
|
)}
|
|
134
131
|
|
|
135
132
|
{product.optionGroups.map((group) => (
|
|
136
133
|
<div key={group.id}>
|
|
137
|
-
<h3 className="mb-1 text-sm font-medium text-
|
|
134
|
+
<h3 className="mb-1 text-sm font-medium text-base-content/70">{group.name}</h3>
|
|
138
135
|
<div className="flex flex-wrap gap-2">
|
|
139
136
|
{group.options.map((opt) => {
|
|
140
137
|
const active = selected[group.id] === opt.id;
|
|
@@ -145,8 +142,8 @@ export function ProductPurchase({
|
|
|
145
142
|
onClick={() => setSelected((s) => ({ ...s, [group.id]: opt.id }))}
|
|
146
143
|
className={`rounded-md border px-3 py-1.5 text-sm transition ${
|
|
147
144
|
active
|
|
148
|
-
? 'border-
|
|
149
|
-
: 'border-
|
|
145
|
+
? 'border-primary bg-primary text-primary-content'
|
|
146
|
+
: 'border-base-300 text-base-content/70 hover:border-primary'
|
|
150
147
|
}`}
|
|
151
148
|
>
|
|
152
149
|
{opt.name}
|
|
@@ -159,7 +156,7 @@ export function ProductPurchase({
|
|
|
159
156
|
|
|
160
157
|
{variant && hasPurchaseOptions && (
|
|
161
158
|
<div>
|
|
162
|
-
<h3 className="mb-1 text-sm font-medium text-
|
|
159
|
+
<h3 className="mb-1 text-sm font-medium text-base-content/70">Purchase options</h3>
|
|
163
160
|
<div className="flex flex-col gap-2">
|
|
164
161
|
{oneTimeAllowed && (
|
|
165
162
|
<PurchaseOption
|
|
@@ -194,7 +191,7 @@ export function ProductPurchase({
|
|
|
194
191
|
onClick={onAdd}
|
|
195
192
|
disabled={!variant || pending}
|
|
196
193
|
data-fc-track="add-to-cart"
|
|
197
|
-
className="
|
|
194
|
+
className="btn btn-primary"
|
|
198
195
|
>
|
|
199
196
|
{pending ? 'Adding…' : added ? 'Added!' : variant ? 'Add to cart' : 'Unavailable'}
|
|
200
197
|
</button>
|
|
@@ -222,16 +219,14 @@ function PurchaseOption({
|
|
|
222
219
|
onClick={onSelect}
|
|
223
220
|
aria-pressed={active}
|
|
224
221
|
className={`flex items-center justify-between gap-3 rounded-md border px-3 py-2 text-left text-sm transition ${
|
|
225
|
-
active
|
|
226
|
-
? 'border-gray-900 ring-1 ring-gray-900'
|
|
227
|
-
: 'border-gray-300 hover:border-gray-900'
|
|
222
|
+
active ? 'border-primary ring-1 ring-primary' : 'border-base-300 hover:border-primary'
|
|
228
223
|
}`}
|
|
229
224
|
>
|
|
230
225
|
<span className="min-w-0">
|
|
231
|
-
<span className="block font-medium text-
|
|
232
|
-
{hint && <span className="block text-xs text-
|
|
226
|
+
<span className="block font-medium text-base-content">{label}</span>
|
|
227
|
+
{hint && <span className="block text-xs text-base-content/60">{hint}</span>}
|
|
233
228
|
</span>
|
|
234
|
-
<span className="shrink-0 font-medium text-
|
|
229
|
+
<span className="shrink-0 font-medium text-base-content">{priceLabel}</span>
|
|
235
230
|
</button>
|
|
236
231
|
);
|
|
237
232
|
}
|