@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.
Files changed (63) hide show
  1. package/dist/src/commands/init.d.ts +16 -0
  2. package/dist/src/commands/init.js +18 -7
  3. package/dist/src/commands/init.js.map +1 -1
  4. package/package.json +1 -1
  5. package/templates/storefront/README.md +145 -61
  6. package/templates/storefront/next.config.js +20 -0
  7. package/templates/storefront/package.json +10 -2
  8. package/templates/storefront/postcss.config.js +1 -2
  9. package/templates/storefront/src/app/%5F%5Ffc/track/route.ts +30 -12
  10. package/templates/storefront/src/app/__forge_beacon/route.ts +1 -2
  11. package/templates/storefront/src/app/api/%5F%5Fbackend/methods/route.ts +27 -0
  12. package/templates/storefront/src/app/cart/page.tsx +33 -6
  13. package/templates/storefront/src/app/checkout/page.tsx +40 -0
  14. package/templates/storefront/src/app/error.tsx +21 -0
  15. package/templates/storefront/src/app/global-error.tsx +23 -0
  16. package/templates/storefront/src/app/globals.css +105 -8
  17. package/templates/storefront/src/app/layout.tsx +45 -17
  18. package/templates/storefront/src/app/page.tsx +153 -43
  19. package/templates/storefront/src/app/ping/route.ts +1 -2
  20. package/templates/storefront/src/app/products/[slug]/not-found.tsx +3 -8
  21. package/templates/storefront/src/app/products/[slug]/page.tsx +69 -23
  22. package/templates/storefront/src/app/products/page.tsx +52 -9
  23. package/templates/storefront/src/components/CartView.tsx +244 -117
  24. package/templates/storefront/src/components/ForgeTracker.tsx +131 -26
  25. package/templates/storefront/src/components/Header.tsx +8 -10
  26. package/templates/storefront/src/components/ProductCard.tsx +25 -13
  27. package/templates/storefront/src/components/ProductPurchase.tsx +13 -18
  28. package/templates/storefront/src/components/checkout/AddressStep.tsx +288 -0
  29. package/templates/storefront/src/components/checkout/CheckoutFlow.tsx +543 -0
  30. package/templates/storefront/src/components/checkout/CheckoutGate.tsx +45 -0
  31. package/templates/storefront/src/components/checkout/PaymentElementForm.tsx +138 -0
  32. package/templates/storefront/src/components/checkout/PaymentFormEmbed.tsx +89 -0
  33. package/templates/storefront/src/components/checkout/RatesStep.tsx +113 -0
  34. package/templates/storefront/src/instrumentation.ts +34 -0
  35. package/templates/storefront/src/lib/action-result.ts +30 -0
  36. package/templates/storefront/src/lib/backend-actions.ts +20 -0
  37. package/templates/storefront/src/lib/backend-client.ts +47 -0
  38. package/templates/storefront/src/lib/cart-context.tsx +157 -22
  39. package/templates/storefront/src/lib/checkout-session.ts +185 -0
  40. package/templates/storefront/src/lib/error-messages.ts +24 -0
  41. package/templates/storefront/src/lib/experiments.ts +42 -44
  42. package/templates/storefront/src/lib/forgecart.ts +61 -78
  43. package/templates/storefront/src/lib/format.ts +91 -0
  44. package/templates/storefront/src/lib/session-actions.ts +54 -0
  45. package/templates/storefront/src/lib/shop-config.ts +44 -0
  46. package/templates/storefront/src/lib/shop-session.ts +114 -0
  47. package/templates/storefront/src/lib/track-forward.ts +14 -1
  48. package/templates/storefront/src/lib/uuid.ts +19 -0
  49. package/templates/storefront/src/server/app.module.ts +18 -0
  50. package/templates/storefront/src/server/backend-api.ts +26 -0
  51. package/templates/storefront/src/server/backend-method.decorator.ts +23 -0
  52. package/templates/storefront/src/server/bootstrap.ts +122 -0
  53. package/templates/storefront/src/server/customer-extras/customer-extras.module.ts +13 -0
  54. package/templates/storefront/src/server/customer-extras/service/customer-extras.service.ts +58 -0
  55. package/templates/storefront/src/server/customer-extras/type/customer-extras.types.ts +11 -0
  56. package/templates/storefront/src/server/forgecart/forgecart-client.factory.ts +69 -0
  57. package/templates/storefront/src/server/forgecart/forgecart.module.ts +9 -0
  58. package/templates/storefront/src/server/runner.ts +91 -0
  59. package/templates/storefront/src/server/types.ts +36 -0
  60. package/templates/storefront/tsconfig.json +2 -0
  61. package/templates/storefront/.env.example +0 -12
  62. package/templates/storefront/src/lib/cart-actions.ts +0 -139
  63. 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 {@link FLUSH_INTERVAL_MS} via `fetch(keepalive)`;
32
- * `sendBeacon` is used ONLY for the `pagehide` flush (its unload-time queueing
33
- * guarantee is the one thing keepalive fetch lacks). Every event carries a
34
- * client-minted `eventId` (`crypto.randomUUID`) the server dedupes on it, so
35
- * an accidental double-send is harmless. Delivery is fire-and-forget: failures
36
- * are swallowed and never retried; analytics must never break the storefront.
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: `crypto.randomUUID` where available; on the one
91
- * environment it isn't (insecure-context LAN-IP dev hosts the API is
92
- * secure-context gated, `getRandomValues` is not) mint the same v4 shape by
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
- if (typeof crypto.randomUUID === 'function') return crypto.randomUUID();
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
- flushTimer = window.setTimeout(flush, FLUSH_INTERVAL_MS);
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
- export function ForgeTracker({
170
- shopApiUrl,
171
- channelToken,
172
- }: {
173
- shopApiUrl: string;
174
- channelToken: string;
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-gray-200 bg-white">
24
- <div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-4">
25
- <Link href="/" className="text-lg font-bold tracking-tight text-gray-900">
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-6 text-sm font-medium text-gray-600">
29
- <Link href="/" className="hover:text-gray-900">
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="hover:text-gray-900">
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="relative hover:text-gray-900">
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="ml-1 inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-gray-900 px-1.5 text-xs font-semibold text-white">
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 { formatPrice, getStartingPrice, type Product } from '../lib/forgecart';
3
+ import type { Product } from '../lib/forgecart';
4
+ import { formatPrice, getStartingPrice } from '../lib/format';
4
5
 
5
- /** A product tile linking to its detail page. Used by the home and grid pages. */
6
- export function ProductCard({ product }: { product: Product }) {
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="group flex flex-col overflow-hidden rounded-lg border border-gray-200 bg-white transition hover:shadow-md"
24
+ className="card card-border group h-full bg-base-100 transition-shadow hover:shadow-md"
14
25
  >
15
- <div className="aspect-square overflow-hidden bg-gray-100">
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
- className="h-full w-full object-cover transition group-hover:scale-105"
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-gray-400">
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
- </div>
30
- <div className="flex flex-1 flex-col gap-1 p-4">
31
- <h3 className="font-medium text-gray-900">{product.name}</h3>
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-gray-600">
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
- type Product,
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-gray-900">{formatPrice(previewPrice)}</p>
127
+ <p className="text-xl font-semibold text-base-content">{formatPrice(previewPrice)}</p>
131
128
  ) : (
132
- <p className="text-sm text-gray-500">Select options to see the price.</p>
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-gray-700">{group.name}</h3>
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-gray-900 bg-gray-900 text-white'
149
- : 'border-gray-300 text-gray-700 hover:border-gray-900'
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-gray-700">Purchase options</h3>
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="rounded-md bg-gray-900 px-5 py-2.5 text-sm font-medium text-white transition hover:bg-gray-700 disabled:cursor-not-allowed disabled:opacity-50"
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-gray-900">{label}</span>
232
- {hint && <span className="block text-xs text-gray-500">{hint}</span>}
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-gray-900">{priceLabel}</span>
229
+ <span className="shrink-0 font-medium text-base-content">{priceLabel}</span>
235
230
  </button>
236
231
  );
237
232
  }