@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.
- package/package.json +226 -0
- package/src/api/auth.ts +108 -0
- package/src/api/carts.ts +506 -0
- package/src/api/categories.ts +184 -0
- package/src/api/checkout.ts +440 -0
- package/src/api/collections.ts +130 -0
- package/src/api/consent.ts +75 -0
- package/src/api/content.ts +125 -0
- package/src/api/customers.ts +307 -0
- package/src/api/gift-cards.ts +112 -0
- package/src/api/http.ts +122 -0
- package/src/api/index.ts +29 -0
- package/src/api/integrations.ts +130 -0
- package/src/api/menus.ts +77 -0
- package/src/api/metaobjects.ts +136 -0
- package/src/api/orders.ts +290 -0
- package/src/api/products.ts +303 -0
- package/src/api/redirects.ts +37 -0
- package/src/api/regions.ts +141 -0
- package/src/api/reviews.ts +259 -0
- package/src/api/search.ts +133 -0
- package/src/api/types.ts +91 -0
- package/src/cart-drawer/cart-drawer.tsx +86 -0
- package/src/cart-drawer/context.tsx +569 -0
- package/src/cart-drawer/continue-shopping.tsx +28 -0
- package/src/cart-drawer/cross-sell-carousel.tsx +212 -0
- package/src/cart-drawer/cross-sell-sidebar.tsx +158 -0
- package/src/cart-drawer/empty.tsx +52 -0
- package/src/cart-drawer/free-gift.tsx +71 -0
- package/src/cart-drawer/gift-wrap.tsx +83 -0
- package/src/cart-drawer/header.tsx +52 -0
- package/src/cart-drawer/index.ts +69 -0
- package/src/cart-drawer/item/index.tsx +164 -0
- package/src/cart-drawer/item/quantity.tsx +100 -0
- package/src/cart-drawer/item/upsell.tsx +110 -0
- package/src/cart-drawer/item/variant.tsx +46 -0
- package/src/cart-drawer/labels-bg.ts +72 -0
- package/src/cart-drawer/labels.ts +119 -0
- package/src/cart-drawer/notes.tsx +131 -0
- package/src/cart-drawer/payment-badges.tsx +96 -0
- package/src/cart-drawer/promo-banner.tsx +43 -0
- package/src/cart-drawer/rewards-points.tsx +78 -0
- package/src/cart-drawer/sticky-footer.tsx +73 -0
- package/src/cart-drawer/summary-breakdown.tsx +196 -0
- package/src/cart-drawer/template.tsx +225 -0
- package/src/cart-drawer/tiered-progress.tsx +168 -0
- package/src/checkout/address-error-copy.ts +119 -0
- package/src/checkout/address-form.tsx +224 -0
- package/src/checkout/address-select.tsx +79 -0
- package/src/checkout/boxnow-locker-selector.tsx +410 -0
- package/src/checkout/checkout-client.tsx +222 -0
- package/src/checkout/company-details.tsx +94 -0
- package/src/checkout/compare-addresses.ts +40 -0
- package/src/checkout/context.tsx +76 -0
- package/src/checkout/discount-section.tsx +218 -0
- package/src/checkout/econt-office-selector.tsx +332 -0
- package/src/checkout/error-message.tsx +25 -0
- package/src/checkout/geocode.ts +154 -0
- package/src/checkout/gift-card-section.tsx +224 -0
- package/src/checkout/index.ts +74 -0
- package/src/checkout/labels-bg.ts +128 -0
- package/src/checkout/labels.ts +263 -0
- package/src/checkout/line-item-card.tsx +152 -0
- package/src/checkout/order-summary.tsx +524 -0
- package/src/checkout/payment-button.tsx +373 -0
- package/src/checkout/payment-error-copy.ts +269 -0
- package/src/checkout/payment-method-list.tsx +365 -0
- package/src/checkout/payment-wrapper.tsx +102 -0
- package/src/checkout/promotion-error-copy.ts +124 -0
- package/src/checkout/shipping-method-list.tsx +335 -0
- package/src/checkout/stripe-wrapper.tsx +165 -0
- package/src/checkout/use-checkout-orchestration.ts +1504 -0
- package/src/common/cart-button-client.tsx +39 -0
- package/src/common/cart-button.tsx +28 -0
- package/src/common/country-select.tsx +65 -0
- package/src/common/delete-button.tsx +66 -0
- package/src/common/index.ts +17 -0
- package/src/common/language-select.tsx +78 -0
- package/src/common/localized-link.tsx +45 -0
- package/src/common/skeleton.tsx +29 -0
- package/src/index.ts +12 -0
- package/src/lib/cart-helpers.ts +113 -0
- package/src/lib/dual-price.tsx +73 -0
- package/src/lib/get-percentage-diff.ts +5 -0
- package/src/lib/get-product-price.ts +133 -0
- package/src/lib/hooks/use-intersection.ts +30 -0
- package/src/lib/hooks/use-toggle-state.ts +25 -0
- package/src/lib/money.ts +73 -0
- package/src/lib/payment-constants.ts +66 -0
- package/src/lib/product.ts +22 -0
- package/src/lib/sort-products.ts +63 -0
- package/src/lib/store-api-error.ts +36 -0
- package/src/lib/utils.ts +16 -0
- package/src/order/context.tsx +32 -0
- package/src/order/index.ts +63 -0
- package/src/order/labels-bg.ts +39 -0
- package/src/order/labels.ts +79 -0
- package/src/order/order-address-card.tsx +47 -0
- package/src/order/order-completed-template.tsx +165 -0
- package/src/order/order-confirmation-header.tsx +65 -0
- package/src/order/order-delivery-card.tsx +258 -0
- package/src/order/order-help-section.tsx +47 -0
- package/src/order/order-item.tsx +201 -0
- package/src/order/order-items-list.tsx +52 -0
- package/src/order/order-payment-card.tsx +95 -0
- package/src/order/order-timeline.tsx +141 -0
- package/src/order/order-totals.tsx +245 -0
- package/src/primitives/field.tsx +125 -0
- package/src/primitives/select-field.tsx +77 -0
- package/src/primitives/ui/accordion.tsx +61 -0
- package/src/primitives/ui/button.tsx +68 -0
- package/src/primitives/ui/collapsible.tsx +16 -0
- package/src/primitives/ui/dialog.tsx +112 -0
- package/src/primitives/ui/input.tsx +30 -0
- package/src/primitives/ui/label.tsx +31 -0
- package/src/primitives/ui/popover.tsx +38 -0
- package/src/primitives/ui/select.tsx +163 -0
- package/src/primitives/ui/sheet.tsx +131 -0
- package/src/primitives/ui/tabs.tsx +62 -0
- package/src/products/context.tsx +34 -0
- package/src/products/image-gallery.tsx +43 -0
- package/src/products/index.ts +44 -0
- package/src/products/labels-bg.ts +35 -0
- package/src/products/labels.ts +57 -0
- package/src/products/mobile-actions.tsx +180 -0
- package/src/products/option-select.tsx +67 -0
- package/src/products/preview-price.tsx +36 -0
- package/src/products/product-actions-wrapper.tsx +58 -0
- package/src/products/product-actions.tsx +217 -0
- package/src/products/product-info.tsx +43 -0
- package/src/products/product-preview.tsx +49 -0
- package/src/products/product-price.tsx +69 -0
- package/src/products/product-tabs.tsx +169 -0
- package/src/products/product-template.tsx +114 -0
- package/src/products/purchase-options.tsx +130 -0
- package/src/products/related-products.tsx +86 -0
- package/src/products/thumbnail.tsx +71 -0
- package/src/products/variant-matching.ts +71 -0
- package/src/reviews-ui/helpers.ts +174 -0
- package/src/reviews-ui/index.ts +74 -0
- package/src/reviews-ui/labels-bg.ts +91 -0
- package/src/reviews-ui/labels.ts +199 -0
- package/src/reviews-ui/photo-upload.tsx +345 -0
- package/src/reviews-ui/review-list.tsx +249 -0
- package/src/reviews-ui/review-widget.tsx +224 -0
- package/src/reviews-ui/review-wizard.tsx +560 -0
- package/src/reviews-ui/star-badge.tsx +104 -0
- package/src/reviews-ui/wizard-state.ts +81 -0
- package/src/store/category-template.tsx +129 -0
- package/src/store/collection-template.tsx +139 -0
- package/src/store/index.ts +41 -0
- package/src/store/labels-bg.ts +22 -0
- package/src/store/labels.ts +52 -0
- package/src/store/paginated-products.tsx +116 -0
- package/src/store/pagination.tsx +103 -0
- package/src/store/search-params.ts +256 -0
- package/src/store/search-template.tsx +249 -0
- package/src/store/skeleton-product-grid.tsx +26 -0
- package/src/store/sort-select.tsx +81 -0
- package/src/store/store-template.tsx +65 -0
- package/src/tracking/attribution.ts +418 -0
- package/src/tracking/consent-banner.tsx +355 -0
- package/src/tracking/consent-init.tsx +44 -0
- package/src/tracking/consent.ts +243 -0
- package/src/tracking/fbq.ts +168 -0
- package/src/tracking/ga4.tsx +49 -0
- package/src/tracking/get-tracking-attribution.ts +224 -0
- package/src/tracking/get-tracking-config.ts +50 -0
- package/src/tracking/gtag.ts +200 -0
- package/src/tracking/index.ts +133 -0
- package/src/tracking/meta-pixel.tsx +166 -0
- package/src/tracking/rybbit-events.ts +242 -0
- package/src/tracking/rybbit.tsx +40 -0
- package/src/tracking/types.ts +185 -0
- package/src/tracking/use-engagement-time.ts +58 -0
- 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
|
+
}
|
package/src/api/menus.ts
ADDED
|
@@ -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
|
+
}
|