@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,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @cartbase/storefront/api/content — pages + blogs (content-pages-blog card).
|
|
3
|
+
*
|
|
4
|
+
* Ground truth: src/app/api/store/pages/[handle]/route.ts,
|
|
5
|
+
* src/app/api/store/blogs/[handle]/posts{,/[postHandle]}/route.ts,
|
|
6
|
+
* src/lib/content/article-shape.ts.
|
|
7
|
+
*
|
|
8
|
+
* Shapes are Shopify Storefront `Page` / `Article` verbatim so storefront
|
|
9
|
+
* code ports 1:1. PUBLISHED only — drafts and deleted rows 404 (lib filter
|
|
10
|
+
* AND anon RLS). Stored HTML is server-sanitized on write — safe to render
|
|
11
|
+
* raw (`dangerouslySetInnerHTML`).
|
|
12
|
+
*
|
|
13
|
+
* Path convention (src/lib/content/paths.ts, shared with menus/redirects):
|
|
14
|
+
* `/pages/<handle>`, `/blogs/<handle>`, `/blogs/<blog>/<post>`.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { StorefrontClient } from "./http"
|
|
18
|
+
import type { IsoDateString, PaginationQuery } from "./types"
|
|
19
|
+
|
|
20
|
+
/** Shopify Storefront `Page`, verbatim. */
|
|
21
|
+
export interface Page {
|
|
22
|
+
/** `cpage_<hex>` */
|
|
23
|
+
id: string
|
|
24
|
+
handle: string
|
|
25
|
+
title: string
|
|
26
|
+
/** Sanitized HTML — safe to render raw. */
|
|
27
|
+
body: string
|
|
28
|
+
/** Stripped text, ~160-char word-boundary truncation. */
|
|
29
|
+
bodySummary: string
|
|
30
|
+
/** Fallbacks applied server-side: title / bodySummary. */
|
|
31
|
+
seo: { title: string; description: string }
|
|
32
|
+
publishedAt: IsoDateString
|
|
33
|
+
createdAt: IsoDateString
|
|
34
|
+
updatedAt: IsoDateString
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface PageResponse {
|
|
38
|
+
page: Page
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* GET /api/store/pages/:handle — one published page.
|
|
43
|
+
*
|
|
44
|
+
* Auth: anon (x-client-id).
|
|
45
|
+
* Errors: 404 not_found (unknown handle, draft, or deleted).
|
|
46
|
+
* Settings: policy pages (privacy-policy, terms-of-service, refund-policy,
|
|
47
|
+
* shipping-policy) are seeded as DRAFTS — they 404 until published.
|
|
48
|
+
*/
|
|
49
|
+
export async function getPage(
|
|
50
|
+
client: StorefrontClient,
|
|
51
|
+
handle: string
|
|
52
|
+
): Promise<PageResponse> {
|
|
53
|
+
return client.get(`/api/store/pages/${encodeURIComponent(handle)}`)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Shopify Storefront `Article`, verbatim. */
|
|
57
|
+
export interface Article {
|
|
58
|
+
/** `bpost_<hex>` */
|
|
59
|
+
id: string
|
|
60
|
+
handle: string
|
|
61
|
+
title: string
|
|
62
|
+
/** Sanitized HTML — safe to render raw. */
|
|
63
|
+
contentHtml: string
|
|
64
|
+
/** Plain-text version of contentHtml. */
|
|
65
|
+
content: string
|
|
66
|
+
/** Author-provided; falls back to truncated content. */
|
|
67
|
+
excerpt: string
|
|
68
|
+
/** null when the post has no hero image. altText is always null today. */
|
|
69
|
+
image: { url: string; altText: null } | null
|
|
70
|
+
tags: string[]
|
|
71
|
+
/** null when the post has no author name set. */
|
|
72
|
+
author: { name: string } | null
|
|
73
|
+
publishedAt: IsoDateString
|
|
74
|
+
/** Fallbacks applied server-side: title / excerpt. */
|
|
75
|
+
seo: { title: string; description: string }
|
|
76
|
+
blog: { handle: string; title: string }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface ArticleResponse {
|
|
80
|
+
article: Article
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type ListBlogPostsQuery = PaginationQuery
|
|
84
|
+
|
|
85
|
+
export interface BlogPostListResponse {
|
|
86
|
+
blog: { handle: string; title: string }
|
|
87
|
+
articles: Article[]
|
|
88
|
+
count: number
|
|
89
|
+
offset: number
|
|
90
|
+
limit: number
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* GET /api/store/blogs/:handle/posts — published posts of one blog,
|
|
95
|
+
* `published_at` desc. `limit` ≤ 100 (default 20), `offset` ≥ 0.
|
|
96
|
+
*
|
|
97
|
+
* Auth: anon (x-client-id).
|
|
98
|
+
* Errors: 404 not_found (unknown blog handle) · 400 validation_failed
|
|
99
|
+
* (limit/offset out of range).
|
|
100
|
+
*/
|
|
101
|
+
export async function listBlogPosts(
|
|
102
|
+
client: StorefrontClient,
|
|
103
|
+
blogHandle: string,
|
|
104
|
+
query?: ListBlogPostsQuery
|
|
105
|
+
): Promise<BlogPostListResponse> {
|
|
106
|
+
return client.get(`/api/store/blogs/${encodeURIComponent(blogHandle)}/posts`, {
|
|
107
|
+
query: { ...query },
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* GET /api/store/blogs/:handle/posts/:postHandle — one published article.
|
|
113
|
+
*
|
|
114
|
+
* Auth: anon (x-client-id).
|
|
115
|
+
* Errors: 404 not_found (unknown blog OR unknown/draft post).
|
|
116
|
+
*/
|
|
117
|
+
export async function getBlogPost(
|
|
118
|
+
client: StorefrontClient,
|
|
119
|
+
blogHandle: string,
|
|
120
|
+
postHandle: string
|
|
121
|
+
): Promise<ArticleResponse> {
|
|
122
|
+
return client.get(
|
|
123
|
+
`/api/store/blogs/${encodeURIComponent(blogHandle)}/posts/${encodeURIComponent(postHandle)}`
|
|
124
|
+
)
|
|
125
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @cartbase/storefront/api/customers — customer profile, addresses, documents.
|
|
3
|
+
*
|
|
4
|
+
* Ground truth: src/app/api/store/customers/** + src/app/api/_lib/customer.ts.
|
|
5
|
+
*
|
|
6
|
+
* Auth model: every endpoint here except none is CUSTOMER-authenticated —
|
|
7
|
+
* `authorization: Bearer <supabase jwt>` (minted by the passwordless code
|
|
8
|
+
* flow in ./auth or a client-side supabase password sign-in) PLUS the anon
|
|
9
|
+
* `x-client-id` tenant header the client always sends. Missing/invalid JWT →
|
|
10
|
+
* `401 unauthenticated`.
|
|
11
|
+
*
|
|
12
|
+
* Lazy registration: `/customers/me` resolves the customer row by
|
|
13
|
+
* (client_id, JWT email) and CREATES it on first authenticated call
|
|
14
|
+
* (has_account=true, account_status per the store's approval policy) — a
|
|
15
|
+
* fresh login never 404s on "me".
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { StorefrontClient } from "./http"
|
|
19
|
+
import type { IsoDateString, PaginationQuery } from "./types"
|
|
20
|
+
|
|
21
|
+
/** One row of `customer.addresses` (barter_commerce.customer_addresses). */
|
|
22
|
+
export interface CustomerAddress {
|
|
23
|
+
id: string
|
|
24
|
+
client_id: string
|
|
25
|
+
customer_id: string
|
|
26
|
+
address_name: string | null
|
|
27
|
+
first_name: string | null
|
|
28
|
+
last_name: string | null
|
|
29
|
+
company: string | null
|
|
30
|
+
address_1: string | null
|
|
31
|
+
address_2: string | null
|
|
32
|
+
city: string | null
|
|
33
|
+
country_code: string | null
|
|
34
|
+
province: string | null
|
|
35
|
+
postal_code: string | null
|
|
36
|
+
phone: string | null
|
|
37
|
+
is_default_billing: boolean
|
|
38
|
+
is_default_shipping: boolean
|
|
39
|
+
metadata: Record<string, unknown> | null
|
|
40
|
+
created_at: IsoDateString
|
|
41
|
+
updated_at: IsoDateString
|
|
42
|
+
deleted_at: IsoDateString | null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The customer payload (full barter_commerce.customers row + addresses).
|
|
47
|
+
* `account_status` (`pending` | `approved`) gates B2B storefront content
|
|
48
|
+
* when the store's approval policy is on (b2b-v1). `tags` are admin-only
|
|
49
|
+
* labels — readable here, never writable from the store surface.
|
|
50
|
+
*/
|
|
51
|
+
export interface StoreCustomer {
|
|
52
|
+
id: string
|
|
53
|
+
client_id: string
|
|
54
|
+
email: string | null
|
|
55
|
+
first_name: string | null
|
|
56
|
+
last_name: string | null
|
|
57
|
+
phone: string | null
|
|
58
|
+
company_name: string | null
|
|
59
|
+
/** Bulgarian company id (ЕИК) — feeds invoice-required checkout. */
|
|
60
|
+
company_eik: string | null
|
|
61
|
+
vat_number: string | null
|
|
62
|
+
has_account: boolean
|
|
63
|
+
/** `pending` | `approved` — store approval policy (customer-accounts card). */
|
|
64
|
+
account_status: string
|
|
65
|
+
tags: string[]
|
|
66
|
+
metadata: Record<string, unknown> | null
|
|
67
|
+
created_by: string | null
|
|
68
|
+
created_at: IsoDateString
|
|
69
|
+
updated_at: IsoDateString
|
|
70
|
+
deleted_at: IsoDateString | null
|
|
71
|
+
addresses: CustomerAddress[]
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface CustomerResponse {
|
|
75
|
+
customer: StoreCustomer
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface CreateCustomerInput {
|
|
79
|
+
/** Must equal the session JWT's email — mismatch → 403 email_mismatch. */
|
|
80
|
+
email: string
|
|
81
|
+
first_name?: string
|
|
82
|
+
last_name?: string
|
|
83
|
+
phone?: string
|
|
84
|
+
company_name?: string
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* POST /api/store/customers — guest-to-registered conversion (password
|
|
89
|
+
* sign-up flow): call `supabase.auth.signUp({email, password})` client-side
|
|
90
|
+
* first, then this with the same email. Upserts the (client_id, email)
|
|
91
|
+
* customer row with has_account=true. NOT needed for the passwordless code
|
|
92
|
+
* flow — verify already lazy-creates the customer.
|
|
93
|
+
*
|
|
94
|
+
* Auth: Bearer JWT (+ x-client-id).
|
|
95
|
+
* Errors: 401 unauthenticated · 403 email_mismatch (body email ≠ JWT email)
|
|
96
|
+
* · 400 validation_failed.
|
|
97
|
+
* Settings: store approval policy decides account_status of a NEW row.
|
|
98
|
+
*/
|
|
99
|
+
export async function createCustomer(
|
|
100
|
+
client: StorefrontClient,
|
|
101
|
+
input: CreateCustomerInput
|
|
102
|
+
): Promise<CustomerResponse> {
|
|
103
|
+
return client.post("/api/store/customers", input)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* GET /api/store/customers/me — the signed-in customer, addresses embedded.
|
|
108
|
+
* Lazy-creates the customer row on first call for this (tenant, email).
|
|
109
|
+
*
|
|
110
|
+
* Auth: Bearer JWT (+ x-client-id).
|
|
111
|
+
* Errors: 401 unauthenticated · 400 missing_client_id.
|
|
112
|
+
*/
|
|
113
|
+
export async function getMe(client: StorefrontClient): Promise<CustomerResponse> {
|
|
114
|
+
return client.get("/api/store/customers/me")
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface UpdateCustomerInput {
|
|
118
|
+
first_name?: string | null
|
|
119
|
+
last_name?: string | null
|
|
120
|
+
phone?: string | null
|
|
121
|
+
company_name?: string | null
|
|
122
|
+
/** ЕИК / VAT (customer-master-data) — loose format, never shape-blocked. */
|
|
123
|
+
company_eik?: string | null
|
|
124
|
+
vat_number?: string | null
|
|
125
|
+
metadata?: Record<string, unknown>
|
|
126
|
+
// NOTE: `tags` deliberately absent — admin-only; the server strips them.
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* POST /api/store/customers/me — update own profile. Unknown keys (incl.
|
|
131
|
+
* `tags`) are stripped server-side; returns the refreshed customer.
|
|
132
|
+
*
|
|
133
|
+
* Auth: Bearer JWT (+ x-client-id).
|
|
134
|
+
* Errors: 401 unauthenticated · 400 validation_failed.
|
|
135
|
+
*/
|
|
136
|
+
export async function updateMe(
|
|
137
|
+
client: StorefrontClient,
|
|
138
|
+
input: UpdateCustomerInput
|
|
139
|
+
): Promise<CustomerResponse> {
|
|
140
|
+
return client.post("/api/store/customers/me", input)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface AddressListResponse {
|
|
144
|
+
addresses: CustomerAddress[]
|
|
145
|
+
/** Not truly paginated: offset is always 0 and limit === count. */
|
|
146
|
+
count: number
|
|
147
|
+
offset: number
|
|
148
|
+
limit: number
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* GET /api/store/customers/me/addresses — all of the customer's addresses
|
|
153
|
+
* (created_at asc). The list envelope is nominal — the route returns EVERY
|
|
154
|
+
* address (offset 0, limit = count); no query params are read.
|
|
155
|
+
*
|
|
156
|
+
* Auth: Bearer JWT (+ x-client-id). Errors: 401 unauthenticated.
|
|
157
|
+
*/
|
|
158
|
+
export async function listAddresses(
|
|
159
|
+
client: StorefrontClient
|
|
160
|
+
): Promise<AddressListResponse> {
|
|
161
|
+
return client.get("/api/store/customers/me/addresses")
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export interface CreateAddressInput {
|
|
165
|
+
address_name?: string
|
|
166
|
+
first_name?: string
|
|
167
|
+
last_name?: string
|
|
168
|
+
company?: string
|
|
169
|
+
address_1?: string
|
|
170
|
+
address_2?: string
|
|
171
|
+
city?: string
|
|
172
|
+
/** Lower-cased server-side. */
|
|
173
|
+
country_code?: string
|
|
174
|
+
province?: string
|
|
175
|
+
postal_code?: string
|
|
176
|
+
phone?: string
|
|
177
|
+
/** true clears the flag on every sibling first (one default per kind). */
|
|
178
|
+
is_default_billing?: boolean
|
|
179
|
+
is_default_shipping?: boolean
|
|
180
|
+
metadata?: Record<string, unknown>
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* POST /api/store/customers/me/addresses — create an address. Returns the
|
|
185
|
+
* FULL refreshed customer (`{customer}`, addresses embedded) — not the
|
|
186
|
+
* created address alone; find it in `customer.addresses`.
|
|
187
|
+
*
|
|
188
|
+
* Auth: Bearer JWT (+ x-client-id).
|
|
189
|
+
* Errors: 401 unauthenticated · 400 validation_failed.
|
|
190
|
+
*/
|
|
191
|
+
export async function createAddress(
|
|
192
|
+
client: StorefrontClient,
|
|
193
|
+
input: CreateAddressInput
|
|
194
|
+
): Promise<CustomerResponse> {
|
|
195
|
+
return client.post("/api/store/customers/me/addresses", input)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export interface AddressResponse {
|
|
199
|
+
address: CustomerAddress
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* GET /api/store/customers/me/addresses/:id — one owned address.
|
|
204
|
+
*
|
|
205
|
+
* Auth: Bearer JWT (+ x-client-id).
|
|
206
|
+
* Errors: 401 unauthenticated · 404 not_found (unknown OR another
|
|
207
|
+
* customer's address — ownership 404s, it never 403s).
|
|
208
|
+
*/
|
|
209
|
+
export async function getAddress(
|
|
210
|
+
client: StorefrontClient,
|
|
211
|
+
addressId: string
|
|
212
|
+
): Promise<AddressResponse> {
|
|
213
|
+
return client.get(`/api/store/customers/me/addresses/${encodeURIComponent(addressId)}`)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export interface UpdateAddressInput {
|
|
217
|
+
address_name?: string | null
|
|
218
|
+
first_name?: string | null
|
|
219
|
+
last_name?: string | null
|
|
220
|
+
company?: string | null
|
|
221
|
+
address_1?: string | null
|
|
222
|
+
address_2?: string | null
|
|
223
|
+
city?: string | null
|
|
224
|
+
country_code?: string | null
|
|
225
|
+
province?: string | null
|
|
226
|
+
postal_code?: string | null
|
|
227
|
+
phone?: string | null
|
|
228
|
+
is_default_billing?: boolean
|
|
229
|
+
is_default_shipping?: boolean
|
|
230
|
+
metadata?: Record<string, unknown>
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* POST /api/store/customers/me/addresses/:id — partial update; default
|
|
235
|
+
* flags clear siblings. Returns the FULL refreshed customer.
|
|
236
|
+
*
|
|
237
|
+
* Auth: Bearer JWT (+ x-client-id).
|
|
238
|
+
* Errors: 401 unauthenticated · 404 not_found · 400 validation_failed.
|
|
239
|
+
*/
|
|
240
|
+
export async function updateAddress(
|
|
241
|
+
client: StorefrontClient,
|
|
242
|
+
addressId: string,
|
|
243
|
+
input: UpdateAddressInput
|
|
244
|
+
): Promise<CustomerResponse> {
|
|
245
|
+
return client.post(
|
|
246
|
+
`/api/store/customers/me/addresses/${encodeURIComponent(addressId)}`,
|
|
247
|
+
input
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* DELETE /api/store/customers/me/addresses/:id — soft-delete an owned
|
|
253
|
+
* address. Returns the FULL refreshed customer (the address gone from
|
|
254
|
+
* `customer.addresses`).
|
|
255
|
+
*
|
|
256
|
+
* Auth: Bearer JWT (+ x-client-id).
|
|
257
|
+
* Errors: 401 unauthenticated · 404 not_found.
|
|
258
|
+
*/
|
|
259
|
+
export async function deleteAddress(
|
|
260
|
+
client: StorefrontClient,
|
|
261
|
+
addressId: string
|
|
262
|
+
): Promise<CustomerResponse> {
|
|
263
|
+
return client.delete(
|
|
264
|
+
`/api/store/customers/me/addresses/${encodeURIComponent(addressId)}`
|
|
265
|
+
)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** One issued (non-void) order document — the account "Invoices" row. */
|
|
269
|
+
export interface CustomerDocument {
|
|
270
|
+
id: string
|
|
271
|
+
order_id: string
|
|
272
|
+
order_display_id: number | null
|
|
273
|
+
doc_type: string
|
|
274
|
+
number: string
|
|
275
|
+
issued_at: IsoDateString
|
|
276
|
+
pdf_url: string | null
|
|
277
|
+
due_date: IsoDateString | null
|
|
278
|
+
paid_at: IsoDateString | null
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export interface ListDocumentsQuery extends PaginationQuery {
|
|
282
|
+
/** Filter by document type (e.g. `invoice`). */
|
|
283
|
+
doc_type?: string
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export interface DocumentListResponse {
|
|
287
|
+
documents: CustomerDocument[]
|
|
288
|
+
count: number
|
|
289
|
+
offset: number
|
|
290
|
+
limit: number
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* GET /api/store/customers/me/documents — the customer's issued order
|
|
295
|
+
* documents, newest first (issued_at desc). Voided documents excluded.
|
|
296
|
+
* Ownership is INNER-join enforced (session customer id AND client_id) —
|
|
297
|
+
* cross-customer reads are impossible. `limit` ≤ 200, default 20.
|
|
298
|
+
*
|
|
299
|
+
* Auth: Bearer JWT (+ x-client-id).
|
|
300
|
+
* Errors: 401 unauthenticated · 400 validation_failed.
|
|
301
|
+
*/
|
|
302
|
+
export async function listMyDocuments(
|
|
303
|
+
client: StorefrontClient,
|
|
304
|
+
query?: ListDocumentsQuery
|
|
305
|
+
): Promise<DocumentListResponse> {
|
|
306
|
+
return client.get("/api/store/customers/me/documents", { query: { ...query } })
|
|
307
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @cartbase/storefront/api/gift-cards — gift-card TENDER on carts.
|
|
3
|
+
*
|
|
4
|
+
* Redemption is a payment tender (`pp_giftcard`), never a discount: cart
|
|
5
|
+
* totals/VAT compute first and NEVER move; applied cards cover part (or all)
|
|
6
|
+
* of `cart.total` and the remainder provider (Stripe/COD/manual) charges
|
|
7
|
+
* only what is left. The cart decoration fields (`gift_cards[]`,
|
|
8
|
+
* `gift_card_total`, `gift_card_remainder` — declared on the `Cart` DTO in
|
|
9
|
+
* `./carts`) are derived from the LIVE transactions ledger at every read.
|
|
10
|
+
*
|
|
11
|
+
* Route ground truth: src/app/api/store/carts/[id]/gift-cards/route.ts +
|
|
12
|
+
* src/lib/gift-cards/{redeem,tender}.ts. Doc: docs/storefront/gift-cards.md.
|
|
13
|
+
*
|
|
14
|
+
* Security model baked into the contract:
|
|
15
|
+
* - failure is ONE generic answer — unknown, disabled, expired, depleted
|
|
16
|
+
* and foreign-tenant codes are all `400 invalid_gift_card` (no
|
|
17
|
+
* code-existence oracle);
|
|
18
|
+
* - attempts are recorded BEFORE lookup and rate-limited per cart
|
|
19
|
+
* (10 / 15 min) AND per IP (30 / 15 min) → `429 rate_limited`;
|
|
20
|
+
* - the confirmation is MASKED (`last4` only) — the code is never echoed.
|
|
21
|
+
*/
|
|
22
|
+
import type { StorefrontClient } from "./http"
|
|
23
|
+
import type { MajorUnitAmount } from "./types"
|
|
24
|
+
import type { Cart } from "./carts"
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// DTOs
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
/** Body of POST /api/store/carts/:id/gift-cards (`.strict()`). */
|
|
31
|
+
export interface ApplyGiftCardInput {
|
|
32
|
+
/** The plaintext code (4–64 chars). Hashed at rest server-side. */
|
|
33
|
+
code: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Body of DELETE /api/store/carts/:id/gift-cards (`.strict()`). */
|
|
37
|
+
export interface RemoveGiftCardInput {
|
|
38
|
+
/** The applied card's id (from `cart.gift_cards[].id` or the apply response). */
|
|
39
|
+
gift_card_id: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Masked apply confirmation. */
|
|
43
|
+
export interface AppliedGiftCardConfirmation {
|
|
44
|
+
id: string
|
|
45
|
+
/** Last 4 characters of the code — the full code is never echoed. */
|
|
46
|
+
last4: string
|
|
47
|
+
/**
|
|
48
|
+
* How much of the cart total THIS card covers right now
|
|
49
|
+
* (`min(live balance, remaining total)` in apply order).
|
|
50
|
+
*/
|
|
51
|
+
amount_applied: MajorUnitAmount
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ApplyGiftCardResponse {
|
|
55
|
+
/** Decorated cart — `gift_cards[]` / `gift_card_total` / `gift_card_remainder` updated; `total` untouched. */
|
|
56
|
+
cart: Cart
|
|
57
|
+
gift_card: AppliedGiftCardConfirmation
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface RemoveGiftCardResponse {
|
|
61
|
+
cart: Cart
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// Functions
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Apply a gift-card code to an open cart (idempotent re-apply). → 200
|
|
70
|
+
* `{cart, gift_card}` with a MASKED confirmation.
|
|
71
|
+
*
|
|
72
|
+
* Auth: anon `x-client-id`.
|
|
73
|
+
* Errors: 400 `invalid_gift_card` (the deliberate generic answer for
|
|
74
|
+
* unknown/disabled/expired/depleted/foreign-tenant codes), 429
|
|
75
|
+
* `rate_limited` (per-cart and per-IP attempt windows — a valid code inside
|
|
76
|
+
* a burned window is refused the same way), 404 `cart_not_found`, 409
|
|
77
|
+
* `cart_completed`, 400 `validation_failed`.
|
|
78
|
+
*
|
|
79
|
+
* Behavior: also syncs the internal `pp_giftcard` payment session to the
|
|
80
|
+
* covered amount when a payment collection exists (no-op before one does —
|
|
81
|
+
* creating the collection composes it). Actual balance redemption happens
|
|
82
|
+
* ONLY at cart complete, atomically; a card drained elsewhere in the
|
|
83
|
+
* meantime shrinks this cart's tender at the next read instead of
|
|
84
|
+
* over-redeeming.
|
|
85
|
+
* Settings: gift cards are issued/disabled from the admin; expiry and
|
|
86
|
+
* balance live on the card's ledger.
|
|
87
|
+
*/
|
|
88
|
+
export async function applyGiftCard(
|
|
89
|
+
client: StorefrontClient,
|
|
90
|
+
cartId: string,
|
|
91
|
+
input: ApplyGiftCardInput
|
|
92
|
+
): Promise<ApplyGiftCardResponse> {
|
|
93
|
+
return client.post(`/api/store/carts/${cartId}/gift-cards`, input)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Remove an applied gift card from an open cart (idempotent). → 200 `{cart}`.
|
|
98
|
+
*
|
|
99
|
+
* Auth: anon `x-client-id`.
|
|
100
|
+
* Errors: 404 `cart_not_found`, 409 `cart_completed`, 400
|
|
101
|
+
* `validation_failed`.
|
|
102
|
+
* Behavior: re-syncs the `pp_giftcard` session to the remaining tender
|
|
103
|
+
* (removing the last card zeroes it), so the remainder provider's session
|
|
104
|
+
* re-inflates to the full total on the next amount sync.
|
|
105
|
+
*/
|
|
106
|
+
export async function removeGiftCard(
|
|
107
|
+
client: StorefrontClient,
|
|
108
|
+
cartId: string,
|
|
109
|
+
input: RemoveGiftCardInput
|
|
110
|
+
): Promise<RemoveGiftCardResponse> {
|
|
111
|
+
return client.delete(`/api/store/carts/${cartId}/gift-cards`, input)
|
|
112
|
+
}
|
package/src/api/http.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @cartbase/storefront — the ONE http seam.
|
|
3
|
+
*
|
|
4
|
+
* Every SDK function in this package is a thin typed wrapper over
|
|
5
|
+
* `storeFetch()` from a `StorefrontClient`. Auth model (store-api.md):
|
|
6
|
+
*
|
|
7
|
+
* - `x-client-id` — required for anon reads (RLS scope)
|
|
8
|
+
* - `x-publishable-api-key` — optional; scopes catalog/carts to the key's
|
|
9
|
+
* sales channels when set
|
|
10
|
+
* - `authorization: Bearer <jwt>` — customer session (passwordless code
|
|
11
|
+
* flow or supabase password login)
|
|
12
|
+
* - `x-locale` — optional storefront locale hint
|
|
13
|
+
*
|
|
14
|
+
* The client is isomorphic (browser + RSC/server actions) — it holds no
|
|
15
|
+
* global state; construct once per request scope on the server, once per
|
|
16
|
+
* app on the client.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { StoreApiError } from "./types"
|
|
20
|
+
|
|
21
|
+
export interface StorefrontClientConfig {
|
|
22
|
+
/** Deployment origin, e.g. `https://admin.mindpages.bg` */
|
|
23
|
+
baseUrl: string
|
|
24
|
+
/** Tenant id — required for anon reads. */
|
|
25
|
+
clientId: string
|
|
26
|
+
/** Publishable API key (channel scope). Optional for single-channel stores. */
|
|
27
|
+
publishableKey?: string
|
|
28
|
+
/** Called per request; return the customer JWT or null for guests. */
|
|
29
|
+
getAuthToken?: () => string | null | Promise<string | null>
|
|
30
|
+
/** Called per request; return the active locale code or null. */
|
|
31
|
+
getLocale?: () => string | null | Promise<string | null>
|
|
32
|
+
/** Override fetch (tests, custom caching). Defaults to global fetch. */
|
|
33
|
+
fetch?: typeof fetch
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type QueryValue = string | number | boolean | null | undefined
|
|
37
|
+
export type Query = Record<string, QueryValue | QueryValue[]>
|
|
38
|
+
|
|
39
|
+
export interface RequestOptions {
|
|
40
|
+
method?: "GET" | "POST" | "DELETE"
|
|
41
|
+
query?: Query
|
|
42
|
+
body?: unknown
|
|
43
|
+
headers?: Record<string, string>
|
|
44
|
+
/** Passed through to fetch — lets Next.js RSC callers tag cache behavior. */
|
|
45
|
+
cache?: RequestCache
|
|
46
|
+
next?: { revalidate?: number | false; tags?: string[] }
|
|
47
|
+
signal?: AbortSignal
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class StorefrontClient {
|
|
51
|
+
constructor(private readonly config: StorefrontClientConfig) {
|
|
52
|
+
if (!config.baseUrl) throw new Error("StorefrontClient: baseUrl is required")
|
|
53
|
+
if (!config.clientId) throw new Error("StorefrontClient: clientId is required")
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The low-level typed request. Domain modules call this — apps rarely should. */
|
|
57
|
+
async request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
|
|
58
|
+
const url = new URL(
|
|
59
|
+
path.replace(/^\//, ""),
|
|
60
|
+
this.config.baseUrl.replace(/\/?$/, "/")
|
|
61
|
+
)
|
|
62
|
+
if (opts.query) {
|
|
63
|
+
for (const [key, value] of Object.entries(opts.query)) {
|
|
64
|
+
if (value === undefined || value === null) continue
|
|
65
|
+
if (Array.isArray(value)) {
|
|
66
|
+
const joined = value.filter((v) => v !== undefined && v !== null).join(",")
|
|
67
|
+
if (joined) url.searchParams.set(key, joined)
|
|
68
|
+
} else {
|
|
69
|
+
url.searchParams.set(key, String(value))
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const headers: Record<string, string> = {
|
|
75
|
+
"x-client-id": this.config.clientId,
|
|
76
|
+
...(this.config.publishableKey
|
|
77
|
+
? { "x-publishable-api-key": this.config.publishableKey }
|
|
78
|
+
: {}),
|
|
79
|
+
...(opts.body !== undefined ? { "content-type": "application/json" } : {}),
|
|
80
|
+
...(opts.headers ?? {}),
|
|
81
|
+
}
|
|
82
|
+
const token = this.config.getAuthToken ? await this.config.getAuthToken() : null
|
|
83
|
+
if (token && !headers.authorization) headers.authorization = `Bearer ${token}`
|
|
84
|
+
const locale = this.config.getLocale ? await this.config.getLocale() : null
|
|
85
|
+
if (locale && !headers["x-locale"]) headers["x-locale"] = locale
|
|
86
|
+
|
|
87
|
+
const doFetch = this.config.fetch ?? fetch
|
|
88
|
+
const res = await doFetch(url.toString(), {
|
|
89
|
+
method: opts.method ?? "GET",
|
|
90
|
+
headers,
|
|
91
|
+
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
|
92
|
+
cache: opts.cache,
|
|
93
|
+
...(opts.next ? { next: opts.next } : {}),
|
|
94
|
+
signal: opts.signal,
|
|
95
|
+
} as RequestInit)
|
|
96
|
+
|
|
97
|
+
if (!res.ok) {
|
|
98
|
+
let detail: unknown
|
|
99
|
+
const text = await res.text()
|
|
100
|
+
try {
|
|
101
|
+
detail = JSON.parse(text)
|
|
102
|
+
} catch {
|
|
103
|
+
detail = text
|
|
104
|
+
}
|
|
105
|
+
throw new StoreApiError(res.status, detail)
|
|
106
|
+
}
|
|
107
|
+
if (res.status === 204) return undefined as T
|
|
108
|
+
return (await res.json()) as T
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
get<T>(path: string, opts: Omit<RequestOptions, "method" | "body"> = {}): Promise<T> {
|
|
112
|
+
return this.request<T>(path, { ...opts, method: "GET" })
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
post<T>(path: string, body?: unknown, opts: Omit<RequestOptions, "method" | "body"> = {}): Promise<T> {
|
|
116
|
+
return this.request<T>(path, { ...opts, method: "POST", body })
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
delete<T>(path: string, body?: unknown, opts: Omit<RequestOptions, "method" | "body"> = {}): Promise<T> {
|
|
120
|
+
return this.request<T>(path, { ...opts, method: "DELETE", body })
|
|
121
|
+
}
|
|
122
|
+
}
|
package/src/api/index.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @cartbase/storefront/api — barrel.
|
|
3
|
+
*
|
|
4
|
+
* PRE-DECLARED SEAM: every domain module line below is written up front so
|
|
5
|
+
* fleet agents create their module file WITHOUT editing this barrel (no
|
|
6
|
+
* union-merge seams). Do not reorder; append new domains at the end.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export * from "./types"
|
|
10
|
+
export * from "./http"
|
|
11
|
+
|
|
12
|
+
export * as products from "./products"
|
|
13
|
+
export * as collections from "./collections"
|
|
14
|
+
export * as categories from "./categories"
|
|
15
|
+
export * as regions from "./regions"
|
|
16
|
+
export * as carts from "./carts"
|
|
17
|
+
export * as giftCards from "./gift-cards"
|
|
18
|
+
export * as checkout from "./checkout"
|
|
19
|
+
export * as orders from "./orders"
|
|
20
|
+
export * as customers from "./customers"
|
|
21
|
+
export * as auth from "./auth"
|
|
22
|
+
export * as content from "./content"
|
|
23
|
+
export * as menus from "./menus"
|
|
24
|
+
export * as metaobjects from "./metaobjects"
|
|
25
|
+
export * as reviews from "./reviews"
|
|
26
|
+
export * as search from "./search"
|
|
27
|
+
export * as integrations from "./integrations"
|
|
28
|
+
export * as consent from "./consent"
|
|
29
|
+
export * as redirects from "./redirects"
|