@forgecart/cli 2.202610280649.0 → 2.202610280926.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 (218) hide show
  1. package/dist/src/commands/init-questions.d.ts +12 -11
  2. package/dist/src/commands/init-questions.js +15 -23
  3. package/dist/src/commands/init-questions.js.map +1 -1
  4. package/dist/src/commands/init.d.ts +13 -13
  5. package/dist/src/commands/init.js +75 -144
  6. package/dist/src/commands/init.js.map +1 -1
  7. package/dist/src/commands/refresh.d.ts +3 -3
  8. package/dist/src/commands/refresh.js +4 -14
  9. package/dist/src/commands/refresh.js.map +1 -1
  10. package/dist/src/commands/template-manifest.d.ts +2 -5
  11. package/dist/src/commands/template-manifest.js +1 -1
  12. package/dist/src/commands/template-manifest.js.map +1 -1
  13. package/dist/src/commands/type/init.input.d.ts +1 -1
  14. package/dist/src/commands/type/init.types.d.ts +4 -4
  15. package/dist/src/template/template-set.d.ts +17 -27
  16. package/dist/src/template/template-set.js +25 -42
  17. package/dist/src/template/template-set.js.map +1 -1
  18. package/dist/src/template/template-unavailable-error.d.ts +3 -4
  19. package/dist/src/template/template-unavailable-error.js +3 -4
  20. package/dist/src/template/template-unavailable-error.js.map +1 -1
  21. package/dist/src/template/type/template-set.types.d.ts +14 -17
  22. package/dist/src/template/type/template-set.types.js +6 -1
  23. package/dist/src/template/type/template-set.types.js.map +1 -1
  24. package/dist/src/template/util/template-identity.util.d.ts +1 -3
  25. package/dist/src/template/util/template-identity.util.js +0 -5
  26. package/dist/src/template/util/template-identity.util.js.map +1 -1
  27. package/package.json +1 -1
  28. package/templates/storefront-blank/.forgecartignore +2 -0
  29. package/templates/storefront-blank/Procfile +1 -0
  30. package/templates/storefront-blank/README.md +86 -0
  31. package/templates/storefront-blank/SEO-MIGRATION.md +718 -0
  32. package/templates/storefront-blank/_gitignore +21 -0
  33. package/templates/storefront-blank/next.config.js +105 -0
  34. package/templates/storefront-blank/package.json +35 -0
  35. package/templates/storefront-blank/postcss.config.js +5 -0
  36. package/templates/storefront-blank/src/app/%5F%5Ffc/identify/route.ts +205 -0
  37. package/templates/storefront-blank/src/app/%5F%5Ffc/track/route.ts +189 -0
  38. package/templates/storefront-blank/src/app/%5F%5Fforge_beacon/route.ts +87 -0
  39. package/templates/storefront-blank/src/app/api/%5F%5Fbackend/methods/route.ts +27 -0
  40. package/templates/storefront-blank/src/app/error.tsx +21 -0
  41. package/templates/storefront-blank/src/app/global-error.tsx +23 -0
  42. package/templates/storefront-blank/src/app/globals.css +110 -0
  43. package/templates/storefront-blank/src/app/layout.tsx +102 -0
  44. package/templates/storefront-blank/src/app/page.tsx +52 -0
  45. package/templates/storefront-blank/src/app/pages/[slug]/not-found.tsx +23 -0
  46. package/templates/storefront-blank/src/app/pages/[slug]/page.tsx +115 -0
  47. package/templates/storefront-blank/src/app/ping/route.ts +21 -0
  48. package/templates/storefront-blank/src/app/robots.ts +69 -0
  49. package/templates/storefront-blank/src/app/sitemap.ts +107 -0
  50. package/templates/storefront-blank/src/components/ForgeErrorBeacon.tsx +102 -0
  51. package/templates/storefront-blank/src/components/ForgeTracker.tsx +479 -0
  52. package/templates/storefront-blank/src/components/ForgecartDesigner.tsx +43 -0
  53. package/templates/storefront-blank/src/components/LocaleLink.tsx +49 -0
  54. package/templates/storefront-blank/src/instrumentation.ts +109 -0
  55. package/templates/storefront-blank/src/lib/account/account-link.ts +76 -0
  56. package/templates/storefront-blank/src/lib/account/account-state.ts +152 -0
  57. package/templates/storefront-blank/src/lib/account/register-state.ts +133 -0
  58. package/templates/storefront-blank/src/lib/account/reset-password-state.ts +111 -0
  59. package/templates/storefront-blank/src/lib/account/verify-state.ts +56 -0
  60. package/templates/storefront-blank/src/lib/account-actions.ts +76 -0
  61. package/templates/storefront-blank/src/lib/account-session.ts +114 -0
  62. package/templates/storefront-blank/src/lib/action-result.ts +30 -0
  63. package/templates/storefront-blank/src/lib/asset-alt.ts +34 -0
  64. package/templates/storefront-blank/src/lib/backend-actions.ts +20 -0
  65. package/templates/storefront-blank/src/lib/backend-client.ts +47 -0
  66. package/templates/storefront-blank/src/lib/cart-context.tsx +236 -0
  67. package/templates/storefront-blank/src/lib/checkout-session.ts +185 -0
  68. package/templates/storefront-blank/src/lib/content/page-metadata.ts +113 -0
  69. package/templates/storefront-blank/src/lib/content/render-fields.tsx +256 -0
  70. package/templates/storefront-blank/src/lib/content/resolve-page.ts +143 -0
  71. package/templates/storefront-blank/src/lib/error-messages.ts +118 -0
  72. package/templates/storefront-blank/src/lib/experiments.ts +333 -0
  73. package/templates/storefront-blank/src/lib/forgecart.ts +464 -0
  74. package/templates/storefront-blank/src/lib/format.ts +89 -0
  75. package/templates/storefront-blank/src/lib/identify-forward.ts +152 -0
  76. package/templates/storefront-blank/src/lib/locale/channel-locales-loader.ts +169 -0
  77. package/templates/storefront-blank/src/lib/locale/channel-locales-map.ts +46 -0
  78. package/templates/storefront-blank/src/lib/locale/channel-locales.ts +191 -0
  79. package/templates/storefront-blank/src/lib/locale/grammar.ts +194 -0
  80. package/templates/storefront-blank/src/lib/locale/localized-path.ts +55 -0
  81. package/templates/storefront-blank/src/lib/locale/middleware-plan.ts +107 -0
  82. package/templates/storefront-blank/src/lib/locale/request-binding.ts +80 -0
  83. package/templates/storefront-blank/src/lib/locale/request-locale.ts +66 -0
  84. package/templates/storefront-blank/src/lib/marketing-params.ts +213 -0
  85. package/templates/storefront-blank/src/lib/money.ts +50 -0
  86. package/templates/storefront-blank/src/lib/seo/alternates.ts +123 -0
  87. package/templates/storefront-blank/src/lib/seo/json-ld.ts +266 -0
  88. package/templates/storefront-blank/src/lib/seo/metadata.ts +419 -0
  89. package/templates/storefront-blank/src/lib/seo/noindex.ts +223 -0
  90. package/templates/storefront-blank/src/lib/seo/public-origin.ts +166 -0
  91. package/templates/storefront-blank/src/lib/seo/redirect-plan.ts +86 -0
  92. package/templates/storefront-blank/src/lib/seo/resolve-path.ts +107 -0
  93. package/templates/storefront-blank/src/lib/seo/scaffolded-routes.ts +83 -0
  94. package/templates/storefront-blank/src/lib/seo/sidecar.ts +75 -0
  95. package/templates/storefront-blank/src/lib/seo/site-verification.ts +98 -0
  96. package/templates/storefront-blank/src/lib/seo/sitemap-cache.ts +114 -0
  97. package/templates/storefront-blank/src/lib/seo/sitemap-entries.ts +310 -0
  98. package/templates/storefront-blank/src/lib/session-actions.ts +61 -0
  99. package/templates/storefront-blank/src/lib/session-cookies.ts +98 -0
  100. package/templates/storefront-blank/src/lib/shop-config.ts +51 -0
  101. package/templates/storefront-blank/src/lib/shop-session.ts +204 -0
  102. package/templates/storefront-blank/src/lib/track-forward.ts +200 -0
  103. package/templates/storefront-blank/src/lib/uuid.ts +19 -0
  104. package/templates/storefront-blank/src/middleware.ts +379 -0
  105. package/templates/storefront-blank/src/seo/redirects.ts +44 -0
  106. package/templates/storefront-blank/src/seo/static-routes.ts +12 -0
  107. package/templates/storefront-blank/src/server/app.module.ts +18 -0
  108. package/templates/storefront-blank/src/server/backend-api.ts +21 -0
  109. package/templates/storefront-blank/src/server/backend-method.decorator.ts +23 -0
  110. package/templates/storefront-blank/src/server/bootstrap.ts +122 -0
  111. package/templates/storefront-blank/src/server/forge/live-revision.ts +158 -0
  112. package/templates/storefront-blank/src/server/forgecart/forgecart-client.factory.ts +73 -0
  113. package/templates/storefront-blank/src/server/forgecart/forgecart.module.ts +9 -0
  114. package/templates/storefront-blank/src/server/runner.ts +90 -0
  115. package/templates/storefront-blank/src/server/types.ts +36 -0
  116. package/templates/storefront-blank/tsconfig.json +25 -0
  117. package/templates/storefront-blank-sdk-floor.json +1174 -0
  118. package/templates/storefront-shadcn-blank/.forgecartignore +2 -0
  119. package/templates/storefront-shadcn-blank/Procfile +1 -0
  120. package/templates/storefront-shadcn-blank/README.md +93 -0
  121. package/templates/storefront-shadcn-blank/SEO-MIGRATION.md +718 -0
  122. package/templates/storefront-shadcn-blank/_gitignore +21 -0
  123. package/templates/storefront-shadcn-blank/components.json +20 -0
  124. package/templates/storefront-shadcn-blank/next.config.js +105 -0
  125. package/templates/storefront-shadcn-blank/package.json +42 -0
  126. package/templates/storefront-shadcn-blank/postcss.config.js +5 -0
  127. package/templates/storefront-shadcn-blank/src/app/%5F%5Ffc/identify/route.ts +205 -0
  128. package/templates/storefront-shadcn-blank/src/app/%5F%5Ffc/track/route.ts +189 -0
  129. package/templates/storefront-shadcn-blank/src/app/%5F%5Fforge_beacon/route.ts +87 -0
  130. package/templates/storefront-shadcn-blank/src/app/api/%5F%5Fbackend/methods/route.ts +27 -0
  131. package/templates/storefront-shadcn-blank/src/app/error.tsx +23 -0
  132. package/templates/storefront-shadcn-blank/src/app/global-error.tsx +23 -0
  133. package/templates/storefront-shadcn-blank/src/app/globals.css +156 -0
  134. package/templates/storefront-shadcn-blank/src/app/layout.tsx +102 -0
  135. package/templates/storefront-shadcn-blank/src/app/page.tsx +53 -0
  136. package/templates/storefront-shadcn-blank/src/app/pages/[slug]/not-found.tsx +24 -0
  137. package/templates/storefront-shadcn-blank/src/app/pages/[slug]/page.tsx +115 -0
  138. package/templates/storefront-shadcn-blank/src/app/ping/route.ts +21 -0
  139. package/templates/storefront-shadcn-blank/src/app/robots.ts +69 -0
  140. package/templates/storefront-shadcn-blank/src/app/sitemap.ts +107 -0
  141. package/templates/storefront-shadcn-blank/src/components/ForgeErrorBeacon.tsx +102 -0
  142. package/templates/storefront-shadcn-blank/src/components/ForgeTracker.tsx +479 -0
  143. package/templates/storefront-shadcn-blank/src/components/ForgecartDesigner.tsx +43 -0
  144. package/templates/storefront-shadcn-blank/src/components/LocaleLink.tsx +49 -0
  145. package/templates/storefront-shadcn-blank/src/components/ui/alert.tsx +75 -0
  146. package/templates/storefront-shadcn-blank/src/components/ui/badge.tsx +40 -0
  147. package/templates/storefront-shadcn-blank/src/components/ui/button.tsx +64 -0
  148. package/templates/storefront-shadcn-blank/src/components/ui/card.tsx +28 -0
  149. package/templates/storefront-shadcn-blank/src/components/ui/input.tsx +26 -0
  150. package/templates/storefront-shadcn-blank/src/components/ui/label.tsx +22 -0
  151. package/templates/storefront-shadcn-blank/src/components/ui/native-select.tsx +27 -0
  152. package/templates/storefront-shadcn-blank/src/components/ui/skeleton.tsx +21 -0
  153. package/templates/storefront-shadcn-blank/src/components/ui/utils.ts +16 -0
  154. package/templates/storefront-shadcn-blank/src/instrumentation.ts +109 -0
  155. package/templates/storefront-shadcn-blank/src/lib/account/account-link.ts +76 -0
  156. package/templates/storefront-shadcn-blank/src/lib/account/account-state.ts +152 -0
  157. package/templates/storefront-shadcn-blank/src/lib/account/register-state.ts +133 -0
  158. package/templates/storefront-shadcn-blank/src/lib/account/reset-password-state.ts +111 -0
  159. package/templates/storefront-shadcn-blank/src/lib/account/verify-state.ts +56 -0
  160. package/templates/storefront-shadcn-blank/src/lib/account-actions.ts +76 -0
  161. package/templates/storefront-shadcn-blank/src/lib/account-session.ts +114 -0
  162. package/templates/storefront-shadcn-blank/src/lib/action-result.ts +30 -0
  163. package/templates/storefront-shadcn-blank/src/lib/asset-alt.ts +34 -0
  164. package/templates/storefront-shadcn-blank/src/lib/backend-actions.ts +20 -0
  165. package/templates/storefront-shadcn-blank/src/lib/backend-client.ts +47 -0
  166. package/templates/storefront-shadcn-blank/src/lib/cart-context.tsx +236 -0
  167. package/templates/storefront-shadcn-blank/src/lib/checkout-session.ts +185 -0
  168. package/templates/storefront-shadcn-blank/src/lib/content/page-metadata.ts +113 -0
  169. package/templates/storefront-shadcn-blank/src/lib/content/render-fields.tsx +256 -0
  170. package/templates/storefront-shadcn-blank/src/lib/content/resolve-page.ts +143 -0
  171. package/templates/storefront-shadcn-blank/src/lib/error-messages.ts +118 -0
  172. package/templates/storefront-shadcn-blank/src/lib/experiments.ts +333 -0
  173. package/templates/storefront-shadcn-blank/src/lib/forgecart.ts +464 -0
  174. package/templates/storefront-shadcn-blank/src/lib/format.ts +89 -0
  175. package/templates/storefront-shadcn-blank/src/lib/identify-forward.ts +152 -0
  176. package/templates/storefront-shadcn-blank/src/lib/locale/channel-locales-loader.ts +169 -0
  177. package/templates/storefront-shadcn-blank/src/lib/locale/channel-locales-map.ts +46 -0
  178. package/templates/storefront-shadcn-blank/src/lib/locale/channel-locales.ts +191 -0
  179. package/templates/storefront-shadcn-blank/src/lib/locale/grammar.ts +194 -0
  180. package/templates/storefront-shadcn-blank/src/lib/locale/localized-path.ts +55 -0
  181. package/templates/storefront-shadcn-blank/src/lib/locale/middleware-plan.ts +107 -0
  182. package/templates/storefront-shadcn-blank/src/lib/locale/request-binding.ts +80 -0
  183. package/templates/storefront-shadcn-blank/src/lib/locale/request-locale.ts +66 -0
  184. package/templates/storefront-shadcn-blank/src/lib/marketing-params.ts +213 -0
  185. package/templates/storefront-shadcn-blank/src/lib/money.ts +50 -0
  186. package/templates/storefront-shadcn-blank/src/lib/seo/alternates.ts +123 -0
  187. package/templates/storefront-shadcn-blank/src/lib/seo/json-ld.ts +266 -0
  188. package/templates/storefront-shadcn-blank/src/lib/seo/metadata.ts +419 -0
  189. package/templates/storefront-shadcn-blank/src/lib/seo/noindex.ts +223 -0
  190. package/templates/storefront-shadcn-blank/src/lib/seo/public-origin.ts +166 -0
  191. package/templates/storefront-shadcn-blank/src/lib/seo/redirect-plan.ts +86 -0
  192. package/templates/storefront-shadcn-blank/src/lib/seo/resolve-path.ts +107 -0
  193. package/templates/storefront-shadcn-blank/src/lib/seo/scaffolded-routes.ts +83 -0
  194. package/templates/storefront-shadcn-blank/src/lib/seo/sidecar.ts +75 -0
  195. package/templates/storefront-shadcn-blank/src/lib/seo/site-verification.ts +98 -0
  196. package/templates/storefront-shadcn-blank/src/lib/seo/sitemap-cache.ts +114 -0
  197. package/templates/storefront-shadcn-blank/src/lib/seo/sitemap-entries.ts +310 -0
  198. package/templates/storefront-shadcn-blank/src/lib/session-actions.ts +61 -0
  199. package/templates/storefront-shadcn-blank/src/lib/session-cookies.ts +98 -0
  200. package/templates/storefront-shadcn-blank/src/lib/shop-config.ts +51 -0
  201. package/templates/storefront-shadcn-blank/src/lib/shop-session.ts +204 -0
  202. package/templates/storefront-shadcn-blank/src/lib/track-forward.ts +200 -0
  203. package/templates/storefront-shadcn-blank/src/lib/uuid.ts +19 -0
  204. package/templates/storefront-shadcn-blank/src/middleware.ts +379 -0
  205. package/templates/storefront-shadcn-blank/src/seo/redirects.ts +44 -0
  206. package/templates/storefront-shadcn-blank/src/seo/static-routes.ts +12 -0
  207. package/templates/storefront-shadcn-blank/src/server/app.module.ts +18 -0
  208. package/templates/storefront-shadcn-blank/src/server/backend-api.ts +21 -0
  209. package/templates/storefront-shadcn-blank/src/server/backend-method.decorator.ts +23 -0
  210. package/templates/storefront-shadcn-blank/src/server/bootstrap.ts +122 -0
  211. package/templates/storefront-shadcn-blank/src/server/forge/live-revision.ts +158 -0
  212. package/templates/storefront-shadcn-blank/src/server/forgecart/forgecart-client.factory.ts +73 -0
  213. package/templates/storefront-shadcn-blank/src/server/forgecart/forgecart.module.ts +9 -0
  214. package/templates/storefront-shadcn-blank/src/server/runner.ts +90 -0
  215. package/templates/storefront-shadcn-blank/src/server/types.ts +36 -0
  216. package/templates/storefront-shadcn-blank/tsconfig.json +25 -0
  217. package/templates/storefront-shadcn-blank-sdk-floor.json +1174 -0
  218. package/templates/template-set.json +15 -0
@@ -0,0 +1,479 @@
1
+ 'use client';
2
+
3
+ import { usePathname } from 'next/navigation';
4
+ import { useEffect } from 'react';
5
+
6
+ import {
7
+ type MarketingIdentifier,
8
+ parseMarketingParams,
9
+ type UtmParams,
10
+ } from '../lib/marketing-params';
11
+ import { mintUuid } from '../lib/uuid';
12
+
13
+ /**
14
+ * Storefront event tracker (design doc S7). Renders nothing.
15
+ *
16
+ * The single client-side emitter of marketing events. Everything goes through
17
+ * the same-origin `/__fc/track` relay — NEVER the SDK's WebSocket singleton,
18
+ * whose connection pins one identity and would mis-attribute every event after
19
+ * a login. The relay replays the shopper's `forgecart-session` cookie per
20
+ * request, so attribution follows the identity.
21
+ *
22
+ * What it emits:
23
+ * - `page_view` on the initial load and on every App-Router route change
24
+ * (client-effect emission is the only prefetch-safe choice — `<Link>`
25
+ * prefetches run middleware and RSC fetches but no client effects).
26
+ * `properties.referrerPath` is the module-tracked previous pathname:
27
+ * `document.referrer` is frozen across soft navigations and must not be
28
+ * read per-view; only the initial load additionally carries it (origin
29
+ * level under the default Referrer-Policy) as `properties.referrer`.
30
+ * - `cta_click` from ONE delegated capture-phase click listener for any
31
+ * `[data-fc-track]` element — the element stays dumb (a semantic slug in
32
+ * `properties.trackId`); what the click MEANS is backend automation
33
+ * config, never deployed code.
34
+ * - `heartbeat` every {@link HEARTBEAT_MS} while the tab is visible (plus
35
+ * one on refocus) — liveness plumbing only, excluded from aggregates.
36
+ *
37
+ * What a landing additionally does: a view whose URL carries marketing
38
+ * parameters reads them once (`lib/marketing-params`). The click IDs go to
39
+ * {@link IDENTIFY_ENDPOINT} and that request is AWAITED before the view is
40
+ * enqueued, so the identity the relay mints carries them instead of the two
41
+ * racing; the campaign tags are held for the rest of the visit and ride every
42
+ * later event. A URL without any of them does nothing at all — no request, no
43
+ * stored state, no change to the events below.
44
+ *
45
+ * Delivery: events buffer and flush as one batch at {@link FLUSH_MAX_EVENTS}
46
+ * events or after an age timer via `fetch(keepalive)` — the FIRST batch waits
47
+ * {@link ESTABLISHMENT_GRACE_MS}, every later batch {@link FLUSH_INTERVAL_MS}
48
+ * (see the grace rationale on the constant); `sendBeacon` is used ONLY for the
49
+ * `pagehide` flush (its unload-time queueing guarantee is the one thing
50
+ * keepalive fetch lacks). Every event carries a client-minted `eventId`
51
+ * (`crypto.randomUUID`) — the server dedupes on it, so an accidental
52
+ * double-send is harmless. Delivery is fire-and-forget: failures are
53
+ * swallowed and never retried; analytics must never break the storefront.
54
+ *
55
+ * Deliberately NO 30-second same-path dedup (the old `ForgeAnalytics`
56
+ * behavior): it was live-map-correct but funnel-lossy — A→B→A inside 30s
57
+ * dropped the return view. The short same-signal suppressors (300ms; also what
58
+ * keeps StrictMode's double-mount from double-emitting, via module scope) plus
59
+ * the server-side `eventId` dedupe replace it, so revisits are recorded.
60
+ *
61
+ * Two deliberate suppressions:
62
+ * - missing config (the pod image pre-renders the template before
63
+ * `forgecart init` writes `.env`) → the tracker is inert;
64
+ * - framed embeds (`window.parent !== window`) → the visual editor's
65
+ * artboard preview emits nothing, so funnels stay production-visitor-only.
66
+ */
67
+
68
+ const HEARTBEAT_MS = 60_000;
69
+ /** Minimum spacing between heartbeats (refocus storms, StrictMode remounts). */
70
+ const HEARTBEAT_MIN_SPACING_MS = 30_000;
71
+ /** Batch flush thresholds: whichever of size / age is hit first sends. */
72
+ const FLUSH_MAX_EVENTS = 10;
73
+ const FLUSH_INTERVAL_MS = 500;
74
+ /**
75
+ * Age timer for the FIRST batch only. On a cookie-less first touch the relay
76
+ * and the first cart Server Action race to mint the shopper session, and the
77
+ * browser keeps whichever `Set-Cookie` lands last — if the tracker's ~500ms
78
+ * page_view batch wins that race against a fast add-to-cart, the cookie flips
79
+ * away from the session holding the just-created cart. Holding the initial
80
+ * flush for this grace lets the cart's cookie land first, so the relay batch
81
+ * then CARRIES it and rides the cart's session instead of minting a rival
82
+ * (design doc S7: the first tracked interaction establishes the identity the
83
+ * cart later reuses — establishment still happens, just not mid-race).
84
+ * Events are timestamped at enqueue (`occurredAt`), so deferral loses nothing.
85
+ */
86
+ const ESTABLISHMENT_GRACE_MS = 2_000;
87
+ /** Same-signal suppression window (per click slug; per page_view path). */
88
+ const SUPPRESS_MS = 300;
89
+ /**
90
+ * Minimum VISIBLE dwell before a route's scroll_depth summary is worth a
91
+ * row (#1022 phase 2b) — filters bounce-through navigations and StrictMode's
92
+ * dev-only phantom unmount.
93
+ */
94
+ const ENGAGEMENT_MIN_DWELL_MS = 250;
95
+ const TRACK_ENDPOINT = '/__fc/track';
96
+ /** Landing-only: where a captured click ID is attached to the shopper. */
97
+ const IDENTIFY_ENDPOINT = '/__fc/identify';
98
+
99
+ /** One buffered event — the relay forwards these fields to `trackEvent`. */
100
+ interface TrackedEvent {
101
+ eventType: string;
102
+ eventId: string;
103
+ occurredAt: string;
104
+ properties?: Record<string, unknown>;
105
+ /**
106
+ * The campaign this visit arrived on, repeated on every event of the visit.
107
+ *
108
+ * Spelled out rather than reused from the parser's `UtmParams` because the
109
+ * five fields are a CONTRACT with the relay's allowlist, not a shape that
110
+ * should follow the parser if it ever grows a sixth tag. An absent tag is an
111
+ * absent KEY here, never `''`: the backend stores a present empty string as a
112
+ * real ClickHouse dimension, where it can no longer be told apart from a
113
+ * campaign that genuinely has no medium.
114
+ */
115
+ utmSource?: string;
116
+ utmMedium?: string;
117
+ utmCampaign?: string;
118
+ utmTerm?: string;
119
+ utmContent?: string;
120
+ }
121
+
122
+ // Module scope on purpose: the buffer, timers and suppressors must survive
123
+ // StrictMode's mount → unmount → mount cycle (a ref would reset) and there is
124
+ // exactly one tracker per page.
125
+ let buffer: TrackedEvent[] = [];
126
+ let flushTimer: number | null = null;
127
+ /** True once any batch has gone out — ends the establishment grace. */
128
+ let firstBatchSent = false;
129
+ const lastClickAtBySlug = new Map<string, number>();
130
+ let lastPageView: { path: string; at: number } | null = null;
131
+ let previousPathname: string | null = null;
132
+ let lastHeartbeatAt = 0;
133
+ /**
134
+ * The campaign tags of the landing that started this visit. Module scope for
135
+ * the same reason as the buffer — StrictMode remounts the component and a ref
136
+ * would reset — and because there is nowhere else to read them from: an
137
+ * internal navigation carries no query string.
138
+ */
139
+ let capturedUtm: UtmParams = {};
140
+ /**
141
+ * The click IDs already sent to {@link IDENTIFY_ENDPOINT}, as `key=value&…`.
142
+ *
143
+ * Module scope so StrictMode's mount → unmount → mount sends ONE identify for
144
+ * one landing. A signature rather than a boolean because "once per captured
145
+ * landing" is not "once per visit": a shopper who clicks a second ad back into
146
+ * the store brings a different click ID that must still be delivered, while a
147
+ * revisit of the same landing URL must not send the same one twice.
148
+ */
149
+ let identifiedClickIds: string | null = null;
150
+
151
+ /**
152
+ * Consent extension point. Tracking currently needs no opt-in, so this is a
153
+ * no-op returning `true`; the merchant cookie-banner fast-follow implements
154
+ * the real gate HERE (read the stored consent state) and every emission path
155
+ * already respects it — nothing enters the buffer without consent.
156
+ */
157
+ function hasTrackingConsent(): boolean {
158
+ return true;
159
+ }
160
+
161
+ /**
162
+ * Client-side idempotency key via the shared insecure-context-safe minter
163
+ * (lib/uuid.ts) — a thrown TypeError inside a click listener would violate
164
+ * the "analytics never breaks the storefront" invariant.
165
+ */
166
+ function mintEventId(): string {
167
+ return mintUuid();
168
+ }
169
+
170
+ /** Stamp and buffer one event; flush on size, else arm the age timer. */
171
+ function enqueue(eventType: string, properties?: Record<string, unknown>): void {
172
+ if (!hasTrackingConsent()) return;
173
+ const event: TrackedEvent = {
174
+ eventType,
175
+ eventId: mintEventId(),
176
+ occurredAt: new Date().toISOString(),
177
+ // Spreading an empty object adds no keys, so an organic visit emits exactly
178
+ // the event shape it did before any of this existed.
179
+ ...capturedUtm,
180
+ };
181
+ if (properties) event.properties = properties;
182
+ buffer.push(event);
183
+
184
+ if (buffer.length >= FLUSH_MAX_EVENTS) {
185
+ flush();
186
+ return;
187
+ }
188
+ if (flushTimer === null) {
189
+ const delay = firstBatchSent ? FLUSH_INTERVAL_MS : ESTABLISHMENT_GRACE_MS;
190
+ flushTimer = window.setTimeout(flush, delay);
191
+ }
192
+ }
193
+
194
+ /** Drain the buffer, returning the batch to send (empties module state). */
195
+ function drain(): TrackedEvent[] {
196
+ if (flushTimer !== null) {
197
+ window.clearTimeout(flushTimer);
198
+ flushTimer = null;
199
+ }
200
+ const events = buffer;
201
+ buffer = [];
202
+ // The grace ends once a batch actually goes out — from then on the age
203
+ // timer runs at the normal cadence.
204
+ if (events.length > 0) firstBatchSent = true;
205
+ return events;
206
+ }
207
+
208
+ /** Primary delivery: keepalive fetch, fire-and-forget, failures swallowed. */
209
+ function flush(): void {
210
+ const events = drain();
211
+ if (events.length === 0) return;
212
+ void fetch(TRACK_ENDPOINT, {
213
+ method: 'POST',
214
+ headers: { 'content-type': 'application/json' },
215
+ body: JSON.stringify({ events }),
216
+ keepalive: true,
217
+ }).catch(() => undefined);
218
+ }
219
+
220
+ /**
221
+ * Read this URL's marketing parameters and act on what it carries.
222
+ *
223
+ * Resolves once the click IDs have been delivered — the caller awaits it so the
224
+ * identify request and the first tracked batch cannot race. Does nothing
225
+ * whatsoever for a URL that carries none, which is the overwhelmingly common
226
+ * case and the reason the parser returns a union.
227
+ */
228
+ async function captureLanding(search: string): Promise<void> {
229
+ // Consent is checked HERE as well as in `enqueue` because the identify
230
+ // request is not a buffered event: it leaves on its own, carrying a click ID
231
+ // that identifies the visitor more directly than anything in the buffer, so
232
+ // a gate that only guards `enqueue` would let it past a visitor who declined.
233
+ if (!hasTrackingConsent()) return;
234
+ const marketing = parseMarketingParams(search);
235
+ if (marketing.kind === 'none') return;
236
+
237
+ // Merge, never clear — the same semantics `middleware.ts` uses for the
238
+ // `fc-exp` cookie. Only a URL that carries tags updates them, so an internal
239
+ // navigation keeps the campaign the visitor arrived on, and a second ad
240
+ // landing legitimately overwrites the tags it brings.
241
+ capturedUtm = { ...capturedUtm, ...marketing.utm };
242
+
243
+ if (marketing.identifiers.length === 0) return;
244
+ const signature = signClickIds(marketing.identifiers);
245
+ if (signature === identifiedClickIds) return;
246
+ // Recorded BEFORE the await: StrictMode's second mount runs while this
247
+ // request is still in flight and must find the landing already claimed.
248
+ identifiedClickIds = signature;
249
+ await fetch(IDENTIFY_ENDPOINT, {
250
+ method: 'POST',
251
+ headers: { 'content-type': 'application/json' },
252
+ body: JSON.stringify({ identifiers: marketing.identifiers }),
253
+ keepalive: true,
254
+ }).catch(() => undefined); // same swallow as flush(): analytics never breaks the storefront
255
+ }
256
+
257
+ /** The click IDs of one landing as a comparable string, in parser order. */
258
+ function signClickIds(identifiers: readonly MarketingIdentifier[]): string {
259
+ return identifiers.map(({ key, value }) => `${key}=${value}`).join('&');
260
+ }
261
+
262
+ /**
263
+ * Capture the landing, THEN record the view.
264
+ *
265
+ * The order is the whole point. The first request of a visit is what mints the
266
+ * shopper session, so sending the identify and the page_view in parallel would
267
+ * mint one identity per request and split the visit in two — the click ID on
268
+ * one, every tracked event on the other, and no attribution joining them.
269
+ */
270
+ async function emitPageView(properties: Record<string, unknown>): Promise<void> {
271
+ await captureLanding(window.location.search);
272
+ enqueue('page_view', properties);
273
+ }
274
+
275
+ /**
276
+ * `pagehide` delivery: `sendBeacon` — the only flush that uses it, for its
277
+ * survive-the-unload queueing guarantee. Falls back to keepalive fetch where
278
+ * the API is unavailable.
279
+ */
280
+ function flushOnPagehide(): void {
281
+ const events = drain();
282
+ if (events.length === 0) return;
283
+ const body = JSON.stringify({ events });
284
+ if (typeof navigator.sendBeacon === 'function') {
285
+ navigator.sendBeacon(TRACK_ENDPOINT, new Blob([body], { type: 'application/json' }));
286
+ return;
287
+ }
288
+ void fetch(TRACK_ENDPOINT, {
289
+ method: 'POST',
290
+ headers: { 'content-type': 'application/json' },
291
+ body,
292
+ keepalive: true,
293
+ }).catch(() => undefined);
294
+ }
295
+
296
+ /**
297
+ * `enabled` is computed SERVER-side (env presence) and passed as a boolean —
298
+ * the tracker only needs to know whether the relay is configured; the channel
299
+ * token itself must never serialize into the RSC payload.
300
+ */
301
+ export function ForgeTracker({ enabled }: { enabled: boolean }) {
302
+ const pathname = usePathname();
303
+
304
+ // page_view — initial load + every route change.
305
+ useEffect(() => {
306
+ if (!enabled) return;
307
+ if (window.parent !== window) return;
308
+ const now = Date.now();
309
+ if (lastPageView && lastPageView.path === pathname && now - lastPageView.at < SUPPRESS_MS) {
310
+ return;
311
+ }
312
+ lastPageView = { path: pathname, at: now };
313
+ const properties: Record<string, unknown> = { path: pathname };
314
+ if (previousPathname !== null) {
315
+ properties.referrerPath = previousPathname;
316
+ } else if (document.referrer) {
317
+ properties.referrer = document.referrer;
318
+ }
319
+ previousPathname = pathname;
320
+ // The suppressor above is already stamped, so the double mount StrictMode
321
+ // performs while this is in flight still emits exactly one view.
322
+ void emitPageView(properties);
323
+ }, [enabled, pathname]);
324
+
325
+ // cta_click — one delegated capture-phase listener — and the pagehide flush.
326
+ useEffect(() => {
327
+ if (!enabled) return;
328
+ if (window.parent !== window) return;
329
+
330
+ const onClick = (event: Event): void => {
331
+ if (!(event.target instanceof Element)) return;
332
+ const tracked = event.target.closest('[data-fc-track]');
333
+ if (!tracked) return;
334
+ const trackId = tracked.getAttribute('data-fc-track');
335
+ if (!trackId) return;
336
+ const now = Date.now();
337
+ const lastAt = lastClickAtBySlug.get(trackId);
338
+ if (lastAt !== undefined && now - lastAt < SUPPRESS_MS) return;
339
+ lastClickAtBySlug.set(trackId, now);
340
+ enqueue('cta_click', { trackId, path: window.location.pathname });
341
+ };
342
+ const onPagehide = (): void => flushOnPagehide();
343
+
344
+ // Capture phase so the event is seen even when a handler below stops
345
+ // propagation (e.g. a framework's synthetic-event stopPropagation).
346
+ document.addEventListener('click', onClick, true);
347
+ window.addEventListener('pagehide', onPagehide);
348
+ return () => {
349
+ document.removeEventListener('click', onClick, true);
350
+ window.removeEventListener('pagehide', onPagehide);
351
+ };
352
+ }, [enabled]);
353
+
354
+ // heartbeat — while visible, plus one on refocus; paused while hidden.
355
+ useEffect(() => {
356
+ if (!enabled) return;
357
+ if (window.parent !== window) return;
358
+
359
+ let interval: ReturnType<typeof setInterval> | null = null;
360
+
361
+ const beat = (): void => {
362
+ const now = Date.now();
363
+ if (now - lastHeartbeatAt < HEARTBEAT_MIN_SPACING_MS) return;
364
+ lastHeartbeatAt = now;
365
+ enqueue('heartbeat');
366
+ };
367
+ const start = (): void => {
368
+ if (interval !== null) return;
369
+ interval = setInterval(beat, HEARTBEAT_MS);
370
+ };
371
+ const stop = (): void => {
372
+ if (interval === null) return;
373
+ clearInterval(interval);
374
+ interval = null;
375
+ };
376
+ const onVisibility = (): void => {
377
+ if (document.visibilityState === 'visible') {
378
+ beat();
379
+ start();
380
+ } else {
381
+ stop();
382
+ }
383
+ };
384
+
385
+ document.addEventListener('visibilitychange', onVisibility);
386
+ if (document.visibilityState === 'visible') start();
387
+ return () => {
388
+ document.removeEventListener('visibilitychange', onVisibility);
389
+ stop();
390
+ };
391
+ }, [enabled]);
392
+
393
+ // scroll_depth — ONE engagement summary per route (#1022 phase 2b): the
394
+ // max scroll depth reached (percent, 0-100) and the VISIBLE dwell time,
395
+ // emitted when the route unmounts (client-side navigation) or the page
396
+ // hides. Rides the dormant `scroll_depth` event type already in the
397
+ // default shop allowlist; the server hoists both properties into the
398
+ // typed `scrollDepth`/`durationMs` ClickHouse columns (CH 005).
399
+ useEffect(() => {
400
+ if (!enabled) return;
401
+ if (window.parent !== window) return;
402
+
403
+ let maxDepthPct = 0;
404
+ let visibleMs = 0;
405
+ let visibleSince: number | null =
406
+ document.visibilityState === 'visible' ? performance.now() : null;
407
+ let ticking = false;
408
+ let emitted = false;
409
+
410
+ const measure = (): void => {
411
+ ticking = false;
412
+ const doc = document.documentElement;
413
+ // A page shorter than the viewport is fully seen — 100 by definition;
414
+ // otherwise percent of total document height brought into view.
415
+ const pct =
416
+ doc.scrollHeight <= window.innerHeight
417
+ ? 100
418
+ : Math.min(
419
+ 100,
420
+ Math.round(((window.scrollY + window.innerHeight) / doc.scrollHeight) * 100),
421
+ );
422
+ if (pct > maxDepthPct) maxDepthPct = pct;
423
+ };
424
+ const onScroll = (): void => {
425
+ if (ticking) return;
426
+ ticking = true;
427
+ requestAnimationFrame(measure);
428
+ };
429
+ const onVisibility = (): void => {
430
+ const now = performance.now();
431
+ if (document.visibilityState === 'visible') {
432
+ visibleSince ??= now;
433
+ return;
434
+ }
435
+ if (visibleSince !== null) {
436
+ visibleMs += now - visibleSince;
437
+ visibleSince = null;
438
+ }
439
+ };
440
+ const emit = (): void => {
441
+ if (emitted) return;
442
+ if (visibleSince !== null) {
443
+ visibleMs += performance.now() - visibleSince;
444
+ visibleSince = null;
445
+ }
446
+ // Dwell floor: a route that never accumulated meaningful visible time
447
+ // has no engagement story — this also swallows StrictMode's dev-only
448
+ // mount/unmount phantom, which would otherwise emit a zero-dwell row.
449
+ if (visibleMs < ENGAGEMENT_MIN_DWELL_MS) return;
450
+ emitted = true;
451
+ enqueue('scroll_depth', {
452
+ path: window.location.pathname,
453
+ scrollDepth: maxDepthPct,
454
+ durationMs: Math.round(visibleMs),
455
+ });
456
+ };
457
+ const onPagehide = (): void => {
458
+ // Emit BEFORE the flush so the summary rides the pagehide beacon —
459
+ // flushOnPagehide drains-and-no-ops on empty, so the sibling pagehide
460
+ // listener's own call stays harmless.
461
+ emit();
462
+ flushOnPagehide();
463
+ };
464
+
465
+ measure(); // above-the-fold baseline before any scroll fires
466
+
467
+ window.addEventListener('scroll', onScroll, { passive: true });
468
+ document.addEventListener('visibilitychange', onVisibility);
469
+ window.addEventListener('pagehide', onPagehide);
470
+ return () => {
471
+ window.removeEventListener('scroll', onScroll);
472
+ document.removeEventListener('visibilitychange', onVisibility);
473
+ window.removeEventListener('pagehide', onPagehide);
474
+ emit(); // client-side route change: summarize the route being left
475
+ };
476
+ }, [enabled, pathname]);
477
+
478
+ return null;
479
+ }
@@ -0,0 +1,43 @@
1
+ 'use client';
2
+
3
+ import { useEffect } from 'react';
4
+
5
+ /**
6
+ * Mounts the ForgeCart visual-editor guest runtime. Renders nothing.
7
+ *
8
+ * The runtime stays dormant until activated: it registers a single `message`
9
+ * listener and posts a READY beacon, then waits for the dashboard host to
10
+ * drive a nonce-gated handshake over `postMessage` (the activation nonce
11
+ * arrives via the `#fcd=` URL fragment — there is no env switch). On a page
12
+ * that is not embedded in an iframe it never even loads.
13
+ *
14
+ * Production builds ship zero bytes of this: the literal `NODE_ENV` check is
15
+ * inlined by the bundler, so the effect body — including the dynamic import
16
+ * and its chunk — is dead-code-eliminated from `next build` output.
17
+ */
18
+ export function ForgecartDesigner() {
19
+ useEffect(() => {
20
+ if (process.env.NODE_ENV !== 'development') {
21
+ return;
22
+ }
23
+ // Top-level pages have no designer host; skip loading the chunk entirely.
24
+ if (window.parent === window) {
25
+ return;
26
+ }
27
+ let cancelled = false;
28
+ let dispose: (() => void) | undefined;
29
+ const install = async () => {
30
+ const mod = await import('@forgecart/designer-runtime');
31
+ if (cancelled) {
32
+ return;
33
+ }
34
+ dispose = mod.installDesignerRuntime();
35
+ };
36
+ void install();
37
+ return () => {
38
+ cancelled = true;
39
+ dispose?.();
40
+ };
41
+ }, []);
42
+ return null;
43
+ }
@@ -0,0 +1,49 @@
1
+ import Link from 'next/link';
2
+ import type { ComponentProps, ReactNode } from 'react';
3
+
4
+ import { localizedPath } from '../lib/locale/localized-path';
5
+ import type { LocaleBinding } from '../lib/locale/localized-path';
6
+
7
+ /**
8
+ * An internal link that stays inside the request's locale cluster (#1346,
9
+ * W1-8 / S2).
10
+ *
11
+ * Every internal navigation goes through this instead of `next/link` directly.
12
+ * A German page whose links all point at the English cluster leaves every
13
+ * German page but the entry point unreachable by following links, so a crawler
14
+ * never discovers them and the locale is effectively unindexed.
15
+ *
16
+ * The locale arrives as a PROP and is never derived here. Under the middleware
17
+ * rewrite the browser's URL and the rendered route differ — `/de/products`
18
+ * renders the `/products` route — so `usePathname()` returns the REWRITTEN
19
+ * path, and a component that read the locale from it would emit English links
20
+ * on every German page while looking correct in review. Server components take
21
+ * the binding from `getRequestLocale()`; client components receive it from
22
+ * whichever server component renders them.
23
+ *
24
+ * `href` is the UNPREFIXED route path, always — the same string you would have
25
+ * written before the locale layer existed. Prefixing is this component's job.
26
+ */
27
+
28
+ // `locale` is omitted because `next/link` HAS a prop by that name — the Pages
29
+ // Router i18n feature, typed `string | false`. This storefront does its own
30
+ // locale routing (App Router, middleware rewrite), so that prop is never the
31
+ // one we want, and forwarding it would collide with the binding below: the
32
+ // production build fails with TS2430, which `next dev` never reports.
33
+ type ForwardedLinkProps = Omit<ComponentProps<typeof Link>, 'href' | 'children' | 'locale'>;
34
+
35
+ export interface LocaleLinkProps extends ForwardedLinkProps {
36
+ /** The unprefixed route path, e.g. `/products`. */
37
+ href: string;
38
+ /** The request's locale binding, from `getRequestLocale()`. */
39
+ locale: LocaleBinding;
40
+ children: ReactNode;
41
+ }
42
+
43
+ export function LocaleLink({ href, locale, children, ...rest }: LocaleLinkProps): ReactNode {
44
+ return (
45
+ <Link href={localizedPath(href, locale)} {...rest}>
46
+ {children}
47
+ </Link>
48
+ );
49
+ }
@@ -0,0 +1,75 @@
1
+ import { cva, type VariantProps } from 'class-variance-authority';
2
+ import type { ComponentProps } from 'react';
3
+
4
+ import { cn } from './utils';
5
+
6
+ /**
7
+ * The shadcn/ui alert, with the status variants this variant declares.
8
+ *
9
+ * shadcn/ui ships `default` and `destructive`; the storefront also speaks
10
+ * success, warning and info, so those three are variants here, each filled
11
+ * with its own `--success` / `--warning` / `--info` token and the
12
+ * `-foreground` that stays legible on it (see the mapping table in
13
+ * `src/app/globals.css`). An alert is `role="alert"` unless the caller says
14
+ * otherwise — a polite outcome passes `role="status"`.
15
+ */
16
+ const alertVariants = cva(
17
+ 'relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
18
+ {
19
+ variants: {
20
+ variant: {
21
+ default: 'bg-card text-card-foreground',
22
+ destructive:
23
+ 'bg-card text-destructive [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90',
24
+ success:
25
+ 'border-success bg-success text-success-foreground *:data-[slot=alert-description]:text-success-foreground/90',
26
+ warning:
27
+ 'border-warning bg-warning text-warning-foreground *:data-[slot=alert-description]:text-warning-foreground/90',
28
+ info: 'border-info bg-info text-info-foreground *:data-[slot=alert-description]:text-info-foreground/90',
29
+ },
30
+ },
31
+ defaultVariants: {
32
+ variant: 'default',
33
+ },
34
+ },
35
+ );
36
+
37
+ function Alert({
38
+ className,
39
+ variant,
40
+ ...props
41
+ }: ComponentProps<'div'> & VariantProps<typeof alertVariants>) {
42
+ return (
43
+ <div
44
+ data-slot="alert"
45
+ role="alert"
46
+ className={cn(alertVariants({ variant }), className)}
47
+ {...props}
48
+ />
49
+ );
50
+ }
51
+
52
+ function AlertTitle({ className, ...props }: ComponentProps<'div'>) {
53
+ return (
54
+ <div
55
+ data-slot="alert-title"
56
+ className={cn('col-start-2 min-h-4 font-medium tracking-tight', className)}
57
+ {...props}
58
+ />
59
+ );
60
+ }
61
+
62
+ function AlertDescription({ className, ...props }: ComponentProps<'div'>) {
63
+ return (
64
+ <div
65
+ data-slot="alert-description"
66
+ className={cn(
67
+ 'col-start-2 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed',
68
+ className,
69
+ )}
70
+ {...props}
71
+ />
72
+ );
73
+ }
74
+
75
+ export { Alert, AlertDescription, AlertTitle };
@@ -0,0 +1,40 @@
1
+ import { Slot } from '@radix-ui/react-slot';
2
+ import { cva, type VariantProps } from 'class-variance-authority';
3
+ import type { ComponentProps } from 'react';
4
+
5
+ import { cn } from './utils';
6
+
7
+ /**
8
+ * The shadcn/ui badge — the cart count, a subscription line's label, an
9
+ * applied coupon. As with the button, the variant shadcn/ui paints with a raw
10
+ * `text-white` is not vendored: every colour here is a token.
11
+ */
12
+ const badgeVariants = cva(
13
+ 'inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap rounded-md border px-2 py-0.5 text-xs font-medium transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 [&>svg]:pointer-events-none [&>svg]:size-3',
14
+ {
15
+ variants: {
16
+ variant: {
17
+ default: 'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
18
+ secondary:
19
+ 'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
20
+ outline: 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
21
+ },
22
+ },
23
+ defaultVariants: {
24
+ variant: 'default',
25
+ },
26
+ },
27
+ );
28
+
29
+ function Badge({
30
+ className,
31
+ variant,
32
+ asChild = false,
33
+ ...props
34
+ }: ComponentProps<'span'> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
35
+ const Comp = asChild ? Slot : 'span';
36
+
37
+ return <Comp data-slot="badge" className={cn(badgeVariants({ variant }), className)} {...props} />;
38
+ }
39
+
40
+ export { Badge, badgeVariants };