@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,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
|
+
}
|
package/src/api/types.ts
ADDED
|
@@ -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
|
+
}
|