@cartbase/storefront 0.5.0 → 0.7.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/README.md +75 -58
- package/package.json +11 -1
- package/src/api/integrations.ts +118 -117
- package/src/api/products.ts +7 -0
- package/src/products/product-actions.tsx +15 -9
- package/src/tracking/attribution.ts +126 -0
- package/src/tracking/consent.ts +14 -0
- package/src/tracking/events.ts +294 -0
- package/src/tracking/ga4.tsx +93 -49
- package/src/tracking/get-tracking-attribution.ts +30 -0
- package/src/tracking/google-ads.ts +84 -0
- package/src/tracking/gtag.ts +26 -13
- package/src/tracking/gtm.tsx +60 -0
- package/src/tracking/index.ts +176 -133
- package/src/tracking/inline-script.ts +49 -0
- package/src/tracking/storefront-tags.tsx +67 -0
- package/src/tracking/tiktok-pixel.tsx +83 -0
- package/src/tracking/track-init.tsx +56 -0
- package/src/tracking/track-order-purchase.tsx +122 -0
- package/src/tracking/ttq.ts +180 -0
- package/src/tracking/types.ts +23 -0
- package/src/tracking/use-tracking-config.ts +54 -0
package/README.md
CHANGED
|
@@ -1,58 +1,75 @@
|
|
|
1
|
-
# @cartbase/storefront
|
|
2
|
-
|
|
3
|
-
The storefront kit for the [Cartbase](https://cartbase.net) commerce
|
|
4
|
-
platform: a fully-typed API client for every store endpoint, checkout
|
|
5
|
-
orchestration, a production-grade cart drawer, product and catalog
|
|
6
|
-
components, and consent-gated tracking — everything a storefront needs
|
|
7
|
-
between your pages and your store.
|
|
8
|
-
|
|
9
|
-
```bash
|
|
10
|
-
npm create cartbase my-store # scaffold with everything prewired
|
|
11
|
-
# or add to an existing Next.js app:
|
|
12
|
-
npm install @cartbase/storefront
|
|
13
|
-
```
|
|
14
|
-
|
|
15
|
-
## Setup
|
|
16
|
-
|
|
17
|
-
The package is **source-shipped TypeScript** — tell Next.js to compile it:
|
|
18
|
-
|
|
19
|
-
```js
|
|
20
|
-
// next.config.js
|
|
21
|
-
module.exports = {
|
|
22
|
-
transpilePackages: ["@cartbase/storefront"],
|
|
23
|
-
}
|
|
24
|
-
```
|
|
25
|
-
|
|
26
|
-
Three environment values wire it to your store (all in your Cartbase
|
|
27
|
-
admin under **Settings**):
|
|
28
|
-
|
|
29
|
-
```bash
|
|
30
|
-
NEXT_PUBLIC_CARTBASE_URL= # your store's API origin
|
|
31
|
-
NEXT_PUBLIC_CARTBASE_CLIENT_ID= # your store's client id
|
|
32
|
-
NEXT_PUBLIC_CARTBASE_PUBLISHABLE_KEY= # optional, channel-scoped catalogs
|
|
33
|
-
```
|
|
34
|
-
|
|
35
|
-
## What's inside
|
|
36
|
-
|
|
37
|
-
| Import | What you get |
|
|
38
|
-
|---|---|
|
|
39
|
-
| `@cartbase/storefront/api` | Typed client for the whole store API — products, carts, checkout, orders, customers, auth, content, search, reviews |
|
|
40
|
-
| `@cartbase/storefront/checkout` | The complete checkout: orchestration hook, address forms, shipping/payment method lists, Stripe wrapper, carrier pickers |
|
|
41
|
-
| `@cartbase/storefront/cart-drawer` | Slide-out cart with upsells, tiers, gift options, notes |
|
|
42
|
-
| `@cartbase/storefront/products` | PDP building blocks: gallery, options, prices, actions |
|
|
43
|
-
| `@cartbase/storefront/store` | Listing pages: pagination, sorting, collection/category/search templates |
|
|
44
|
-
| `@cartbase/storefront/order` | Order confirmation surfaces |
|
|
45
|
-
| `@cartbase/storefront/tracking` | Consent banner
|
|
46
|
-
| `@cartbase/storefront/theme` | The design system: token names plus a filled default set of values (Tailwind 4, CSS-first) |
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
The
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
1
|
+
# @cartbase/storefront
|
|
2
|
+
|
|
3
|
+
The storefront kit for the [Cartbase](https://cartbase.net) commerce
|
|
4
|
+
platform: a fully-typed API client for every store endpoint, checkout
|
|
5
|
+
orchestration, a production-grade cart drawer, product and catalog
|
|
6
|
+
components, and consent-gated tracking — everything a storefront needs
|
|
7
|
+
between your pages and your store.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm create cartbase my-store # scaffold with everything prewired
|
|
11
|
+
# or add to an existing Next.js app:
|
|
12
|
+
npm install @cartbase/storefront
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Setup
|
|
16
|
+
|
|
17
|
+
The package is **source-shipped TypeScript** — tell Next.js to compile it:
|
|
18
|
+
|
|
19
|
+
```js
|
|
20
|
+
// next.config.js
|
|
21
|
+
module.exports = {
|
|
22
|
+
transpilePackages: ["@cartbase/storefront"],
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Three environment values wire it to your store (all in your Cartbase
|
|
27
|
+
admin under **Settings**):
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
NEXT_PUBLIC_CARTBASE_URL= # your store's API origin
|
|
31
|
+
NEXT_PUBLIC_CARTBASE_CLIENT_ID= # your store's client id
|
|
32
|
+
NEXT_PUBLIC_CARTBASE_PUBLISHABLE_KEY= # optional, channel-scoped catalogs
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## What's inside
|
|
36
|
+
|
|
37
|
+
| Import | What you get |
|
|
38
|
+
|---|---|
|
|
39
|
+
| `@cartbase/storefront/api` | Typed client for the whole store API — products, carts, checkout, orders, customers, auth, content, search, reviews |
|
|
40
|
+
| `@cartbase/storefront/checkout` | The complete checkout: orchestration hook, address forms, shipping/payment method lists, Stripe wrapper, carrier pickers |
|
|
41
|
+
| `@cartbase/storefront/cart-drawer` | Slide-out cart with upsells, tiers, gift options, notes |
|
|
42
|
+
| `@cartbase/storefront/products` | PDP building blocks: gallery, options, prices, actions |
|
|
43
|
+
| `@cartbase/storefront/store` | Listing pages: pagination, sorting, collection/category/search templates |
|
|
44
|
+
| `@cartbase/storefront/order` | Order confirmation surfaces |
|
|
45
|
+
| `@cartbase/storefront/tracking` | Consent banner and Consent Mode v2, every marketing tag the store configured mounted from its own config (Meta Pixel, TikTok, Google Analytics 4 and Google Ads on one tag, Google Tag Manager), and one call per commerce moment that fires all of them |
|
|
46
|
+
| `@cartbase/storefront/theme` | The design system: token names plus a filled default set of values (Tailwind 4, CSS-first) |
|
|
47
|
+
|
|
48
|
+
### Tracking, in three lines
|
|
49
|
+
|
|
50
|
+
The merchant configures their pixels in the admin; the storefront mounts
|
|
51
|
+
what they configured. Nothing per vendor:
|
|
52
|
+
|
|
53
|
+
```tsx
|
|
54
|
+
<ConsentInit /> // first child of <body>, sets the consent defaults
|
|
55
|
+
<StorefrontTags client={client} /> // every configured tag, gated by the above
|
|
56
|
+
<TrackInit /> // captures UTMs + ad-click ids on the landing page
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Then one call per moment (`trackProductView`, `trackCartAdd`,
|
|
60
|
+
`trackCheckoutStart`) and `<TrackOrderPurchase>` on the confirmation
|
|
61
|
+
page. Purchase events dedupe against the platform's own server-side
|
|
62
|
+
events automatically — the ids are derived from the order number on both
|
|
63
|
+
sides, so the browser event and the server event count as one conversion.
|
|
64
|
+
|
|
65
|
+
## Documentation
|
|
66
|
+
|
|
67
|
+
The complete reference — every endpoint with request/response shapes and
|
|
68
|
+
working examples, plus a step-by-step build runbook — is written to be
|
|
69
|
+
followed by a developer **or handed to a coding agent as-is**. It ships
|
|
70
|
+
inside every `npm create cartbase` scaffold under `docs/`, and online at
|
|
71
|
+
your store's docs site. Start with *Build a storefront — the runbook*.
|
|
72
|
+
|
|
73
|
+
## License
|
|
74
|
+
|
|
75
|
+
MIT © Cartbase
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cartbase/storefront",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Storefront SDK + UI component library for Cartbase stores: typed API client, checkout orchestration, cart drawer, product/catalog components, tracking. Source-shipped TypeScript — add it to transpilePackages.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -77,12 +77,22 @@
|
|
|
77
77
|
"./tracking/consent-banner": "./src/tracking/consent-banner.tsx",
|
|
78
78
|
"./tracking/meta-pixel": "./src/tracking/meta-pixel.tsx",
|
|
79
79
|
"./tracking/ga4": "./src/tracking/ga4.tsx",
|
|
80
|
+
"./tracking/tiktok-pixel": "./src/tracking/tiktok-pixel.tsx",
|
|
81
|
+
"./tracking/ttq": "./src/tracking/ttq.ts",
|
|
82
|
+
"./tracking/events": "./src/tracking/events.ts",
|
|
83
|
+
"./tracking/storefront-tags": "./src/tracking/storefront-tags.tsx",
|
|
84
|
+
"./tracking/track-init": "./src/tracking/track-init.tsx",
|
|
85
|
+
"./tracking/track-order-purchase": "./src/tracking/track-order-purchase.tsx",
|
|
86
|
+
"./tracking/gtm": "./src/tracking/gtm.tsx",
|
|
87
|
+
"./tracking/inline-script": "./src/tracking/inline-script.ts",
|
|
88
|
+
"./tracking/google-ads": "./src/tracking/google-ads.ts",
|
|
80
89
|
"./tracking/rybbit": "./src/tracking/rybbit.tsx",
|
|
81
90
|
"./tracking/rybbit-events": "./src/tracking/rybbit-events.ts",
|
|
82
91
|
"./tracking/fbq": "./src/tracking/fbq.ts",
|
|
83
92
|
"./tracking/gtag": "./src/tracking/gtag.ts",
|
|
84
93
|
"./tracking/attribution": "./src/tracking/attribution.ts",
|
|
85
94
|
"./tracking/get-tracking-config": "./src/tracking/get-tracking-config.ts",
|
|
95
|
+
"./tracking/use-tracking-config": "./src/tracking/use-tracking-config.ts",
|
|
86
96
|
"./tracking/get-tracking-attribution": "./src/tracking/get-tracking-attribution.ts",
|
|
87
97
|
"./tracking/use-engagement-time": "./src/tracking/use-engagement-time.ts",
|
|
88
98
|
"./checkout": "./src/checkout/index.ts",
|
package/src/api/integrations.ts
CHANGED
|
@@ -1,117 +1,118 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @cartbase/storefront/api/integrations — store-public integrations config
|
|
3
|
-
* (couriers-port + tracking-integrations cards).
|
|
4
|
-
*
|
|
5
|
-
* Ground truth: src/app/api/store/integrations/{route.ts,boxnow/lockers/
|
|
6
|
-
* route.ts} + src/lib/integrations/store-config.ts +
|
|
7
|
-
* src/lib/tracking/store-config-block.ts.
|
|
8
|
-
*
|
|
9
|
-
* The config payload is COMPOSED from an ordered block registry — each
|
|
10
|
-
* block owns distinct top-level keys (`carriers`, `tracking` today; future
|
|
11
|
-
* blocks append). NOT wrapped in an envelope: the blocks ARE the top-level
|
|
12
|
-
* keys. (The `cod` block died with the 'cod' integration, 2026-08-11 —
|
|
13
|
-
* method fees now ride the payment listing entries themselves.)
|
|
14
|
-
*
|
|
15
|
-
* SECURITY LAW: every block is an explicit allowlist — credentials
|
|
16
|
-
* (carrier API keys, CAPI access_token, GA4 api_secret, Klaviyo
|
|
17
|
-
* private_key) can NEVER appear in this payload; the contract tests assert
|
|
18
|
-
* it key-by-key.
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
import type { StorefrontClient } from "./http"
|
|
22
|
-
|
|
23
|
-
/** Public capability flags of one ENABLED carrier. */
|
|
24
|
-
export interface PublicCarrierConfig {
|
|
25
|
-
enabled: true
|
|
26
|
-
/** Carrier supports cash-on-delivery collection. */
|
|
27
|
-
cod: boolean
|
|
28
|
-
/** Office/pickup-point delivery. */
|
|
29
|
-
pickup_points: boolean
|
|
30
|
-
/** Locker/APM network. */
|
|
31
|
-
lockers: boolean
|
|
32
|
-
/** Present only when lockers=true — the public locker-directory endpoint
|
|
33
|
-
* (path relative to the API base, e.g. `/api/store/integrations/boxnow/lockers`). */
|
|
34
|
-
lockers_url?: string
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** Public tag config — only ENABLED providers with a public id appear.
|
|
38
|
-
* Secrets can never appear here (explicit allowlist). */
|
|
39
|
-
export interface StoreTrackingBlock {
|
|
40
|
-
facebookPixel?: { pixelId: string }
|
|
41
|
-
gtm?: { containerId: string }
|
|
42
|
-
ga4?: { measurementId: string }
|
|
43
|
-
klaviyo?: { publicKey: string }
|
|
44
|
-
googleAds?: { conversionId: string; conversionLabel?: string }
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
* client
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
1
|
+
/**
|
|
2
|
+
* @cartbase/storefront/api/integrations — store-public integrations config
|
|
3
|
+
* (couriers-port + tracking-integrations cards).
|
|
4
|
+
*
|
|
5
|
+
* Ground truth: src/app/api/store/integrations/{route.ts,boxnow/lockers/
|
|
6
|
+
* route.ts} + src/lib/integrations/store-config.ts +
|
|
7
|
+
* src/lib/tracking/store-config-block.ts.
|
|
8
|
+
*
|
|
9
|
+
* The config payload is COMPOSED from an ordered block registry — each
|
|
10
|
+
* block owns distinct top-level keys (`carriers`, `tracking` today; future
|
|
11
|
+
* blocks append). NOT wrapped in an envelope: the blocks ARE the top-level
|
|
12
|
+
* keys. (The `cod` block died with the 'cod' integration, 2026-08-11 —
|
|
13
|
+
* method fees now ride the payment listing entries themselves.)
|
|
14
|
+
*
|
|
15
|
+
* SECURITY LAW: every block is an explicit allowlist — credentials
|
|
16
|
+
* (carrier API keys, CAPI access_token, GA4 api_secret, Klaviyo
|
|
17
|
+
* private_key) can NEVER appear in this payload; the contract tests assert
|
|
18
|
+
* it key-by-key.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { StorefrontClient } from "./http"
|
|
22
|
+
|
|
23
|
+
/** Public capability flags of one ENABLED carrier. */
|
|
24
|
+
export interface PublicCarrierConfig {
|
|
25
|
+
enabled: true
|
|
26
|
+
/** Carrier supports cash-on-delivery collection. */
|
|
27
|
+
cod: boolean
|
|
28
|
+
/** Office/pickup-point delivery. */
|
|
29
|
+
pickup_points: boolean
|
|
30
|
+
/** Locker/APM network. */
|
|
31
|
+
lockers: boolean
|
|
32
|
+
/** Present only when lockers=true — the public locker-directory endpoint
|
|
33
|
+
* (path relative to the API base, e.g. `/api/store/integrations/boxnow/lockers`). */
|
|
34
|
+
lockers_url?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Public tag config — only ENABLED providers with a public id appear.
|
|
38
|
+
* Secrets can never appear here (explicit allowlist). */
|
|
39
|
+
export interface StoreTrackingBlock {
|
|
40
|
+
facebookPixel?: { pixelId: string }
|
|
41
|
+
gtm?: { containerId: string }
|
|
42
|
+
ga4?: { measurementId: string }
|
|
43
|
+
klaviyo?: { publicKey: string }
|
|
44
|
+
googleAds?: { conversionId: string; conversionLabel?: string }
|
|
45
|
+
tiktok?: { pixelId: string }
|
|
46
|
+
/** True when the store's consent CMP is enabled — mount tags ONLY through
|
|
47
|
+
* the consent gate (`_1c_consent` / Consent Mode v2). */
|
|
48
|
+
consent_required: boolean
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The composed payload of GET /api/store/integrations. */
|
|
52
|
+
export interface StoreIntegrationsConfig {
|
|
53
|
+
/** Keyed by provider slug (e.g. `boxnow`, `econt`). Disabled carriers are
|
|
54
|
+
* ABSENT, never `enabled: false`. */
|
|
55
|
+
carriers: Record<string, PublicCarrierConfig>
|
|
56
|
+
tracking: StoreTrackingBlock
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* GET /api/store/integrations — everything a storefront needs at render/
|
|
61
|
+
* checkout time about the store's integrations, secrets excluded by
|
|
62
|
+
* construction.
|
|
63
|
+
*
|
|
64
|
+
* Auth: anon (x-client-id); `x-publishable-api-key` is VALIDATED when the
|
|
65
|
+
* client sends one (unknown/revoked/foreign → 400 invalid_publishable_key)
|
|
66
|
+
* and may be omitted by single-channel storefronts.
|
|
67
|
+
* Errors: 400 missing_client_id · 400 invalid_publishable_key.
|
|
68
|
+
* Settings: admin → Settings → Integrations (per-provider enable/config);
|
|
69
|
+
* consent settings drive `tracking.consent_required`.
|
|
70
|
+
*
|
|
71
|
+
* Storefront wiring: mount tags from `tracking` + consent state; Purchase
|
|
72
|
+
* events MUST use `eventID = "purchase_" + order.display_id` so Meta
|
|
73
|
+
* dedupes browser Pixel vs server CAPI; write TrackingAttribution keys into
|
|
74
|
+
* `cart.metadata` (consent-gated) so server events inherit fbp/fbc/ga
|
|
75
|
+
* signals.
|
|
76
|
+
*/
|
|
77
|
+
export async function getIntegrationsConfig(
|
|
78
|
+
client: StorefrontClient
|
|
79
|
+
): Promise<StoreIntegrationsConfig> {
|
|
80
|
+
return client.get("/api/store/integrations")
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** One BoxNow locker (APM) for the checkout picker. */
|
|
84
|
+
export interface BoxNowLocker {
|
|
85
|
+
id: string
|
|
86
|
+
title: string
|
|
87
|
+
addressLine1: string
|
|
88
|
+
addressLine2: string
|
|
89
|
+
postalCode: string
|
|
90
|
+
country: string
|
|
91
|
+
/** Numbers or null — malformed carrier coordinates coerce to null, never NaN. */
|
|
92
|
+
lat: number | null
|
|
93
|
+
lng: number | null
|
|
94
|
+
note: string
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface BoxNowLockersResponse {
|
|
98
|
+
lockers: BoxNowLocker[]
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* GET /api/store/integrations/boxnow/lockers — the BoxNow locker directory
|
|
103
|
+
* for the checkout locker picker. Cached hard (in-process 10-min TTL per
|
|
104
|
+
* store + `Cache-Control: public, max-age=600, stale-while-revalidate=3600`)
|
|
105
|
+
* — locker locations change on a weeks timescale.
|
|
106
|
+
*
|
|
107
|
+
* Auth: anon (x-client-id).
|
|
108
|
+
* Errors: 503 `{message, lockers: []}` when BoxNow is not configured/
|
|
109
|
+
* enabled for the store · 502 `{message, lockers: []}` when the carrier
|
|
110
|
+
* call fails. (Both carry the `lockers` key — a picker can always map over
|
|
111
|
+
* it.) Discover availability via `carriers.boxnow.lockers_url` on the
|
|
112
|
+
* integrations config instead of probing for the 503.
|
|
113
|
+
*/
|
|
114
|
+
export async function listBoxNowLockers(
|
|
115
|
+
client: StorefrontClient
|
|
116
|
+
): Promise<BoxNowLockersResponse> {
|
|
117
|
+
return client.get("/api/store/integrations/boxnow/lockers")
|
|
118
|
+
}
|
package/src/api/products.ts
CHANGED
|
@@ -116,6 +116,13 @@ export interface StoreProductVariant {
|
|
|
116
116
|
* context. Varies by customer group — never cache shared.
|
|
117
117
|
*/
|
|
118
118
|
calculated_price: CalculatedPrice | null
|
|
119
|
+
/**
|
|
120
|
+
* THE availability predicate, computed server-side (untracked or
|
|
121
|
+
* backorderable ⇒ true; otherwise kit-aware available stock > 0).
|
|
122
|
+
* Optional only for wire back-compat with platforms that predate it —
|
|
123
|
+
* when absent, components fall back to the optimistic legacy behavior.
|
|
124
|
+
*/
|
|
125
|
+
in_stock?: boolean
|
|
119
126
|
}
|
|
120
127
|
|
|
121
128
|
export interface StoreProduct {
|
|
@@ -14,13 +14,12 @@
|
|
|
14
14
|
* - `countryCode` (Medusa region routing) is dropped — Cartbase pricing
|
|
15
15
|
* context travels with the product fetch, and the host's `addToCart`
|
|
16
16
|
* closes over whatever routing it needs.
|
|
17
|
-
* - Stock:
|
|
18
|
-
*
|
|
19
|
-
* 400 `insufficient_inventory`
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* quantity check.
|
|
17
|
+
* - Stock: the variant DTO carries `in_stock`, computed server-side by THE
|
|
18
|
+
* availability predicate — this component only reads it. The server still
|
|
19
|
+
* enforces stock at add (400 `insufficient_inventory` flips the button to
|
|
20
|
+
* the out-of-stock state). On a legacy wire without the field, managed
|
|
21
|
+
* variants fall back to the old optimistic behavior; richer DTOs carrying
|
|
22
|
+
* `inventory_quantity` keep the original quantity check.
|
|
24
23
|
*
|
|
25
24
|
* Variant matching is the extracted pure module `./variant-matching`
|
|
26
25
|
* (handles Cartbase's option-value link shape).
|
|
@@ -121,10 +120,17 @@ export function ProductActions({
|
|
|
121
120
|
|
|
122
121
|
const inStock = useMemo(() => {
|
|
123
122
|
if (!selectedVariant) return false
|
|
123
|
+
// The server computes THE availability predicate and emits it as
|
|
124
|
+
// `in_stock` on the variant DTO — read it, never re-derive it here
|
|
125
|
+
// (the hand-copied clauses this replaced could not see stock levels and
|
|
126
|
+
// showed an enabled Add to cart on sold-out variants until a 400).
|
|
127
|
+
if (typeof selectedVariant.in_stock === "boolean") {
|
|
128
|
+
return selectedVariant.in_stock && !stockExhausted
|
|
129
|
+
}
|
|
130
|
+
// Legacy wire (platform predates the field): optimistic fallback — the
|
|
131
|
+
// server still enforces stock on add.
|
|
124
132
|
if (!selectedVariant.manage_inventory) return true
|
|
125
133
|
if (selectedVariant.allow_backorder) return true
|
|
126
|
-
// Cartbase wire shape carries no inventory_quantity — optimistic; the
|
|
127
|
-
// server enforces stock on add. Richer DTOs keep the original check.
|
|
128
134
|
const qty = (selectedVariant as { inventory_quantity?: number })
|
|
129
135
|
.inventory_quantity
|
|
130
136
|
if (qty === undefined) return !stockExhausted
|
|
@@ -265,6 +265,38 @@ export function normalisePhoneForHash(raw: string): string {
|
|
|
265
265
|
return digits
|
|
266
266
|
}
|
|
267
267
|
|
|
268
|
+
/**
|
|
269
|
+
* Normalise a phone for GOOGLE, which is not the same string as Meta's.
|
|
270
|
+
*
|
|
271
|
+
* Google requires E.164 WITH the leading plus: "must be 11 to 15 digits
|
|
272
|
+
* including a plus sign (+) prefix and country code with no dashes,
|
|
273
|
+
* parentheses, or spaces"
|
|
274
|
+
* (https://support.google.com/google-ads/answer/13258081). Meta requires
|
|
275
|
+
* digits only. Two different strings, two different digests, and a shared
|
|
276
|
+
* normaliser can only ever serve one of them.
|
|
277
|
+
*
|
|
278
|
+
* This existing as a separate function is the fix for a real defect:
|
|
279
|
+
* `setEnhancedConversions` fed Meta's digits-only value into Google's
|
|
280
|
+
* `sha256_phone_number`, which matched nobody, for as long as it shipped.
|
|
281
|
+
* It fails silently — gtag accepts the hash, no error is raised anywhere,
|
|
282
|
+
* and the only symptom is enhanced conversions recovering fewer
|
|
283
|
+
* conversions than they should. Whoever refactors these two into one
|
|
284
|
+
* "shared" helper will recreate it.
|
|
285
|
+
*
|
|
286
|
+
* Returns undefined when the result could not be a real E.164 number:
|
|
287
|
+
* hashing a malformed number is strictly worse than sending nothing,
|
|
288
|
+
* because it cannot match AND it occupies the field Google would
|
|
289
|
+
* otherwise fall back from.
|
|
290
|
+
*/
|
|
291
|
+
export function normalisePhoneForGoogleHash(raw: string): string | undefined {
|
|
292
|
+
const digits = normalisePhoneForHash(raw)
|
|
293
|
+
if (!digits) return undefined
|
|
294
|
+
// Google's stated range, country code included. A Bulgarian mobile is
|
|
295
|
+
// 12 digits (359 plus 9 national).
|
|
296
|
+
if (digits.length < 11 || digits.length > 15) return undefined
|
|
297
|
+
return "+" + digits
|
|
298
|
+
}
|
|
299
|
+
|
|
268
300
|
// ── UTM attribution capture ──────────────────────────────────────────
|
|
269
301
|
//
|
|
270
302
|
// Captures `utm_source / utm_medium / utm_campaign / utm_term /
|
|
@@ -416,3 +448,97 @@ export function getCapturedFirstTouchUtms(): CapturedUtms | null {
|
|
|
416
448
|
export function getCapturedLastTouchUtms(): CapturedUtms | null {
|
|
417
449
|
return readUtmsCookie(UTM_LAST_COOKIE)
|
|
418
450
|
}
|
|
451
|
+
|
|
452
|
+
// ── Ad-click identifiers ─────────────────────────────────────────────
|
|
453
|
+
//
|
|
454
|
+
// Meta's `fbclid` is folded into the `_fbc` cookie above by Meta's own
|
|
455
|
+
// format. TikTok's and Google's click ids have no such carrier, and both
|
|
456
|
+
// platforms need them:
|
|
457
|
+
//
|
|
458
|
+
// ttclid appended by TikTok to the landing URL on an ad click.
|
|
459
|
+
// Without it TikTok cannot tie a conversion back to the click
|
|
460
|
+
// that caused it, which is the reporting an advertiser checks
|
|
461
|
+
// first. Forwarded to the Events API as `user.ttclid`.
|
|
462
|
+
// _ttp a first-party cookie the TikTok pixel writes ITSELF once
|
|
463
|
+
// first-party cookies are enabled in pixel settings. We never
|
|
464
|
+
// write it, only read it; forwarded as `user.ttp`.
|
|
465
|
+
// gclid Google's standard click id.
|
|
466
|
+
// gbraid iOS app-to-web variant.
|
|
467
|
+
// wbraid iOS web-to-web variant.
|
|
468
|
+
//
|
|
469
|
+
// A Google campaign can deliver any one of those three, so capturing
|
|
470
|
+
// gclid alone silently loses iOS traffic — the traffic most likely to be
|
|
471
|
+
// missing a cookie in the first place.
|
|
472
|
+
//
|
|
473
|
+
// Write-once semantics deliberately do NOT apply: a newer click of the
|
|
474
|
+
// same kind overwrites, because the most recent click is what the ad
|
|
475
|
+
// platforms attribute to. What must never happen is a later ORGANIC
|
|
476
|
+
// visit clearing the click that acquired the visitor, and that is why
|
|
477
|
+
// each cookie is only touched when its parameter is actually present.
|
|
478
|
+
|
|
479
|
+
const TTCLID_COOKIE = "_1c_ttclid"
|
|
480
|
+
const GCLID_COOKIE = "_1c_gclid"
|
|
481
|
+
const GBRAID_COOKIE = "_1c_gbraid"
|
|
482
|
+
const WBRAID_COOKIE = "_1c_wbraid"
|
|
483
|
+
|
|
484
|
+
/** TikTok's ttclid lifetime follows the CTA window in Attribution
|
|
485
|
+
* Manager; Google's conversion window tops out at 90 days. One TTL
|
|
486
|
+
* covers both, and each platform applies its own window on top and
|
|
487
|
+
* ignores anything older. */
|
|
488
|
+
const CLICK_ID_TTL_DAYS = 90
|
|
489
|
+
|
|
490
|
+
/** Cookie name → the URL parameter it is captured from. */
|
|
491
|
+
const CLICK_ID_CAPTURE: ReadonlyArray<readonly [string, string]> = [
|
|
492
|
+
[TTCLID_COOKIE, "ttclid"],
|
|
493
|
+
[GCLID_COOKIE, "gclid"],
|
|
494
|
+
[GBRAID_COOKIE, "gbraid"],
|
|
495
|
+
[WBRAID_COOKIE, "wbraid"],
|
|
496
|
+
]
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* The cookies the click ids live in, and the flat `order.metadata` keys
|
|
500
|
+
* the checkout writeback maps them onto. `ttp` is in the list because it
|
|
501
|
+
* is READ from TikTok's own cookie; it is never written here.
|
|
502
|
+
*/
|
|
503
|
+
export const CLICK_ID_COOKIES = {
|
|
504
|
+
ttclid: TTCLID_COOKIE,
|
|
505
|
+
gclid: GCLID_COOKIE,
|
|
506
|
+
gbraid: GBRAID_COOKIE,
|
|
507
|
+
wbraid: WBRAID_COOKIE,
|
|
508
|
+
ttp: "_ttp",
|
|
509
|
+
} as const
|
|
510
|
+
|
|
511
|
+
/** cookie name → order.metadata key (code truth for the server side:
|
|
512
|
+
* `TrackingAttributionMeta` in src/lib/tracking/constants.ts). */
|
|
513
|
+
export const CLICK_ID_METADATA_KEYS: ReadonlyArray<readonly [string, string]> = [
|
|
514
|
+
[CLICK_ID_COOKIES.ttclid, "tt_ttclid"],
|
|
515
|
+
[CLICK_ID_COOKIES.ttp, "tt_ttp"],
|
|
516
|
+
[CLICK_ID_COOKIES.gclid, "google_gclid"],
|
|
517
|
+
[CLICK_ID_COOKIES.gbraid, "google_gbraid"],
|
|
518
|
+
[CLICK_ID_COOKIES.wbraid, "google_wbraid"],
|
|
519
|
+
]
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Capture every ad-click identifier present on the current URL.
|
|
523
|
+
*
|
|
524
|
+
* Idempotent and safe to call on every mount, next to
|
|
525
|
+
* `captureUtmsFromUrl()`: a URL carrying no click id leaves every
|
|
526
|
+
* existing cookie untouched.
|
|
527
|
+
*/
|
|
528
|
+
export function captureClickIdsFromUrl(): void {
|
|
529
|
+
if (!isBrowser()) return
|
|
530
|
+
|
|
531
|
+
let params: URLSearchParams
|
|
532
|
+
try {
|
|
533
|
+
params = new URLSearchParams(window.location.search)
|
|
534
|
+
} catch {
|
|
535
|
+
return
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
for (const [cookieName, param] of CLICK_ID_CAPTURE) {
|
|
539
|
+
const raw = params.get(param)
|
|
540
|
+
if (raw && raw.trim().length > 0) {
|
|
541
|
+
setCookie(cookieName, encodeURIComponent(raw.trim()), CLICK_ID_TTL_DAYS)
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
package/src/tracking/consent.ts
CHANGED
|
@@ -117,6 +117,20 @@ export function applyConsent(choices: ConsentChoices): void {
|
|
|
117
117
|
if (typeof w.fbq === "function") {
|
|
118
118
|
w.fbq("consent", choices.ads ? "grant" : "revoke")
|
|
119
119
|
}
|
|
120
|
+
|
|
121
|
+
// TikTok. `ttq` registers grantConsent / revokeConsent alongside track
|
|
122
|
+
// and page, and `setAndDefer` queues them before the SDK is fetched, so
|
|
123
|
+
// this works whether or not the pixel script has loaded yet.
|
|
124
|
+
//
|
|
125
|
+
// The decision is relayed HERE, in the one function every consent door
|
|
126
|
+
// already goes through (the built-in banner, setConsent for an external
|
|
127
|
+
// CMP), rather than by watching the cookie from outside. A vendor added
|
|
128
|
+
// to this list is live everywhere at once; a vendor that has to poll for
|
|
129
|
+
// the decision is a vendor that will one day miss it.
|
|
130
|
+
if (w.ttq && typeof w.ttq.grantConsent === "function") {
|
|
131
|
+
if (choices.ads) w.ttq.grantConsent()
|
|
132
|
+
else w.ttq.revokeConsent()
|
|
133
|
+
}
|
|
120
134
|
}
|
|
121
135
|
|
|
122
136
|
/**
|