@cartbase/storefront 0.1.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 (176) hide show
  1. package/package.json +226 -0
  2. package/src/api/auth.ts +108 -0
  3. package/src/api/carts.ts +506 -0
  4. package/src/api/categories.ts +184 -0
  5. package/src/api/checkout.ts +440 -0
  6. package/src/api/collections.ts +130 -0
  7. package/src/api/consent.ts +75 -0
  8. package/src/api/content.ts +125 -0
  9. package/src/api/customers.ts +307 -0
  10. package/src/api/gift-cards.ts +112 -0
  11. package/src/api/http.ts +122 -0
  12. package/src/api/index.ts +29 -0
  13. package/src/api/integrations.ts +130 -0
  14. package/src/api/menus.ts +77 -0
  15. package/src/api/metaobjects.ts +136 -0
  16. package/src/api/orders.ts +290 -0
  17. package/src/api/products.ts +303 -0
  18. package/src/api/redirects.ts +37 -0
  19. package/src/api/regions.ts +141 -0
  20. package/src/api/reviews.ts +259 -0
  21. package/src/api/search.ts +133 -0
  22. package/src/api/types.ts +91 -0
  23. package/src/cart-drawer/cart-drawer.tsx +86 -0
  24. package/src/cart-drawer/context.tsx +569 -0
  25. package/src/cart-drawer/continue-shopping.tsx +28 -0
  26. package/src/cart-drawer/cross-sell-carousel.tsx +212 -0
  27. package/src/cart-drawer/cross-sell-sidebar.tsx +158 -0
  28. package/src/cart-drawer/empty.tsx +52 -0
  29. package/src/cart-drawer/free-gift.tsx +71 -0
  30. package/src/cart-drawer/gift-wrap.tsx +83 -0
  31. package/src/cart-drawer/header.tsx +52 -0
  32. package/src/cart-drawer/index.ts +69 -0
  33. package/src/cart-drawer/item/index.tsx +164 -0
  34. package/src/cart-drawer/item/quantity.tsx +100 -0
  35. package/src/cart-drawer/item/upsell.tsx +110 -0
  36. package/src/cart-drawer/item/variant.tsx +46 -0
  37. package/src/cart-drawer/labels-bg.ts +72 -0
  38. package/src/cart-drawer/labels.ts +119 -0
  39. package/src/cart-drawer/notes.tsx +131 -0
  40. package/src/cart-drawer/payment-badges.tsx +96 -0
  41. package/src/cart-drawer/promo-banner.tsx +43 -0
  42. package/src/cart-drawer/rewards-points.tsx +78 -0
  43. package/src/cart-drawer/sticky-footer.tsx +73 -0
  44. package/src/cart-drawer/summary-breakdown.tsx +196 -0
  45. package/src/cart-drawer/template.tsx +225 -0
  46. package/src/cart-drawer/tiered-progress.tsx +168 -0
  47. package/src/checkout/address-error-copy.ts +119 -0
  48. package/src/checkout/address-form.tsx +224 -0
  49. package/src/checkout/address-select.tsx +79 -0
  50. package/src/checkout/boxnow-locker-selector.tsx +410 -0
  51. package/src/checkout/checkout-client.tsx +222 -0
  52. package/src/checkout/company-details.tsx +94 -0
  53. package/src/checkout/compare-addresses.ts +40 -0
  54. package/src/checkout/context.tsx +76 -0
  55. package/src/checkout/discount-section.tsx +218 -0
  56. package/src/checkout/econt-office-selector.tsx +332 -0
  57. package/src/checkout/error-message.tsx +25 -0
  58. package/src/checkout/geocode.ts +154 -0
  59. package/src/checkout/gift-card-section.tsx +224 -0
  60. package/src/checkout/index.ts +74 -0
  61. package/src/checkout/labels-bg.ts +128 -0
  62. package/src/checkout/labels.ts +263 -0
  63. package/src/checkout/line-item-card.tsx +152 -0
  64. package/src/checkout/order-summary.tsx +524 -0
  65. package/src/checkout/payment-button.tsx +373 -0
  66. package/src/checkout/payment-error-copy.ts +269 -0
  67. package/src/checkout/payment-method-list.tsx +365 -0
  68. package/src/checkout/payment-wrapper.tsx +102 -0
  69. package/src/checkout/promotion-error-copy.ts +124 -0
  70. package/src/checkout/shipping-method-list.tsx +335 -0
  71. package/src/checkout/stripe-wrapper.tsx +165 -0
  72. package/src/checkout/use-checkout-orchestration.ts +1504 -0
  73. package/src/common/cart-button-client.tsx +39 -0
  74. package/src/common/cart-button.tsx +28 -0
  75. package/src/common/country-select.tsx +65 -0
  76. package/src/common/delete-button.tsx +66 -0
  77. package/src/common/index.ts +17 -0
  78. package/src/common/language-select.tsx +78 -0
  79. package/src/common/localized-link.tsx +45 -0
  80. package/src/common/skeleton.tsx +29 -0
  81. package/src/index.ts +12 -0
  82. package/src/lib/cart-helpers.ts +113 -0
  83. package/src/lib/dual-price.tsx +73 -0
  84. package/src/lib/get-percentage-diff.ts +5 -0
  85. package/src/lib/get-product-price.ts +133 -0
  86. package/src/lib/hooks/use-intersection.ts +30 -0
  87. package/src/lib/hooks/use-toggle-state.ts +25 -0
  88. package/src/lib/money.ts +73 -0
  89. package/src/lib/payment-constants.ts +66 -0
  90. package/src/lib/product.ts +22 -0
  91. package/src/lib/sort-products.ts +63 -0
  92. package/src/lib/store-api-error.ts +36 -0
  93. package/src/lib/utils.ts +16 -0
  94. package/src/order/context.tsx +32 -0
  95. package/src/order/index.ts +63 -0
  96. package/src/order/labels-bg.ts +39 -0
  97. package/src/order/labels.ts +79 -0
  98. package/src/order/order-address-card.tsx +47 -0
  99. package/src/order/order-completed-template.tsx +165 -0
  100. package/src/order/order-confirmation-header.tsx +65 -0
  101. package/src/order/order-delivery-card.tsx +258 -0
  102. package/src/order/order-help-section.tsx +47 -0
  103. package/src/order/order-item.tsx +201 -0
  104. package/src/order/order-items-list.tsx +52 -0
  105. package/src/order/order-payment-card.tsx +95 -0
  106. package/src/order/order-timeline.tsx +141 -0
  107. package/src/order/order-totals.tsx +245 -0
  108. package/src/primitives/field.tsx +125 -0
  109. package/src/primitives/select-field.tsx +77 -0
  110. package/src/primitives/ui/accordion.tsx +61 -0
  111. package/src/primitives/ui/button.tsx +68 -0
  112. package/src/primitives/ui/collapsible.tsx +16 -0
  113. package/src/primitives/ui/dialog.tsx +112 -0
  114. package/src/primitives/ui/input.tsx +30 -0
  115. package/src/primitives/ui/label.tsx +31 -0
  116. package/src/primitives/ui/popover.tsx +38 -0
  117. package/src/primitives/ui/select.tsx +163 -0
  118. package/src/primitives/ui/sheet.tsx +131 -0
  119. package/src/primitives/ui/tabs.tsx +62 -0
  120. package/src/products/context.tsx +34 -0
  121. package/src/products/image-gallery.tsx +43 -0
  122. package/src/products/index.ts +44 -0
  123. package/src/products/labels-bg.ts +35 -0
  124. package/src/products/labels.ts +57 -0
  125. package/src/products/mobile-actions.tsx +180 -0
  126. package/src/products/option-select.tsx +67 -0
  127. package/src/products/preview-price.tsx +36 -0
  128. package/src/products/product-actions-wrapper.tsx +58 -0
  129. package/src/products/product-actions.tsx +217 -0
  130. package/src/products/product-info.tsx +43 -0
  131. package/src/products/product-preview.tsx +49 -0
  132. package/src/products/product-price.tsx +69 -0
  133. package/src/products/product-tabs.tsx +169 -0
  134. package/src/products/product-template.tsx +114 -0
  135. package/src/products/purchase-options.tsx +130 -0
  136. package/src/products/related-products.tsx +86 -0
  137. package/src/products/thumbnail.tsx +71 -0
  138. package/src/products/variant-matching.ts +71 -0
  139. package/src/reviews-ui/helpers.ts +174 -0
  140. package/src/reviews-ui/index.ts +74 -0
  141. package/src/reviews-ui/labels-bg.ts +91 -0
  142. package/src/reviews-ui/labels.ts +199 -0
  143. package/src/reviews-ui/photo-upload.tsx +345 -0
  144. package/src/reviews-ui/review-list.tsx +249 -0
  145. package/src/reviews-ui/review-widget.tsx +224 -0
  146. package/src/reviews-ui/review-wizard.tsx +560 -0
  147. package/src/reviews-ui/star-badge.tsx +104 -0
  148. package/src/reviews-ui/wizard-state.ts +81 -0
  149. package/src/store/category-template.tsx +129 -0
  150. package/src/store/collection-template.tsx +139 -0
  151. package/src/store/index.ts +41 -0
  152. package/src/store/labels-bg.ts +22 -0
  153. package/src/store/labels.ts +52 -0
  154. package/src/store/paginated-products.tsx +116 -0
  155. package/src/store/pagination.tsx +103 -0
  156. package/src/store/search-params.ts +256 -0
  157. package/src/store/search-template.tsx +249 -0
  158. package/src/store/skeleton-product-grid.tsx +26 -0
  159. package/src/store/sort-select.tsx +81 -0
  160. package/src/store/store-template.tsx +65 -0
  161. package/src/tracking/attribution.ts +418 -0
  162. package/src/tracking/consent-banner.tsx +355 -0
  163. package/src/tracking/consent-init.tsx +44 -0
  164. package/src/tracking/consent.ts +243 -0
  165. package/src/tracking/fbq.ts +168 -0
  166. package/src/tracking/ga4.tsx +49 -0
  167. package/src/tracking/get-tracking-attribution.ts +224 -0
  168. package/src/tracking/get-tracking-config.ts +50 -0
  169. package/src/tracking/gtag.ts +200 -0
  170. package/src/tracking/index.ts +133 -0
  171. package/src/tracking/meta-pixel.tsx +166 -0
  172. package/src/tracking/rybbit-events.ts +242 -0
  173. package/src/tracking/rybbit.tsx +40 -0
  174. package/src/tracking/types.ts +185 -0
  175. package/src/tracking/use-engagement-time.ts +58 -0
  176. package/tailwind-preset.cjs +72 -0
@@ -0,0 +1,130 @@
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`, `cod`, `tracking` today;
11
+ * future blocks append). NOT wrapped in an envelope: the blocks ARE the
12
+ * top-level keys.
13
+ *
14
+ * SECURITY LAW: every block is an explicit allowlist — credentials
15
+ * (carrier API keys, CAPI access_token, GA4 api_secret, Klaviyo
16
+ * private_key) can NEVER appear in this payload; the contract tests assert
17
+ * it key-by-key.
18
+ */
19
+
20
+ import type { StorefrontClient } from "./http"
21
+
22
+ /** Public capability flags of one ENABLED carrier. */
23
+ export interface PublicCarrierConfig {
24
+ enabled: true
25
+ /** Carrier supports cash-on-delivery collection. */
26
+ cod: boolean
27
+ /** Office/pickup-point delivery. */
28
+ pickup_points: boolean
29
+ /** Locker/APM network. */
30
+ lockers: boolean
31
+ /** Present only when lockers=true — the public locker-directory endpoint
32
+ * (path relative to the API base, e.g. `/api/store/integrations/boxnow/lockers`). */
33
+ lockers_url?: string
34
+ }
35
+
36
+ /** COD checkout block — null unless enabled AND fee_amount > 0 (the exact
37
+ * gate the totals engine applies, so the optimistic fee row never disagrees
38
+ * with the charged total). */
39
+ export interface PublicCodConfig {
40
+ enabled: true
41
+ /** EUR major units. */
42
+ fee_amount: number
43
+ fee_currency: "eur"
44
+ fee_label: string
45
+ /** Admin's checkout note, or null. */
46
+ description: string | null
47
+ }
48
+
49
+ /** Public tag config — only ENABLED providers with a public id appear.
50
+ * Secrets can never appear here (explicit allowlist). */
51
+ export interface StoreTrackingBlock {
52
+ facebookPixel?: { pixelId: string }
53
+ gtm?: { containerId: string }
54
+ ga4?: { measurementId: string }
55
+ klaviyo?: { publicKey: string }
56
+ googleAds?: { conversionId: string; conversionLabel?: string }
57
+ /** True when the store's consent CMP is enabled — mount tags ONLY through
58
+ * the consent gate (`_1c_consent` / Consent Mode v2). */
59
+ consent_required: boolean
60
+ }
61
+
62
+ /** The composed payload of GET /api/store/integrations. */
63
+ export interface StoreIntegrationsConfig {
64
+ /** Keyed by provider slug (e.g. `boxnow`, `econt`). Disabled carriers are
65
+ * ABSENT, never `enabled: false`. */
66
+ carriers: Record<string, PublicCarrierConfig>
67
+ cod: PublicCodConfig | null
68
+ tracking: StoreTrackingBlock
69
+ }
70
+
71
+ /**
72
+ * GET /api/store/integrations — everything a storefront needs at render/
73
+ * checkout time about the store's integrations, secrets excluded by
74
+ * construction.
75
+ *
76
+ * Auth: anon (x-client-id); `x-publishable-api-key` is VALIDATED when the
77
+ * client sends one (unknown/revoked/foreign → 400 invalid_publishable_key)
78
+ * and may be omitted by single-channel storefronts.
79
+ * Errors: 400 missing_client_id · 400 invalid_publishable_key.
80
+ * Settings: admin → Settings → Integrations (per-provider enable/config);
81
+ * consent settings drive `tracking.consent_required`.
82
+ *
83
+ * Storefront wiring: mount tags from `tracking` + consent state; Purchase
84
+ * events MUST use `eventID = "purchase_" + order.display_id` so Meta
85
+ * dedupes browser Pixel vs server CAPI; write TrackingAttribution keys into
86
+ * `cart.metadata` (consent-gated) so server events inherit fbp/fbc/ga
87
+ * signals.
88
+ */
89
+ export async function getIntegrationsConfig(
90
+ client: StorefrontClient
91
+ ): Promise<StoreIntegrationsConfig> {
92
+ return client.get("/api/store/integrations")
93
+ }
94
+
95
+ /** One BoxNow locker (APM) for the checkout picker. */
96
+ export interface BoxNowLocker {
97
+ id: string
98
+ title: string
99
+ addressLine1: string
100
+ addressLine2: string
101
+ postalCode: string
102
+ country: string
103
+ /** Numbers or null — malformed carrier coordinates coerce to null, never NaN. */
104
+ lat: number | null
105
+ lng: number | null
106
+ note: string
107
+ }
108
+
109
+ export interface BoxNowLockersResponse {
110
+ lockers: BoxNowLocker[]
111
+ }
112
+
113
+ /**
114
+ * GET /api/store/integrations/boxnow/lockers — the BoxNow locker directory
115
+ * for the checkout locker picker. Cached hard (in-process 10-min TTL per
116
+ * store + `Cache-Control: public, max-age=600, stale-while-revalidate=3600`)
117
+ * — locker locations change on a weeks timescale.
118
+ *
119
+ * Auth: anon (x-client-id).
120
+ * Errors: 503 `{message, lockers: []}` when BoxNow is not configured/
121
+ * enabled for the store · 502 `{message, lockers: []}` when the carrier
122
+ * call fails. (Both carry the `lockers` key — a picker can always map over
123
+ * it.) Discover availability via `carriers.boxnow.lockers_url` on the
124
+ * integrations config instead of probing for the 503.
125
+ */
126
+ export async function listBoxNowLockers(
127
+ client: StorefrontClient
128
+ ): Promise<BoxNowLockersResponse> {
129
+ return client.get("/api/store/integrations/boxnow/lockers")
130
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * @cartbase/storefront/api/menus — backend-owned navigation
3
+ * (content-navigation card).
4
+ *
5
+ * Ground truth: src/app/api/store/menus/[handle]/route.ts +
6
+ * src/lib/content/menus.ts.
7
+ *
8
+ * Shopify Storefront `Menu` shape verbatim. Item `url`s are COMPUTED AT
9
+ * READ TIME from live resource handles (src/lib/content/paths.ts) — a
10
+ * handle rename never breaks a menu. Items whose referenced resource is
11
+ * deleted or not storefront-visible (draft page/post/product, inactive or
12
+ * internal category) are SKIPPED, subtree included — the payload only ever
13
+ * contains renderable links. 3-level nesting max.
14
+ *
15
+ * ## ETag / caching semantics
16
+ * The route serves `ETag` (sha1 of the payload) +
17
+ * `Cache-Control: public, s-maxage=60, stale-while-revalidate=3600`, and
18
+ * answers `If-None-Match` with `304` (empty body). Browser `fetch` handles
19
+ * this transparently; server-side callers on Next.js should instead lean on
20
+ * the RSC cache (`next: { revalidate }`) via `RequestOptions`. Note the SDK
21
+ * itself does NOT replay ETags — a 304 only occurs when the caller's fetch
22
+ * layer sends `If-None-Match`, in which case the platform serves the cached
23
+ * body before the SDK sees it. The `menu.updated` event fires on every menu
24
+ * mutation for future revalidation hooks.
25
+ */
26
+
27
+ import type { StorefrontClient } from "./http"
28
+
29
+ /** One navigation node — recursive, ≤ 3 levels total. */
30
+ export interface MenuItem {
31
+ title: string
32
+ /** What the item points at. */
33
+ type:
34
+ | "frontpage"
35
+ | "collection"
36
+ | "product"
37
+ | "category"
38
+ | "page"
39
+ | "blog_post"
40
+ | "external"
41
+ /**
42
+ * Computed at read time: `/` for frontpage, the shared path convention
43
+ * (`/collections/<handle>`, `/products/<handle>`, `/categories/<handle>`,
44
+ * `/pages/<handle>`, `/blogs/<blog>/<post>`) for resources, and the
45
+ * stored URL verbatim for `external`.
46
+ */
47
+ url: string
48
+ /** Referenced resource id (`pcol_…`, `prod_…`); null for frontpage/external. */
49
+ resourceId: string | null
50
+ items: MenuItem[]
51
+ }
52
+
53
+ export interface Menu {
54
+ handle: string
55
+ title: string
56
+ items: MenuItem[]
57
+ }
58
+
59
+ export interface MenuResponse {
60
+ menu: Menu
61
+ }
62
+
63
+ /**
64
+ * GET /api/store/menus/:handle — one menu, renderable links only.
65
+ *
66
+ * Auth: anon (x-client-id).
67
+ * Errors: 404 not_found (unknown/deleted handle — a store with zero menus
68
+ * 404s every handle; render no nav, never crash).
69
+ * Settings: menus are authored in admin → Content → Navigation; deleting a
70
+ * referenced resource silently drops its item (subtree included) here.
71
+ */
72
+ export async function getMenu(
73
+ client: StorefrontClient,
74
+ handle: string
75
+ ): Promise<MenuResponse> {
76
+ return client.get(`/api/store/menus/${encodeURIComponent(handle)}`)
77
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * @cartbase/storefront/api/metaobjects — merchant-defined content types
3
+ * (metaobjects card): size charts, brand profiles, FAQ blocks…
4
+ *
5
+ * Ground truth: src/app/api/store/metaobjects/** +
6
+ * src/lib/metaobjects/store.ts.
7
+ *
8
+ * ACTIVE entries only — drafts 404 (lib filter AND anon RLS). Capabilities
9
+ * per definition: `renderable` → `seo` present; `online_store` → `url`
10
+ * present (`/metaobjects/<type>/<handle>`; the storefront prepends its
11
+ * origin). References resolve at READ time: a target the storefront cannot
12
+ * see yields `reference: null` — never dropped, never a 500.
13
+ *
14
+ * `by-metafield` is a RESERVED type slug (the static route shadows /:type).
15
+ */
16
+
17
+ import type { StorefrontClient } from "./http"
18
+ import type { IsoDateString, PaginationQuery } from "./types"
19
+
20
+ /** One field of a metaobject entry. */
21
+ export interface MetaobjectField {
22
+ key: string
23
+ /** Field kind from the definition (text, rich_text, reference kinds…). */
24
+ kind: string
25
+ /** Raw stored value (shape depends on `kind`). */
26
+ value: unknown
27
+ /**
28
+ * Reference kinds only (absent otherwise). Resolved target, or null when
29
+ * the target is deleted / not storefront-visible. NOT resolved on LIST
30
+ * payloads — only the by-handle read and by-metafield resolve references.
31
+ */
32
+ reference?: { id: string; title: string; handle: string; type?: string } | null
33
+ }
34
+
35
+ export interface Metaobject {
36
+ id: string
37
+ /** The definition's type slug. */
38
+ type: string
39
+ handle: string
40
+ displayName: string
41
+ fields: MetaobjectField[]
42
+ /** Present only when the definition has the `renderable` capability.
43
+ * title falls back to displayName; description to null. */
44
+ seo?: { title: string; description: string | null }
45
+ /** Present only with the `online_store` capability:
46
+ * `/metaobjects/<type>/<handle>`. */
47
+ url?: string
48
+ updatedAt: IsoDateString
49
+ }
50
+
51
+ export type ListMetaobjectsQuery = PaginationQuery
52
+
53
+ export interface MetaobjectListResponse {
54
+ metaobjects: Metaobject[]
55
+ count: number
56
+ offset: number
57
+ limit: number
58
+ }
59
+
60
+ /**
61
+ * GET /api/store/metaobjects/:type — paginated ACTIVE entries of one type,
62
+ * newest-updated first. `limit` clamped to 1–100 (default 20; out-of-range
63
+ * values are clamped, not rejected). List payloads carry RAW field values —
64
+ * `reference` is NOT resolved here (use getMetaobject for the resolve path).
65
+ *
66
+ * Auth: anon (x-client-id).
67
+ * Errors: 404 not_found (unknown type).
68
+ */
69
+ export async function listMetaobjects(
70
+ client: StorefrontClient,
71
+ type: string,
72
+ query?: ListMetaobjectsQuery
73
+ ): Promise<MetaobjectListResponse> {
74
+ return client.get(`/api/store/metaobjects/${encodeURIComponent(type)}`, {
75
+ query: { ...query },
76
+ })
77
+ }
78
+
79
+ export interface MetaobjectResponse {
80
+ metaobject: Metaobject
81
+ }
82
+
83
+ /**
84
+ * GET /api/store/metaobjects/:type/:handle — one ACTIVE entry, references
85
+ * RESOLVED (invisible targets → `reference: null`).
86
+ *
87
+ * Auth: anon (x-client-id).
88
+ * Errors: 404 not_found (unknown type, unknown handle, or draft entry).
89
+ */
90
+ export async function getMetaobject(
91
+ client: StorefrontClient,
92
+ type: string,
93
+ handle: string
94
+ ): Promise<MetaobjectResponse> {
95
+ return client.get(
96
+ `/api/store/metaobjects/${encodeURIComponent(type)}/${encodeURIComponent(handle)}`
97
+ )
98
+ }
99
+
100
+ export interface ByMetafieldQuery {
101
+ /** Owning entity type, e.g. `product`. */
102
+ entity_type: string
103
+ /** Owning entity id, e.g. `prod_…`. */
104
+ entity_id: string
105
+ /** The metafield key — its definition MUST be `metaobject_reference`. */
106
+ key: string
107
+ }
108
+
109
+ /**
110
+ * Response of the by-metafield chain. `metaobjects` is always present in
111
+ * stored order. `metaobject` (first entry or null) is present ONLY when the
112
+ * metafield definition is single-valued — LIST definitions return
113
+ * `{metaobjects}` alone.
114
+ */
115
+ export interface ByMetafieldResponse {
116
+ metaobject?: Metaobject | null
117
+ metaobjects: Metaobject[]
118
+ }
119
+
120
+ /**
121
+ * GET /api/store/metaobjects/by-metafield — the product→size-chart fetch
122
+ * chain: resolve an entity's `metaobject_reference` METAFIELD into full
123
+ * metaobject payload(s), references resolved. Deliberate leak boundary:
124
+ * only `metaobject_reference` definitions resolve here (any other key →
125
+ * 404), and only ACTIVE entries return.
126
+ *
127
+ * Auth: anon (x-client-id).
128
+ * Errors: 400 invalid_data (missing entity_type/entity_id/key) ·
129
+ * 404 not_found (no such definition, wrong field_type, or no stored value).
130
+ */
131
+ export async function getMetaobjectsByMetafield(
132
+ client: StorefrontClient,
133
+ query: ByMetafieldQuery
134
+ ): Promise<ByMetafieldResponse> {
135
+ return client.get("/api/store/metaobjects/by-metafield", { query: { ...query } })
136
+ }
@@ -0,0 +1,290 @@
1
+ /**
2
+ * @cartbase/storefront/api/orders — the authenticated customer's orders.
3
+ *
4
+ * EVERY function here requires a customer session
5
+ * (`authorization: Bearer <jwt>` via the client's `getAuthToken`) — there
6
+ * is no anonymous order read; a guest's only order handle is the
7
+ * `completeCart()` response. Missing/invalid JWT → 401 `unauthenticated`.
8
+ *
9
+ * Route ground truth: src/app/api/store/orders/**. Doc:
10
+ * docs/storefront/orders.md.
11
+ *
12
+ * `GET /api/store/orders/display/:displayId` was contract-listed but
13
+ * missing from the codebase when this module was first written; the route
14
+ * was built at the batch-9 merge and `retrieveOrderByDisplayId` wraps it.
15
+ */
16
+ import type { StorefrontClient } from "./http"
17
+ import type { IsoDateString, ListEnvelope, MajorUnitAmount } from "./types"
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // DTOs — ground truth: orders/route.ts (list select `*`), orders/[id]/route.ts
21
+ // (STORE_ORDER_DETAIL_SELECT), orders/[id]/transfer/*.
22
+ // ---------------------------------------------------------------------------
23
+
24
+ /** Order row as listed (plain `orders` columns, no embeds). */
25
+ export interface StoreOrderSummaryRow {
26
+ id: string
27
+ /** Human-facing autoincrement. */
28
+ display_id: number
29
+ status: string
30
+ email: string | null
31
+ currency_code: string
32
+ customer_id: string | null
33
+ sales_channel_id: string | null
34
+ region_id: string | null
35
+ metadata: Record<string, unknown> | null
36
+ created_at: IsoDateString
37
+ updated_at: IsoDateString
38
+ [key: string]: unknown
39
+ }
40
+
41
+ /** `order_line_items` row (title/prices/product snapshot at purchase time). */
42
+ export interface StoreOrderLineItem {
43
+ id: string
44
+ title: string | null
45
+ subtitle: string | null
46
+ product_title: string | null
47
+ product_handle: string | null
48
+ thumbnail: string | null
49
+ variant_id: string | null
50
+ variant_title: string | null
51
+ variant_sku: string | null
52
+ unit_price: MajorUnitAmount
53
+ is_giftcard?: boolean
54
+ requires_shipping?: boolean
55
+ metadata: Record<string, unknown> | null
56
+ [key: string]: unknown
57
+ }
58
+
59
+ /** Version pivot row: quantity + the embedded line item. */
60
+ export interface StoreOrderItem {
61
+ id: string
62
+ order_id: string
63
+ quantity: number
64
+ line_item: StoreOrderLineItem | null
65
+ [key: string]: unknown
66
+ }
67
+
68
+ export interface StoreFulfillmentLabel {
69
+ tracking_number: string | null
70
+ tracking_url: string | null
71
+ }
72
+
73
+ /** Store-safe fulfillment subset: lifecycle timestamps + tracking labels. */
74
+ export interface StoreOrderFulfillment {
75
+ fulfillment: {
76
+ id: string
77
+ packed_at: IsoDateString | null
78
+ shipped_at: IsoDateString | null
79
+ delivered_at: IsoDateString | null
80
+ canceled_at: IsoDateString | null
81
+ labels: StoreFulfillmentLabel[]
82
+ } | null
83
+ }
84
+
85
+ /** `order_addresses` row. */
86
+ export interface StoreOrderAddress {
87
+ id: string
88
+ first_name: string | null
89
+ last_name: string | null
90
+ company: string | null
91
+ address_1: string | null
92
+ address_2: string | null
93
+ city: string | null
94
+ country_code: string | null
95
+ province: string | null
96
+ postal_code: string | null
97
+ phone: string | null
98
+ [key: string]: unknown
99
+ }
100
+
101
+ /**
102
+ * Order detail — a store-safe subset of the admin select: items (with
103
+ * embedded line_item), fulfillments with tracking, both addresses. No
104
+ * internal joins (customer row, payment internals) and NO internal staff
105
+ * notes (timeline comments never cross this surface).
106
+ */
107
+ export interface StoreOrderDetail extends StoreOrderSummaryRow {
108
+ items: StoreOrderItem[]
109
+ fulfillments: StoreOrderFulfillment[]
110
+ shipping_address: StoreOrderAddress | null
111
+ billing_address: StoreOrderAddress | null
112
+ }
113
+
114
+ export interface ListOrdersQuery {
115
+ /** 1–200, default 20. */
116
+ limit?: number
117
+ /** Default 0. */
118
+ offset?: number
119
+ /** Exact-match filter on `orders.status` (e.g. `pending`, `completed`, `canceled`). */
120
+ status?: string
121
+ }
122
+
123
+ export interface OrderListResponse extends ListEnvelope {
124
+ orders: StoreOrderSummaryRow[]
125
+ }
126
+
127
+ export interface OrderResponse {
128
+ order: StoreOrderDetail
129
+ }
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // Reads
133
+ // ---------------------------------------------------------------------------
134
+
135
+ /**
136
+ * List MY orders (newest first). → 200 `{orders, count, offset, limit}` —
137
+ * plain order rows, no embeds (fetch the detail for items/tracking).
138
+ * Auth: REQUIRED Bearer JWT (+ `x-client-id`). Scoped to the session
139
+ * customer — another customer's orders are invisible, not 403.
140
+ * Errors: 401 `unauthenticated`, 400 `validation_failed` (bad
141
+ * limit/offset).
142
+ */
143
+ export async function listOrders(
144
+ client: StorefrontClient,
145
+ query: ListOrdersQuery = {}
146
+ ): Promise<OrderListResponse> {
147
+ return client.get("/api/store/orders", { query: { ...query } })
148
+ }
149
+
150
+ /**
151
+ * Retrieve MY order with items, fulfillments (+ tracking labels) and
152
+ * addresses. → 200 `{order}`.
153
+ * Auth: REQUIRED Bearer JWT (+ `x-client-id`).
154
+ * Errors: 401 `unauthenticated`, 404 `not_found` (unknown id OR an order
155
+ * belonging to another customer — ownership is part of the lookup, so
156
+ * cross-customer reads are indistinguishable from missing).
157
+ */
158
+ export async function retrieveOrder(
159
+ client: StorefrontClient,
160
+ orderId: string
161
+ ): Promise<OrderResponse> {
162
+ return client.get(`/api/store/orders/${orderId}`)
163
+ }
164
+
165
+ /**
166
+ * Retrieve MY order by its human number: the autoincrement `display_id`
167
+ * (numeric segment) or a `custom_display_id` (any segment; a numeric
168
+ * custom id wins over a colliding display_id — it is the number the store
169
+ * showed the customer). Same detail embeds + ownership scope as
170
+ * `retrieveOrder`.
171
+ * → 200 `{order}`.
172
+ * Auth: REQUIRED Bearer JWT (+ `x-client-id`).
173
+ * Errors: 401 `unauthenticated`, 404 `not_found` (unknown number OR another
174
+ * customer's order — indistinguishable by design).
175
+ */
176
+ export async function retrieveOrderByDisplayId(
177
+ client: StorefrontClient,
178
+ displayId: string | number
179
+ ): Promise<OrderResponse> {
180
+ return client.get(`/api/store/orders/display/${displayId}`)
181
+ }
182
+
183
+ // ---------------------------------------------------------------------------
184
+ // Transfers — move a (guest) order to the authenticated customer.
185
+ // Flow: the CLAIMING customer calls request → a token is issued for the
186
+ // order's email holder → the claimer (whose email must match the order's)
187
+ // calls accept with the token; decline/cancel end a pending transfer.
188
+ // ---------------------------------------------------------------------------
189
+
190
+ export interface RequestOrderTransferInput {
191
+ /** Free-text shown to the recipient; stored on the transfer action. */
192
+ description?: string
193
+ }
194
+
195
+ /**
196
+ * request → `{order:{id, transfer:{requested:true}}}` — ALWAYS this shape,
197
+ * for the first request and for duplicates alike. The transfer token is
198
+ * email-delivered only and never crosses the store surface.
199
+ */
200
+ export interface RequestOrderTransferResponse {
201
+ order: {
202
+ id: string
203
+ transfer: { requested: true }
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Request the order be transferred to ME (the authenticated customer).
209
+ * Idempotent: an existing pending transfer is returned, not duplicated.
210
+ * → 200.
211
+ * Auth: REQUIRED Bearer JWT (+ `x-client-id`).
212
+ * Errors: 401 `unauthenticated`, 404 `not_found`, 400 `already_owned`
213
+ * (the order already belongs to this customer).
214
+ */
215
+ export async function requestOrderTransfer(
216
+ client: StorefrontClient,
217
+ orderId: string,
218
+ input: RequestOrderTransferInput = {}
219
+ ): Promise<RequestOrderTransferResponse> {
220
+ return client.post(`/api/store/orders/${orderId}/transfer/request`, input)
221
+ }
222
+
223
+ export interface CancelOrderTransferResponse {
224
+ order: { id: string; transfer: { canceled: true } }
225
+ }
226
+
227
+ /**
228
+ * Cancel MY pending transfer request (only the requester may cancel).
229
+ * → 200.
230
+ * Auth: REQUIRED Bearer JWT (+ `x-client-id`).
231
+ * Errors: 401 `unauthenticated`, 404 `not_found` (order or no pending
232
+ * transfer), 403 `forbidden` (not the requester).
233
+ */
234
+ export async function cancelOrderTransfer(
235
+ client: StorefrontClient,
236
+ orderId: string
237
+ ): Promise<CancelOrderTransferResponse> {
238
+ return client.post(`/api/store/orders/${orderId}/transfer/cancel`)
239
+ }
240
+
241
+ export interface AcceptOrderTransferInput {
242
+ /** The transfer token received by email (≥16 chars). */
243
+ token: string
244
+ }
245
+
246
+ /** accept → the full updated order row (now owned by the caller). */
247
+ export interface AcceptOrderTransferResponse {
248
+ order: StoreOrderSummaryRow
249
+ }
250
+
251
+ /**
252
+ * Accept a pending transfer with the emailed token — assigns the order to
253
+ * the authenticated customer. The caller's email must equal the order's
254
+ * original email (a leaked token alone is not enough). → 200 `{order}`.
255
+ * Auth: REQUIRED Bearer JWT (+ `x-client-id`).
256
+ * Errors: 401 `unauthenticated`, 404 `not_found` (order / no pending
257
+ * transfer), 403 `invalid_token` | `email_mismatch`, 400
258
+ * `validation_failed`.
259
+ */
260
+ export async function acceptOrderTransfer(
261
+ client: StorefrontClient,
262
+ orderId: string,
263
+ input: AcceptOrderTransferInput
264
+ ): Promise<AcceptOrderTransferResponse> {
265
+ return client.post(`/api/store/orders/${orderId}/transfer/accept`, input)
266
+ }
267
+
268
+ export interface DeclineOrderTransferInput {
269
+ /** The transfer token received by email (≥16 chars). */
270
+ token: string
271
+ }
272
+
273
+ export interface DeclineOrderTransferResponse {
274
+ order: { id: string; transfer: { declined: true } }
275
+ }
276
+
277
+ /**
278
+ * Decline a pending transfer (the order's original email holder rejects
279
+ * it) — validates the token, then removes the pending action. → 200.
280
+ * Auth: REQUIRED Bearer JWT (+ `x-client-id`).
281
+ * Errors: 401 `unauthenticated`, 404 `not_found`, 403 `invalid_token`,
282
+ * 400 `validation_failed`.
283
+ */
284
+ export async function declineOrderTransfer(
285
+ client: StorefrontClient,
286
+ orderId: string,
287
+ input: DeclineOrderTransferInput
288
+ ): Promise<DeclineOrderTransferResponse> {
289
+ return client.post(`/api/store/orders/${orderId}/transfer/decline`, input)
290
+ }