@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,259 @@
1
+ /**
2
+ * @cartbase/storefront/api/reviews — verified-purchase reviews (reviews card;
3
+ * shapes mirror alenika/src/lib/reviews-api.ts verbatim).
4
+ *
5
+ * Ground truth: src/app/api/store/reviews/** + src/lib/reviews/*.
6
+ *
7
+ * Model: reviews exist ONLY via a single-use, order-scoped, expiring TOKEN
8
+ * minted by the request scanner and mailed as `<review_link_base>/<token>`.
9
+ * The token IS the auth for every write — no login. All public reads serve
10
+ * `status='visible'` rows only; hidden/pending/deleted never leak.
11
+ *
12
+ * Two-step wizard: submit (consumes the token) → attach photo (reward).
13
+ * Resume rule: token consumed + review.reward_code null → resume at the
14
+ * photo step; reward_code set → fully done, show the code.
15
+ */
16
+
17
+ import type { StorefrontClient } from "./http"
18
+ import type { IsoDateString } from "./types"
19
+
20
+ /** One media entry on a review. */
21
+ export interface ReviewMedia {
22
+ type: "image" | "video"
23
+ /** Must live on the store's public file host (R2) — see createUploadUrl. */
24
+ url: string
25
+ thumb: string
26
+ w: number
27
+ h: number
28
+ bytes: number
29
+ }
30
+
31
+ /** Public review — EXACTLY these keys (leak guard: no email/order/IP/status). */
32
+ export interface PublicReview {
33
+ id: string
34
+ customer_name: string
35
+ rating: number
36
+ /** Always null today — the form has no title field. */
37
+ title: string | null
38
+ body: string | null
39
+ /** Moderation-hidden entries are filtered out server-side. */
40
+ media: ReviewMedia[]
41
+ admin_response: string | null
42
+ admin_response_at: IsoDateString | null
43
+ created_at: IsoDateString
44
+ }
45
+
46
+ export interface ListReviewsQuery {
47
+ product_id: string
48
+ /** default = with-media first then newest; rating uses `order`; date = newest. */
49
+ sort?: "default" | "rating" | "date"
50
+ order?: "asc" | "desc"
51
+ /** ≤ 50, default 10. */
52
+ limit?: number
53
+ offset?: number
54
+ }
55
+
56
+ export interface ReviewListResponse {
57
+ reviews: PublicReview[]
58
+ count: number
59
+ has_more: boolean
60
+ }
61
+
62
+ /**
63
+ * GET /api/store/reviews — visible reviews for a product.
64
+ *
65
+ * Auth: anon (x-client-id).
66
+ * Errors: 400 validation_failed (missing product_id, bad sort/limit).
67
+ */
68
+ export async function listReviews(
69
+ client: StorefrontClient,
70
+ query: ListReviewsQuery
71
+ ): Promise<ReviewListResponse> {
72
+ return client.get("/api/store/reviews", { query: { ...query } })
73
+ }
74
+
75
+ /** Star-rating stats. distribution keys are "1".."5". */
76
+ export interface ReviewAggregate {
77
+ product_id: string
78
+ count: number
79
+ /** Rounded to 1 decimal; 0 when no reviews. */
80
+ avg_rating: number
81
+ distribution: Record<"1" | "2" | "3" | "4" | "5", number>
82
+ }
83
+
84
+ /**
85
+ * GET /api/store/reviews/aggregate — the PDP star-badge stats. Visible
86
+ * reviews only. Edge-cached 60s (`Cache-Control: public, max-age=60`).
87
+ *
88
+ * Auth: anon (x-client-id).
89
+ * Errors: 400 validation_failed (missing product_id).
90
+ */
91
+ export async function getAggregate(
92
+ client: StorefrontClient,
93
+ productId: string
94
+ ): Promise<ReviewAggregate> {
95
+ return client.get("/api/store/reviews/aggregate", { query: { product_id: productId } })
96
+ }
97
+
98
+ /** Widget payload — aggregate + first page + display options, one call. */
99
+ export interface ReviewWidgetPayload {
100
+ product_id: string
101
+ aggregate: ReviewAggregate
102
+ reviews: PublicReview[]
103
+ count: number
104
+ has_more: boolean
105
+ options: {
106
+ layout: "masonry" | "list"
107
+ page_size: number
108
+ photo_first: boolean
109
+ }
110
+ }
111
+
112
+ /**
113
+ * GET /api/store/reviews/widget — everything the product review widget +
114
+ * star badge need in one call (first page sized/sorted per the store's
115
+ * Settings → Reviews display options). Edge-cached 60s.
116
+ *
117
+ * Auth: anon (x-client-id).
118
+ * Errors: 400 validation_failed (missing product_id).
119
+ * Settings: widget_layout / widget_page_size / widget_photo_first.
120
+ */
121
+ export async function getWidget(
122
+ client: StorefrontClient,
123
+ productId: string
124
+ ): Promise<ReviewWidgetPayload> {
125
+ return client.get("/api/store/reviews/widget", { query: { product_id: productId } })
126
+ }
127
+
128
+ /** Token validation — the review-form bootstrap state. */
129
+ export type TokenValidation =
130
+ | { valid: false; reason: "not_found" | "expired" | "invalid" }
131
+ | {
132
+ valid: true
133
+ already_submitted: boolean
134
+ /** Present when already_submitted and the review row exists. */
135
+ review: { id: string; reward_code: string | null } | null
136
+ product: {
137
+ id: string
138
+ handle: string
139
+ title: string
140
+ thumbnail: string | null
141
+ } | null
142
+ customer_name: string
143
+ expires_at: IsoDateString
144
+ }
145
+
146
+ /**
147
+ * GET /api/store/reviews/token/:token — validate a review token and load
148
+ * the form context (product card, greeting, resume state). ALWAYS 200 —
149
+ * invalid tokens come back as `{valid: false, reason}`. Never cached.
150
+ *
151
+ * Auth: anon (x-client-id); the token is the bearer secret.
152
+ */
153
+ export async function validateToken(
154
+ client: StorefrontClient,
155
+ token: string
156
+ ): Promise<TokenValidation> {
157
+ return client.get(`/api/store/reviews/token/${encodeURIComponent(token)}`)
158
+ }
159
+
160
+ export interface SubmitReviewInput {
161
+ token: string
162
+ /** Integer 1–5. */
163
+ rating: number
164
+ /** REQUIRED — a rating alone is not a review. HTML stripped; ≤ 2000 chars. */
165
+ body: string
166
+ /** ≤ 7 items: ≤ 6 images + ≤ 1 video; URLs must be on the file host. */
167
+ media?: ReviewMedia[]
168
+ }
169
+
170
+ export interface SubmitReviewResponse {
171
+ id: string
172
+ success: true
173
+ }
174
+
175
+ /**
176
+ * POST /api/store/reviews — submit the review (wizard step 1). Consumes the
177
+ * token; idempotent on the (order, product) unique — a race returns the
178
+ * existing review. No title field.
179
+ *
180
+ * Auth: anon (x-client-id); the token is the auth.
181
+ * Errors: 400 invalid_data (rating/body/media rules; body empty after
182
+ * sanitize) · 404 not_found (unknown token) · 409 conflict (token already
183
+ * consumed — replay) · 410 gone (expired) · 429 rate_limited (3/h per IP).
184
+ * Settings: moderation_mode `hold` lands the review as pending (not
185
+ * publicly visible until approved).
186
+ */
187
+ export async function submitReview(
188
+ client: StorefrontClient,
189
+ input: SubmitReviewInput
190
+ ): Promise<SubmitReviewResponse> {
191
+ return client.post("/api/store/reviews", input)
192
+ }
193
+
194
+ export interface AttachReviewPhotoInput {
195
+ /** The SAME review token — consumed is OK (two-step wizard). */
196
+ token: string
197
+ /** 1–7 items, ≤ 6 images + ≤ 1 video, file-host URLs only. */
198
+ media: ReviewMedia[]
199
+ }
200
+
201
+ /** `{code}` fresh mint · `{code, already_issued}` retry · `{code: null, message}` mint failed (media still saved). */
202
+ export type AttachReviewPhotoResponse =
203
+ | { code: string }
204
+ | { code: string; already_issued: true }
205
+ | { code: null; message: string }
206
+
207
+ /**
208
+ * POST /api/store/reviews/:id/photo — wizard step 2: attach media and mint
209
+ * the single-use reward code (a real promotion, percentage-off-order; 10%
210
+ * default). The reward is for the PHOTO, never the rating. Idempotent —
211
+ * retries return the same code; a promo failure never loses the media.
212
+ *
213
+ * Auth: anon (x-client-id); token must match the review's (order, product).
214
+ * Errors: 400 invalid_data (missing/invalid media) · 403 forbidden (token
215
+ * does not match this review) · 404 not_found (review or token unknown).
216
+ * Settings: reward_enabled / reward_percentage (Settings → Reviews).
217
+ */
218
+ export async function attachReviewPhoto(
219
+ client: StorefrontClient,
220
+ reviewId: string,
221
+ input: AttachReviewPhotoInput
222
+ ): Promise<AttachReviewPhotoResponse> {
223
+ return client.post(`/api/store/reviews/${encodeURIComponent(reviewId)}/photo`, input)
224
+ }
225
+
226
+ export interface CreateUploadUrlInput {
227
+ token: string
228
+ /** Original filename (informational). */
229
+ name: string
230
+ /** MIME: image/jpeg|jpg|png|webp or video/mp4|quicktime. */
231
+ type: string
232
+ /** Bytes — pre-checked against the per-type cap when provided. */
233
+ size?: number
234
+ }
235
+
236
+ export interface CreateUploadUrlResponse {
237
+ /** Signed R2 PUT URL — upload the raw file body here. */
238
+ uploadUrl: string
239
+ /** The public URL to reference in ReviewMedia.url after upload. */
240
+ publicUrl: string
241
+ key: string
242
+ filename: string
243
+ }
244
+
245
+ /**
246
+ * POST /api/store/reviews/upload-url — signed R2 upload for the photo step.
247
+ * A consumed token is accepted (step 1 consumed it); expiry still applies.
248
+ * Caps: image ≤ 8MB, video ≤ 50MB.
249
+ *
250
+ * Auth: anon (x-client-id); the token is the auth.
251
+ * Errors: 400 invalid_data (unsupported MIME, file too large) ·
252
+ * 404 not_found (unknown token) · 410 gone (expired token).
253
+ */
254
+ export async function createUploadUrl(
255
+ client: StorefrontClient,
256
+ input: CreateUploadUrlInput
257
+ ): Promise<CreateUploadUrlResponse> {
258
+ return client.post("/api/store/reviews/upload-url", input)
259
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * @cartbase/storefront/api/search — storefront search + related products
3
+ * (search-discovery card).
4
+ *
5
+ * Results are the SAME `StoreProduct` objects the products listing serves —
6
+ * reuse your product-card renderer as-is. Matching: full-text
7
+ * (Cyrillic-correct, prefix) ∪ title trigram typo tolerance ∪ SKU prefix ∪
8
+ * exact tag value; admin synonyms expand bidirectionally one level; admin
9
+ * pins for the exact query rank first, then global boosts, then relevance.
10
+ * Docs: docs/storefront/search.md.
11
+ */
12
+ import type { Query, StorefrontClient } from "./http"
13
+ import type { PricingContextQuery } from "./types"
14
+ import type { StoreProduct } from "./products"
15
+
16
+ export interface SearchFacetBucket {
17
+ /** Filter value to send back (id, option value, or "min-max" for price). */
18
+ value: string
19
+ label: string
20
+ count: number
21
+ /** Range (price) buckets only — pass back as `price_min`/`price_max`. */
22
+ min?: number
23
+ max?: number | null
24
+ }
25
+
26
+ export interface SearchFacet {
27
+ /** "price" | "availability" | "type" | "tags" | "collection" | "options.<Title>" */
28
+ key: string
29
+ label: string
30
+ type: "value" | "range"
31
+ buckets: SearchFacetBucket[]
32
+ }
33
+
34
+ export interface SearchProductsQuery extends PricingContextQuery {
35
+ /** Required, min 1 char. */
36
+ q: string
37
+ collection_id?: string[]
38
+ category_id?: string[]
39
+ tag_id?: string[]
40
+ type_id?: string[]
41
+ /** Major units in the pricing currency, applied to the cheapest base price. */
42
+ price_min?: number
43
+ price_max?: number
44
+ availability?: "in_stock" | "out_of_stock"
45
+ /**
46
+ * Option filters: title → accepted values (OR within an option, AND
47
+ * across options). Serialized as `option.<Title>=v1,v2` params.
48
+ */
49
+ options?: Record<string, string[]>
50
+ /** 1–100, default 20. */
51
+ limit?: number
52
+ offset?: number
53
+ }
54
+
55
+ export interface SearchProductsResponse {
56
+ results: StoreProduct[]
57
+ /**
58
+ * Per the tenant's Settings → Search & discovery → Filters config.
59
+ * Counts follow the standard faceting rule: each facet is computed on the
60
+ * result set filtered by every OTHER active filter (own dimension
61
+ * excluded). Empty buckets and empty facets are omitted.
62
+ */
63
+ facets: SearchFacet[]
64
+ total: number
65
+ offset: number
66
+ limit: number
67
+ }
68
+
69
+ export interface RelatedProductsQuery extends PricingContextQuery {
70
+ /** 1–24, default 12. */
71
+ limit?: number
72
+ }
73
+
74
+ export interface RelatedProductsResponse {
75
+ products: StoreProduct[]
76
+ count: number
77
+ /** True when any item came from the deterministic fallback chain. */
78
+ auto_filled: boolean
79
+ }
80
+
81
+ /** Fold the `options` record into `option.<Title>` wire params. */
82
+ function toSearchParams(query: SearchProductsQuery): Query {
83
+ const { options, ...rest } = query
84
+ const out: Query = { ...rest }
85
+ if (options) {
86
+ for (const [title, values] of Object.entries(options)) {
87
+ if (values.length) out[`option.${title}`] = values
88
+ }
89
+ }
90
+ return out
91
+ }
92
+
93
+ /**
94
+ * Search published products. Draft products, other tenants, and products
95
+ * outside the publishable key's channels NEVER appear (pins included).
96
+ *
97
+ * Auth: anon (`x-client-id`); optional `x-publishable-api-key` scopes the
98
+ * catalog to the key's channels (empty results when nothing matches);
99
+ * optional Bearer JWT makes `calculated_price` group-aware — never cache
100
+ * price-decorated responses shared.
101
+ *
102
+ * Errors: 400 `missing_client_id`, 400 `validation_failed` (missing/empty
103
+ * `q`, bad enum), 400 `invalid_publishable_key`, 400 `invalid_region`.
104
+ * Settings: Search & discovery — synonyms, pins/boosts, facet config
105
+ * (order/enabled, price bucket strategy).
106
+ */
107
+ export async function searchProducts(
108
+ client: StorefrontClient,
109
+ query: SearchProductsQuery
110
+ ): Promise<SearchProductsResponse> {
111
+ return client.get("/api/store/products/search", { query: toSearchParams(query) })
112
+ }
113
+
114
+ /**
115
+ * Related products for a PDP: manual admin picks first (position order),
116
+ * then a deterministic fallback fills to `limit` — same primary collection
117
+ * (newest first), then most-shared-tags. Accepts a product id or handle.
118
+ *
119
+ * Auth: anon (`x-client-id`); optional publishable key — the anchor product
120
+ * must be visible to the key (404 otherwise) and scoped-away products never
121
+ * appear as related items. Errors: 404 `not_found` (unknown/draft/
122
+ * channel-hidden anchor), 400 `validation_failed`, 400 `invalid_region`.
123
+ * Settings: manual related picks (Admin → Product → Related).
124
+ */
125
+ export async function listRelatedProducts(
126
+ client: StorefrontClient,
127
+ idOrHandle: string,
128
+ query?: RelatedProductsQuery
129
+ ): Promise<RelatedProductsResponse> {
130
+ return client.get(`/api/store/products/${encodeURIComponent(idOrHandle)}/related`, {
131
+ query: { ...query },
132
+ })
133
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * @cartbase/storefront — shared API surface types.
3
+ *
4
+ * The envelope + error contract every domain module builds on. Shapes are
5
+ * the documented store-API contract (docs/contracts/store-api.md and the
6
+ * per-domain teaching docs under docs/storefront/): single entities come
7
+ * back as `{ <resource>: {...} }`, lists as `{ <resources>: [...], count,
8
+ * offset, limit }`, and errors as `{ error, code, details? }`.
9
+ *
10
+ * Domain modules own their DTOs; only cross-domain primitives live here.
11
+ */
12
+
13
+ /** List envelope every collection endpoint returns. */
14
+ export interface ListEnvelope {
15
+ count: number
16
+ offset: number
17
+ limit: number
18
+ }
19
+
20
+ /** The wire error body (`{ error, code, details? }`). */
21
+ export interface StoreApiErrorBody {
22
+ error?: string
23
+ message?: string
24
+ code?: string
25
+ details?: unknown
26
+ }
27
+
28
+ /**
29
+ * Thrown for every non-2xx response. `status` + `code` are the stable
30
+ * programmatic surface (codes are contract-listed per endpoint, e.g.
31
+ * `invalid_gift_card`, `checkout_method_hidden`, `rate_limited`);
32
+ * `body` keeps the raw payload for anything else.
33
+ */
34
+ export class StoreApiError extends Error {
35
+ readonly status: number
36
+ readonly code: string | null
37
+ readonly body: unknown
38
+
39
+ constructor(status: number, body: unknown) {
40
+ const parsed = (body ?? {}) as StoreApiErrorBody
41
+ super(parsed.error ?? parsed.message ?? `Store API error (${status})`)
42
+ this.name = "StoreApiError"
43
+ this.status = status
44
+ this.code = typeof parsed.code === "string" ? parsed.code : null
45
+ this.body = body
46
+ }
47
+ }
48
+
49
+ /** ISO-8601 timestamp string (the wire format for every date field). */
50
+ export type IsoDateString = string
51
+
52
+ /**
53
+ * Money amounts are decimal major units (EUR 45.5, not cents) everywhere in
54
+ * the store API. Conversion to minor units happens only at provider
55
+ * boundaries server-side — the SDK never does money math (server truth).
56
+ */
57
+ export type MajorUnitAmount = number
58
+
59
+ /** `variant.calculated_price` (b2b-v1 pricing context, store-api.md). */
60
+ export interface CalculatedPrice {
61
+ calculated_amount: MajorUnitAmount
62
+ original_amount: MajorUnitAmount
63
+ currency_code: string
64
+ is_calculated_price_price_list: boolean
65
+ price_list_id: string | null
66
+ price_list_type: "sale" | "override" | null
67
+ }
68
+
69
+ /** Raw base price row on variants (`product.prices[]` shape detail). */
70
+ export interface PriceRow {
71
+ amount: MajorUnitAmount
72
+ currency_code: string
73
+ min_quantity?: number | null
74
+ max_quantity?: number | null
75
+ }
76
+
77
+ /**
78
+ * Pricing context accepted by every price-decorated listing (products,
79
+ * collections products, search, related). Server composes the rest
80
+ * (customer groups from the Bearer JWT, never from params).
81
+ */
82
+ export interface PricingContextQuery {
83
+ currency_code?: string
84
+ region_id?: string
85
+ }
86
+
87
+ /** Standard pagination query accepted by list endpoints. */
88
+ export interface PaginationQuery {
89
+ limit?: number
90
+ offset?: number
91
+ }
@@ -0,0 +1,86 @@
1
+ "use client"
2
+
3
+ import { useEffect, useRef, type ReactNode } from "react"
4
+
5
+ import { cn } from "../lib/utils"
6
+ import { useCartDrawer } from "./context"
7
+
8
+ /**
9
+ * Slide-out cart drawer shell. Contains an overlay, a focus-trapped panel
10
+ * that slides in from the right, an optional left sidebar (desktop only)
11
+ * for cross-sell, and a main content slot for the drawer body.
12
+ *
13
+ * Visual 1:1 with mindpages but colors are token-driven: `bg-card`,
14
+ * `bg-muted`, `border-border`. Override by setting CSS variables
15
+ * on the consuming store's :root.
16
+ *
17
+ * Ported from `@1click/ui/src/cart-drawer/cart-drawer.tsx` (v2.3.1);
18
+ * originally extracted from mindpages-storefront
19
+ * src/modules/cart-drawer/cart-drawer.tsx.
20
+ */
21
+
22
+ type CartDrawerProps = {
23
+ sidebar?: ReactNode
24
+ children: ReactNode
25
+ /** Optional className for the main panel (composes with internal styles) */
26
+ panelClassName?: string
27
+ }
28
+
29
+ export function CartDrawer({ sidebar, children, panelClassName }: CartDrawerProps) {
30
+ const { isOpen, close } = useCartDrawer()
31
+ const panelRef = useRef<HTMLDivElement>(null)
32
+
33
+ useEffect(() => {
34
+ if (isOpen && panelRef.current) {
35
+ panelRef.current.focus()
36
+ }
37
+ }, [isOpen])
38
+
39
+ const hasSidebar = !!sidebar
40
+
41
+ return (
42
+ <>
43
+ {/* Overlay */}
44
+ <div
45
+ className={cn(
46
+ "fixed inset-0 bg-black/50 transition-opacity duration-300 z-[60]",
47
+ isOpen
48
+ ? "opacity-100 pointer-events-auto"
49
+ : "opacity-0 pointer-events-none"
50
+ )}
51
+ onClick={close}
52
+ aria-hidden="true"
53
+ />
54
+
55
+ {/* Panel */}
56
+ <div
57
+ ref={panelRef}
58
+ tabIndex={-1}
59
+ role="dialog"
60
+ aria-modal="true"
61
+ aria-label="Shopping cart"
62
+ className={cn(
63
+ "fixed top-0 right-0 h-full bg-card z-[61] flex overflow-hidden",
64
+ "transition-transform duration-300 ease-[cubic-bezier(0.16,1,0.3,1)]",
65
+ isOpen ? "translate-x-0" : "translate-x-full",
66
+ hasSidebar ? "w-full md:w-[740px]" : "w-full sm:w-[460px]",
67
+ panelClassName
68
+ )}
69
+ style={{
70
+ boxShadow: isOpen ? "-20px 0 60px rgba(0,0,0,0.12)" : "none",
71
+ paddingTop: "env(safe-area-inset-top, 0px)",
72
+ }}
73
+ >
74
+ {/* Sidebar — desktop only */}
75
+ {hasSidebar && (
76
+ <div className="hidden md:block w-[280px] flex-shrink-0 bg-muted border-r border-border">
77
+ {sidebar}
78
+ </div>
79
+ )}
80
+
81
+ {/* Main cart */}
82
+ <div className="flex-1 flex flex-col min-w-0 bg-card">{children}</div>
83
+ </div>
84
+ </>
85
+ )
86
+ }