@cartbase/storefront 0.6.0 → 0.8.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/src/api/http.ts CHANGED
@@ -4,9 +4,11 @@
4
4
  * Every SDK function in this package is a thin typed wrapper over
5
5
  * `storeFetch()` from a `StorefrontClient`. Auth model (store-api.md):
6
6
  *
7
- * - `x-client-id` required for anon reads (RLS scope)
8
- * - `x-publishable-api-key` optional; scopes catalog/carts to the key's
9
- * sales channels when set
7
+ * - `x-publishable-api-key` THE store's key: names the store on its own
8
+ * (one key, 2026-09-07) and scopes catalog and
9
+ * carts to the key's sales channels
10
+ * - `x-client-id` — the platform's own door (hosted builds carry it);
11
+ * optional, wins over the key when both are sent
10
12
  * - `authorization: Bearer <jwt>` — customer session (passwordless code
11
13
  * flow or supabase password login)
12
14
  * - `x-locale` — optional storefront locale hint
@@ -19,12 +21,16 @@
19
21
  import { StoreApiError } from "./types"
20
22
 
21
23
  export interface StorefrontClientConfig {
22
- /** Deployment origin, e.g. `https://admin.mindpages.bg` */
24
+ /** Deployment origin, e.g. `https://admin.cartbase.ai` */
23
25
  baseUrl: string
24
- /** Tenant id — required for anon reads. */
25
- clientId: string
26
- /** Publishable API key (channel scope). Optional for single-channel stores. */
26
+ /**
27
+ * The store's publishable key: names the store and scopes the catalog to
28
+ * the key's sales channels. The one input a merchant hands out; at least
29
+ * one of `publishableKey` and `clientId` is required.
30
+ */
27
31
  publishableKey?: string
32
+ /** Tenant id — the platform's own door. Optional when a key is given. */
33
+ clientId?: string
28
34
  /** Called per request; return the customer JWT or null for guests. */
29
35
  getAuthToken?: () => string | null | Promise<string | null>
30
36
  /** Called per request; return the active locale code or null. */
@@ -50,7 +56,9 @@ export interface RequestOptions {
50
56
  export class StorefrontClient {
51
57
  constructor(private readonly config: StorefrontClientConfig) {
52
58
  if (!config.baseUrl) throw new Error("StorefrontClient: baseUrl is required")
53
- if (!config.clientId) throw new Error("StorefrontClient: clientId is required")
59
+ if (!config.publishableKey && !config.clientId) {
60
+ throw new Error("StorefrontClient: publishableKey (or clientId) is required")
61
+ }
54
62
  }
55
63
 
56
64
  /** The low-level typed request. Domain modules call this — apps rarely should. */
@@ -72,7 +80,7 @@ export class StorefrontClient {
72
80
  }
73
81
 
74
82
  const headers: Record<string, string> = {
75
- "x-client-id": this.config.clientId,
83
+ ...(this.config.clientId ? { "x-client-id": this.config.clientId } : {}),
76
84
  ...(this.config.publishableKey
77
85
  ? { "x-publishable-api-key": this.config.publishableKey }
78
86
  : {}),
package/src/api/index.ts CHANGED
@@ -13,6 +13,7 @@ export * as products from "./products"
13
13
  export * as collections from "./collections"
14
14
  export * as categories from "./categories"
15
15
  export * as regions from "./regions"
16
+ export * as store from "./store"
16
17
  export * as carts from "./carts"
17
18
  export * as giftCards from "./gift-cards"
18
19
  export * as checkout from "./checkout"
@@ -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
- /** True when the store's consent CMP is enabled — mount tags ONLY through
46
- * the consent gate (`_1c_consent` / Consent Mode v2). */
47
- consent_required: boolean
48
- }
49
-
50
- /** The composed payload of GET /api/store/integrations. */
51
- export interface StoreIntegrationsConfig {
52
- /** Keyed by provider slug (e.g. `boxnow`, `econt`). Disabled carriers are
53
- * ABSENT, never `enabled: false`. */
54
- carriers: Record<string, PublicCarrierConfig>
55
- tracking: StoreTrackingBlock
56
- }
57
-
58
- /**
59
- * GET /api/store/integrations — everything a storefront needs at render/
60
- * checkout time about the store's integrations, secrets excluded by
61
- * construction.
62
- *
63
- * Auth: anon (x-client-id); `x-publishable-api-key` is VALIDATED when the
64
- * client sends one (unknown/revoked/foreign 400 invalid_publishable_key)
65
- * and may be omitted by single-channel storefronts.
66
- * Errors: 400 missing_client_id · 400 invalid_publishable_key.
67
- * Settings: admin Settings Integrations (per-provider enable/config);
68
- * consent settings drive `tracking.consent_required`.
69
- *
70
- * Storefront wiring: mount tags from `tracking` + consent state; Purchase
71
- * events MUST use `eventID = "purchase_" + order.display_id` so Meta
72
- * dedupes browser Pixel vs server CAPI; write TrackingAttribution keys into
73
- * `cart.metadata` (consent-gated) so server events inherit fbp/fbc/ga
74
- * signals.
75
- */
76
- export async function getIntegrationsConfig(
77
- client: StorefrontClient
78
- ): Promise<StoreIntegrationsConfig> {
79
- return client.get("/api/store/integrations")
80
- }
81
-
82
- /** One BoxNow locker (APM) for the checkout picker. */
83
- export interface BoxNowLocker {
84
- id: string
85
- title: string
86
- addressLine1: string
87
- addressLine2: string
88
- postalCode: string
89
- country: string
90
- /** Numbers or null — malformed carrier coordinates coerce to null, never NaN. */
91
- lat: number | null
92
- lng: number | null
93
- note: string
94
- }
95
-
96
- export interface BoxNowLockersResponse {
97
- lockers: BoxNowLocker[]
98
- }
99
-
100
- /**
101
- * GET /api/store/integrations/boxnow/lockers — the BoxNow locker directory
102
- * for the checkout locker picker. Cached hard (in-process 10-min TTL per
103
- * store + `Cache-Control: public, max-age=600, stale-while-revalidate=3600`)
104
- * locker locations change on a weeks timescale.
105
- *
106
- * Auth: anon (x-client-id).
107
- * Errors: 503 `{message, lockers: []}` when BoxNow is not configured/
108
- * enabled for the store · 502 `{message, lockers: []}` when the carrier
109
- * call fails. (Both carry the `lockers` key a picker can always map over
110
- * it.) Discover availability via `carriers.boxnow.lockers_url` on the
111
- * integrations config instead of probing for the 503.
112
- */
113
- export async function listBoxNowLockers(
114
- client: StorefrontClient
115
- ): Promise<BoxNowLockersResponse> {
116
- return client.get("/api/store/integrations/boxnow/lockers")
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
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * @cartbase/storefront/api/store — the store's identity.
3
+ *
4
+ * Name, slug and brand (the merchant's Settings → Brand): what a
5
+ * storefront needs to title, head and foot itself without hardcoding a
6
+ * name. One anonymous read at layout level. Docs: docs/storefront/store.md.
7
+ */
8
+ import type { StorefrontClient } from "./http"
9
+
10
+ export interface StoreBrand {
11
+ /** Primary logo, public URL, or null. */
12
+ logo_url: string | null
13
+ /** Square mark, public URL, or null. */
14
+ logo_square_url: string | null
15
+ /** `#rrggbb` or null. */
16
+ color_primary: string | null
17
+ /** `#rrggbb` or null. */
18
+ color_secondary: string | null
19
+ slogan: string | null
20
+ }
21
+
22
+ export interface StoreIdentity {
23
+ /** The store's public id: what `<PlatformInit storeId>` mounts. */
24
+ id: string
25
+ /** The store's display name. */
26
+ name: string
27
+ /** The store's slug; its address is `{slug}.cartbase.net` until a custom domain. */
28
+ slug: string
29
+ brand: StoreBrand
30
+ }
31
+
32
+ /** GET /api/store/store — anon. */
33
+ export function getStore(client: StorefrontClient): Promise<{ store: StoreIdentity }> {
34
+ return client.request<{ store: StoreIdentity }>("/api/store/store")
35
+ }
@@ -141,7 +141,7 @@ export async function geocodeAddress(
141
141
  url.searchParams.set("limit", "1")
142
142
 
143
143
  const res = await fetch(url.toString(), {
144
- headers: { "User-Agent": "barter-storefront/1.0" },
144
+ headers: { "User-Agent": "cartbase-storefront/1.0" },
145
145
  })
146
146
  const data = await res.json()
147
147
  if (data?.[0]) {
@@ -39,3 +39,48 @@ export function readCartCookie(
39
39
  ): string | undefined {
40
40
  return get(CART_COOKIE) ?? get(LEGACY_CART_COOKIE)
41
41
  }
42
+
43
+ /**
44
+ * THE VISITOR ID — our own per-browser id and the join key between browsing
45
+ * and money (ecommerce-analytics §3.1). It is OURS deliberately: the
46
+ * analytics engine is swappable, so a key owned by the engine would couple
47
+ * the whole platform to it.
48
+ *
49
+ * Written by the injected storefront proxy in a `Set-Cookie` header, never
50
+ * by `document.cookie`, and that is not a style preference: Safari's ITP
51
+ * caps JavaScript-written cookies at 7 days while server-set first-party
52
+ * cookies live up to 400. A JS-written id makes every returning Safari
53
+ * visitor read as new after a week.
54
+ */
55
+ export const VISITOR_COOKIE = "_cartbase_visitor"
56
+
57
+ /** @deprecated Pre-rename name — read-only compat, never written. */
58
+ export const LEGACY_VISITOR_COOKIE = "_1c_anon"
59
+
60
+ /** First-touch UTM tuple, written once, 365 days. */
61
+ export const UTM_FIRST_COOKIE = "_cartbase_utm_first"
62
+ /** @deprecated Pre-rename name — read-only compat, never written. */
63
+ export const LEGACY_UTM_FIRST_COOKIE = "_1c_utm_first"
64
+
65
+ /** Last-touch UTM tuple, rewritten on every UTM-bearing visit, 90 days. */
66
+ export const UTM_LAST_COOKIE = "_cartbase_utm_last"
67
+ /** @deprecated Pre-rename name — read-only compat, never written. */
68
+ export const LEGACY_UTM_LAST_COOKIE = "_1c_utm_last"
69
+
70
+ /** Known-visitor marker (localStorage, not a cookie). */
71
+ export const KNOWN_VISITOR_KEY = "_cartbase_visitor_v1"
72
+ /** @deprecated Pre-rename key — read-only compat, never written. */
73
+ export const LEGACY_KNOWN_VISITOR_KEY = "_1c_visitor_v1"
74
+
75
+ /**
76
+ * Read a value preferring the current name and falling back to the legacy
77
+ * one, so a visitor who arrived before the rename keeps their identity and
78
+ * their attribution instead of being counted as somebody new.
79
+ */
80
+ export function readRenamedCookie(
81
+ get: (name: string) => string | undefined,
82
+ current: string,
83
+ legacy: string
84
+ ): string | undefined {
85
+ return get(current) ?? get(legacy)
86
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The platform's origin (one key, store-birth-and-doors item 6, 2026-09-07):
3
+ * a constant, not an input. Every Cartbase store's API lives here; a
4
+ * storefront overrides it only to point at a local or staging platform
5
+ * (`NEXT_PUBLIC_CARTBASE_URL`), never to find its own store, which the
6
+ * publishable key does.
7
+ */
8
+ export const CARTBASE_API_ORIGIN = "https://admin.cartbase.ai"
9
+
10
+ /** The origin to call: the override when set, else the platform. */
11
+ export function cartbaseApiOrigin(override?: string | null): string {
12
+ return (override && override.trim()) || CARTBASE_API_ORIGIN
13
+ }
@@ -0,0 +1,74 @@
1
+ import { VISITOR_COOKIE, LEGACY_VISITOR_COOKIE } from "./cookie-names"
2
+
3
+ /**
4
+ * THE VISITOR'S TWO IDS. One module, because three copies of a cookie read
5
+ * had already appeared across the package and a fourth was about to.
6
+ *
7
+ * - The VISITOR id is the durable one. It is written by the platform's
8
+ * injected proxy as a `Set-Cookie` on the very first request, which is
9
+ * what makes it survive: Safari caps a cookie written by JavaScript at
10
+ * seven days, so a returning visitor read from a JS-written id looks new
11
+ * every week. It is the join key between a browsing session and the cart
12
+ * and order it becomes.
13
+ * - The TAB SESSION id is per tab and dies with it. Two tabs are two
14
+ * sessions, which is what a merchant counting live visitors sees on
15
+ * their own screen.
16
+ */
17
+
18
+ /**
19
+ * Reads the platform's first-party visitor cookie, preferring the current
20
+ * name and falling back to the pre-rename one. Server-side there is no
21
+ * document, and callers pass the value explicitly instead.
22
+ */
23
+ export function readVisitorId(): string | undefined {
24
+ if (typeof document === "undefined") return undefined
25
+ for (const name of [VISITOR_COOKIE, LEGACY_VISITOR_COOKIE]) {
26
+ const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`))
27
+ if (match) return decodeURIComponent(match[1])
28
+ }
29
+ return undefined
30
+ }
31
+
32
+ const TAB_SESSION_KEY = "_cartbase_live_session"
33
+
34
+ function randomId(): string {
35
+ return typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
36
+ ? crypto.randomUUID()
37
+ : `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`
38
+ }
39
+
40
+ /**
41
+ * One id per tab, shared by the heartbeat and the pageview tracker so the
42
+ * live count and the traffic report are talking about the same sessions.
43
+ */
44
+ export function getTabSessionId(): string {
45
+ try {
46
+ const existing = window.sessionStorage.getItem(TAB_SESSION_KEY)
47
+ if (existing) return existing
48
+ const id = randomId()
49
+ window.sessionStorage.setItem(TAB_SESSION_KEY, id)
50
+ return id
51
+ } catch {
52
+ // Private mode with storage blocked: a per-mount id still produces an
53
+ // honest count, it just does not survive a reload.
54
+ return randomId()
55
+ }
56
+ }
57
+
58
+ /**
59
+ * True when the platform's injected collector is already running on this
60
+ * page. It claims the flag synchronously, before hydration, so any React
61
+ * component that checks this on mount sees a settled answer.
62
+ *
63
+ * Why it exists: every deployed storefront gets the collector injected at
64
+ * deploy time, and the scaffold ALSO mounts <StorefrontTags>. Without this
65
+ * check every store built from the scaffold would count every pageview
66
+ * twice, which is the kind of defect that looks like growth.
67
+ */
68
+ export function platformCollectorPresent(): boolean {
69
+ try {
70
+ return Boolean((window as unknown as { __cartbase_collector?: unknown }).__cartbase_collector)
71
+ } catch {
72
+ return false
73
+ }
74
+ }