create-cartbase 0.0.1 → 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/LICENSE +21 -0
- package/README.md +9 -3
- package/dist/index.js +94 -0
- package/package.json +18 -4
- package/template/app/CLAUDE.md +18 -0
- package/template/app/docs/BUILD-A-STOREFRONT.md +216 -0
- package/template/app/docs/README.md +76 -0
- package/template/app/docs/auth.md +105 -0
- package/template/app/docs/carts.md +376 -0
- package/template/app/docs/categories.md +194 -0
- package/template/app/docs/checkout.md +611 -0
- package/template/app/docs/collections.md +167 -0
- package/template/app/docs/components.md +1089 -0
- package/template/app/docs/consent.md +81 -0
- package/template/app/docs/content.md +126 -0
- package/template/app/docs/customers.md +269 -0
- package/template/app/docs/deploy.md +192 -0
- package/template/app/docs/gift-cards.md +153 -0
- package/template/app/docs/integrations.md +137 -0
- package/template/app/docs/menus.md +73 -0
- package/template/app/docs/metaobjects.md +126 -0
- package/template/app/docs/orders.md +221 -0
- package/template/app/docs/products.md +300 -0
- package/template/app/docs/redirects.md +50 -0
- package/template/app/docs/regions.md +207 -0
- package/template/app/docs/reviews.md +223 -0
- package/template/app/docs/search.md +218 -0
- package/template/app/docs/subscriptions.md +148 -0
- package/template/app/next.config.ts +34 -0
- package/template/app/package.json +25 -0
- package/template/app/postcss.config.cjs +6 -0
- package/template/app/smoke.mjs +158 -0
- package/template/app/src/app/checkout/checkout-page-client.tsx +66 -0
- package/template/app/src/app/checkout/page.tsx +49 -0
- package/template/app/src/app/globals.css +42 -0
- package/template/app/src/app/layout.tsx +105 -0
- package/template/app/src/app/order/[id]/confirmed/page.tsx +77 -0
- package/template/app/src/app/page.tsx +25 -0
- package/template/app/src/app/products/[handle]/page.tsx +58 -0
- package/template/app/src/app/providers.tsx +54 -0
- package/template/app/src/app/search/page.tsx +20 -0
- package/template/app/src/lib/browser-client.ts +35 -0
- package/template/app/src/lib/cart-actions.ts +43 -0
- package/template/app/src/lib/config.ts +16 -0
- package/template/app/src/lib/server-client.ts +25 -0
- package/template/app/tailwind.config.cjs +9 -0
- package/template/app/tsconfig.json +41 -0
- package/template/app/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# Gift cards — tender on carts
|
|
2
|
+
|
|
3
|
+
Gift-card redemption is a **payment tender** (`pp_giftcard`), never a
|
|
4
|
+
discount: cart totals and VAT compute first and **never move**; applied
|
|
5
|
+
cards cover part (or all) of `cart.total` and the remainder provider
|
|
6
|
+
(Stripe/COD/manual) charges only what is left. Balances derive from an
|
|
7
|
+
append-only transactions ledger — resolved LIVE on every cart read, so a
|
|
8
|
+
balance spent elsewhere shrinks this cart's tender instead of
|
|
9
|
+
over-redeeming. Actual redemption happens ONLY at cart complete, atomically.
|
|
10
|
+
|
|
11
|
+
**Auth for every endpoint on this page:** anon `x-client-id`.
|
|
12
|
+
|
|
13
|
+
SDK module: `@cartbase/storefront/api/gift-cards`. Zero-remainder checkout
|
|
14
|
+
(cards cover everything) is part of the Buy-click sequence — see
|
|
15
|
+
[checkout.md](checkout.md).
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Cart decoration (every cart read)
|
|
20
|
+
|
|
21
|
+
Every cart response carries three tender fields (declared on the `Cart` DTO
|
|
22
|
+
in `@cartbase/storefront/api/carts`; see [carts.md](carts.md) for the full
|
|
23
|
+
cart shape):
|
|
24
|
+
|
|
25
|
+
```jsonc
|
|
26
|
+
{
|
|
27
|
+
"cart": {
|
|
28
|
+
// …totals (NEVER moved by gift cards)…
|
|
29
|
+
"total": 54,
|
|
30
|
+
"gift_cards": [ // apply order; each covers min(live balance, remaining total)
|
|
31
|
+
{ "id": "gift_…", "last4": "PQRS", "amount": 25 }
|
|
32
|
+
// a disabled/expired/depleted card stays listed at amount: 0
|
|
33
|
+
],
|
|
34
|
+
"gift_card_total": 25, // Σ coverage = the pp_giftcard session amount
|
|
35
|
+
"gift_card_remainder": 29 // max(total − gift_card_total, 0) — what the remainder provider charges
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
# The decoration fields exist on every cart read (empty state shown here).
|
|
42
|
+
CART_JSON=$(curl -sf -X POST "$BASE/api/store/carts" \
|
|
43
|
+
-H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
|
|
44
|
+
-d '{"email":"gc-doc-'"$RUN"'@example.test",
|
|
45
|
+
"items":[{"variant_id":"variant_01tst000000000000000002","quantity":1}]}')
|
|
46
|
+
CART_ID=$(echo "$CART_JSON" | grep -o '"id":"cart_[^"]*"' | head -1 | cut -d'"' -f4)
|
|
47
|
+
echo "$CART_JSON" | grep -q '"gift_cards":\[\]'
|
|
48
|
+
echo "$CART_JSON" | grep -q '"gift_card_total":0'
|
|
49
|
+
echo "$CART_JSON" | grep -q '"gift_card_remainder"'
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## POST /api/store/carts/:id/gift-cards — apply a code
|
|
55
|
+
|
|
56
|
+
- **Purpose** — apply a gift-card code to an open cart (idempotent
|
|
57
|
+
re-apply).
|
|
58
|
+
- **Auth** — anon `x-client-id`.
|
|
59
|
+
- **Request** — `{code}` (`.strict()`, 4–64 chars). The code travels ONCE,
|
|
60
|
+
here; it is hashed at rest and never echoed back.
|
|
61
|
+
- **Response** — `200 {cart, gift_card}` — a MASKED confirmation:
|
|
62
|
+
|
|
63
|
+
```jsonc
|
|
64
|
+
{
|
|
65
|
+
"cart": { "…": "decorated cart — gift_cards/gift_card_total/gift_card_remainder updated, total untouched" },
|
|
66
|
+
"gift_card": {
|
|
67
|
+
"id": "gift_…",
|
|
68
|
+
"last4": "PQRS", // last 4 chars of the code — never the code
|
|
69
|
+
"amount_applied": 25 // how much of THIS cart this card covers right now
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
- **Errors** — deliberately a single generic answer, **no code-existence
|
|
75
|
+
oracle**: unknown, disabled, expired, depleted and foreign-tenant codes
|
|
76
|
+
are all `400 {code: "invalid_gift_card"}`. Attempts are recorded BEFORE
|
|
77
|
+
lookup and rate-limited per cart (10 / 15 min) AND per IP (30 / 15 min) →
|
|
78
|
+
`429 {code: "rate_limited"}` — a valid code inside a burned window is
|
|
79
|
+
refused the same way. Also 404 `cart_not_found`, 409 `cart_completed`,
|
|
80
|
+
400 `validation_failed`.
|
|
81
|
+
- **SDK** — `giftCards.applyGiftCard(client, cartId, {code})`.
|
|
82
|
+
- **Components** — checkout gift-card field (show `last4` +
|
|
83
|
+
`amount_applied`; on 400 show one generic "code cannot be applied"
|
|
84
|
+
message — do NOT branch on failure reasons that the API deliberately
|
|
85
|
+
hides).
|
|
86
|
+
- **Settings** — cards are issued/disabled in the admin (gift-cards admin
|
|
87
|
+
surface); expiry + balance live on the card's ledger. Applying also syncs
|
|
88
|
+
the internal `pp_giftcard` payment session when a payment collection
|
|
89
|
+
already exists (creating one composes it — checkout.md).
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
# Executable error contract: the generic-oracle answer. (A happy-path apply
|
|
93
|
+
# needs an admin-issued card — shown in the jsonc above; proven end-to-end
|
|
94
|
+
# by tests/store/gift-cards.test.ts.)
|
|
95
|
+
STATUS=$(curl -s -o /dev/null -w '%{http_code}' -X POST \
|
|
96
|
+
"$BASE/api/store/carts/$CART_ID/gift-cards" \
|
|
97
|
+
-H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
|
|
98
|
+
-H "x-forwarded-for: 10.90.$((RANDOM % 250)).$((RANDOM % 250))" \
|
|
99
|
+
-d '{"code":"NOPE-NOPE-NOPE-NOPE"}')
|
|
100
|
+
test "$STATUS" = 400
|
|
101
|
+
RES=$(curl -s -X POST "$BASE/api/store/carts/$CART_ID/gift-cards" \
|
|
102
|
+
-H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
|
|
103
|
+
-H "x-forwarded-for: 10.90.$((RANDOM % 250)).$((RANDOM % 250))" \
|
|
104
|
+
-d '{"code":"ALSO-NOT-A-REAL-CODE"}')
|
|
105
|
+
echo "$RES" | grep -q '"code":"invalid_gift_card"'
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## DELETE /api/store/carts/:id/gift-cards — remove an applied card
|
|
111
|
+
|
|
112
|
+
- **Purpose** — remove an applied card from an open cart (idempotent —
|
|
113
|
+
removing a never-applied id still returns the cart).
|
|
114
|
+
- **Auth** — anon `x-client-id`.
|
|
115
|
+
- **Request** — `{gift_card_id}` (`.strict()`; the id from
|
|
116
|
+
`cart.gift_cards[].id` or the apply confirmation).
|
|
117
|
+
- **Response** — `200 {cart}` (decoration recomputed; the `pp_giftcard`
|
|
118
|
+
session re-synced to the remaining tender).
|
|
119
|
+
- **Errors** — 404 `cart_not_found`, 409 `cart_completed`, 400
|
|
120
|
+
`validation_failed`.
|
|
121
|
+
- **SDK** — `giftCards.removeGiftCard(client, cartId, {gift_card_id})`.
|
|
122
|
+
- **Components** — checkout applied-cards list (remove chip).
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
# Idempotent remove is executable without an issued card.
|
|
126
|
+
curl -sf -X DELETE "$BASE/api/store/carts/$CART_ID/gift-cards" \
|
|
127
|
+
-H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
|
|
128
|
+
-d '{"gift_card_id":"gift_never_applied"}' | grep -q '"gift_card_total":0'
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## What happens at complete (contract summary)
|
|
134
|
+
|
|
135
|
+
Redemption is atomic at `POST /carts/:id/complete` — idempotent per
|
|
136
|
+
(card, order); two carts racing one balance: exactly one completes, the
|
|
137
|
+
loser gets `402 gift_card_insufficient_balance` and its order is
|
|
138
|
+
compensated away. A remainder-payment failure reverses this order's
|
|
139
|
+
redemptions back to the cards before rethrowing. A gift session that no
|
|
140
|
+
longer covers its amount (card disabled/drained since apply) →
|
|
141
|
+
`402 payment_incomplete` / `402 gift_card_not_redeemable`, nothing burned.
|
|
142
|
+
Zero-remainder carts complete on the gift session alone — no provider
|
|
143
|
+
involved. Refunds/cancels always reverse tender **to the card**, never to
|
|
144
|
+
a bank. Buying gift-card products (digital issue-on-purchase, physical
|
|
145
|
+
activate-at-packing) is a catalog concern — see the product docs and the
|
|
146
|
+
gift-cards admin surface.
|
|
147
|
+
|
|
148
|
+
## Cleanup / accretion note
|
|
149
|
+
|
|
150
|
+
This page creates carts only (no store-facing delete endpoint exists —
|
|
151
|
+
same inert accretion as the suite's own cart tests) and applies no real
|
|
152
|
+
cards. Invalid-code attempts land in the rate-limit ledger under a unique
|
|
153
|
+
random IP per run, far below the per-IP window.
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# Store integrations config — carriers, COD, tracking, lockers
|
|
2
|
+
|
|
3
|
+
Store-public integration configuration for render/checkout time
|
|
4
|
+
(couriers-port + tracking-integrations cards). The payload is **composed**
|
|
5
|
+
from an ordered block registry — each block owns distinct top-level keys
|
|
6
|
+
(`carriers`, `cod`, `tracking` today; future blocks append). It is NOT
|
|
7
|
+
wrapped in an envelope: the blocks ARE the top-level keys.
|
|
8
|
+
|
|
9
|
+
**Security law (the allowlist rule)**: every block is an explicit
|
|
10
|
+
allowlist — objects are built field-by-field from capability descriptors
|
|
11
|
+
and the few named public config values. Credentials are never read into a
|
|
12
|
+
response object, so **secrets can never appear in this payload**: carrier
|
|
13
|
+
API keys, Meta CAPI `access_token`, GA4 `api_secret`, Klaviyo
|
|
14
|
+
`private_key`. The executable block below asserts their absence on the
|
|
15
|
+
live payload; `tests/store/store-integrations.test.ts` asserts it
|
|
16
|
+
key-by-key.
|
|
17
|
+
|
|
18
|
+
## GET /api/store/integrations — the composed config
|
|
19
|
+
|
|
20
|
+
- **Purpose**: one call at storefront boot/checkout for carrier
|
|
21
|
+
capabilities, the COD fee row, and the public analytics tag IDs.
|
|
22
|
+
- **Auth**: anon (`x-client-id`); `x-publishable-api-key` is **validated
|
|
23
|
+
when present** (unknown/revoked/foreign-tenant key →
|
|
24
|
+
`400 invalid_publishable_key`) and may be omitted by single-channel
|
|
25
|
+
storefronts.
|
|
26
|
+
- **Request**: no params.
|
|
27
|
+
- **Response**:
|
|
28
|
+
|
|
29
|
+
```jsonc
|
|
30
|
+
{
|
|
31
|
+
// Only ENABLED carriers appear — disabled/unconfigured are ABSENT,
|
|
32
|
+
// never `enabled: false`. Keyed by provider slug.
|
|
33
|
+
"carriers": {
|
|
34
|
+
"boxnow": {
|
|
35
|
+
"enabled": true,
|
|
36
|
+
"cod": true, // carrier collects cash on delivery
|
|
37
|
+
"pickup_points": false, // office/pickup-point delivery
|
|
38
|
+
"lockers": true, // locker/APM network
|
|
39
|
+
"lockers_url": "/api/store/integrations/boxnow/lockers" // only when lockers
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
// null unless the cod integration is enabled AND fee_amount > 0 — the
|
|
43
|
+
// exact gate the totals engine applies, so the storefront's optimistic
|
|
44
|
+
// fee row can never disagree with the charged total. EUR only.
|
|
45
|
+
"cod": {
|
|
46
|
+
"enabled": true,
|
|
47
|
+
"fee_amount": 3.5, // EUR major units
|
|
48
|
+
"fee_currency": "eur",
|
|
49
|
+
"fee_label": "Cash on delivery fee",
|
|
50
|
+
"description": null // admin's checkout note, or null
|
|
51
|
+
},
|
|
52
|
+
// Public tag config — only ENABLED providers with a public id appear.
|
|
53
|
+
"tracking": {
|
|
54
|
+
"facebookPixel": { "pixelId": "123…" },
|
|
55
|
+
"gtm": { "containerId": "GTM-…" },
|
|
56
|
+
"ga4": { "measurementId": "G-…" },
|
|
57
|
+
"klaviyo": { "publicKey": "…" },
|
|
58
|
+
"googleAds": { "conversionId": "AW-…", "conversionLabel": "…" },
|
|
59
|
+
"consent_required": true // always present — mirrors the consent CMP's enabled
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
- **Errors**: `400 missing_client_id` · `400 invalid_publishable_key`.
|
|
65
|
+
- **SDK**: `integrations.getIntegrationsConfig(client)`
|
|
66
|
+
- **Components**: carrier/locker pickers, COD fee row in checkout, tracking
|
|
67
|
+
mounts (`<MetaPixel>/<GA4>/<Gtm>` behind the consent gate).
|
|
68
|
+
- **Settings**: admin → Settings → Integrations (per-provider enable +
|
|
69
|
+
config); consent settings drive `tracking.consent_required`; Rybbit is
|
|
70
|
+
deliberately ABSENT (platform analytics, not a tenant integration).
|
|
71
|
+
|
|
72
|
+
Tracking wiring contract: mount tags only through the consent gate when
|
|
73
|
+
`consent_required` (see [consent.md](consent.md)); Purchase events MUST use
|
|
74
|
+
`eventID = "purchase_" + order.display_id` so Meta dedupes browser Pixel vs
|
|
75
|
+
server CAPI; write TrackingAttribution keys into `cart.metadata`
|
|
76
|
+
(consent-gated) so server events inherit fbp/fbc/anon-id/ga signals.
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
BODY=$(curl -sf "$BASE/api/store/integrations" -H "x-client-id: $CLIENT_ID")
|
|
80
|
+
echo "$BODY" | grep -q '"carriers"'
|
|
81
|
+
echo "$BODY" | grep -q '"tracking"'
|
|
82
|
+
echo "$BODY" | grep -q '"consent_required"'
|
|
83
|
+
# THE ALLOWLIST RULE, asserted negatively: no secret key names, ever.
|
|
84
|
+
if echo "$BODY" | grep -qE 'access_token|api_secret|private_key|client_secret|credentials'; then
|
|
85
|
+
echo "SECRET LEAK in /api/store/integrations payload"; exit 1
|
|
86
|
+
fi
|
|
87
|
+
# A valid publishable key passes validation (B2B channel-scoped dev key).
|
|
88
|
+
curl -sf "$BASE/api/store/integrations" -H "x-client-id: $CLIENT_ID" \
|
|
89
|
+
-H "x-publishable-api-key: $PUBLISHABLE_KEY" | grep -q '"tracking"'
|
|
90
|
+
# A bogus key → 400 invalid_publishable_key.
|
|
91
|
+
STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/store/integrations" \
|
|
92
|
+
-H "x-client-id: $CLIENT_ID" -H "x-publishable-api-key: pk_doc_bogus_$RUN")
|
|
93
|
+
test "$STATUS" = 400
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## GET /api/store/integrations/boxnow/lockers — locker directory
|
|
97
|
+
|
|
98
|
+
- **Purpose**: the BoxNow APM directory for the checkout locker picker.
|
|
99
|
+
Discover availability via `carriers.boxnow.lockers_url` on the config
|
|
100
|
+
above — don't probe for the 503.
|
|
101
|
+
- **Auth**: anon (`x-client-id`).
|
|
102
|
+
- **Response**:
|
|
103
|
+
|
|
104
|
+
```jsonc
|
|
105
|
+
{
|
|
106
|
+
"lockers": [
|
|
107
|
+
{
|
|
108
|
+
"id": "42",
|
|
109
|
+
"title": "BoxNow Sofia Center",
|
|
110
|
+
"addressLine1": "bul. Vitosha 1",
|
|
111
|
+
"addressLine2": "",
|
|
112
|
+
"postalCode": "1000",
|
|
113
|
+
"country": "BG",
|
|
114
|
+
"lat": 42.6977, // number or null — malformed carrier data → null, never NaN
|
|
115
|
+
"lng": 23.3219,
|
|
116
|
+
"note": ""
|
|
117
|
+
}
|
|
118
|
+
]
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
- **Errors**: `503 {message, lockers: []}` (BoxNow not configured/enabled
|
|
123
|
+
for the store) · `502 {message, lockers: []}` (carrier call failed).
|
|
124
|
+
Every state carries the `lockers` key — a picker can always map over it.
|
|
125
|
+
- **Caching**: in-process 10-min TTL per store +
|
|
126
|
+
`Cache-Control: public, max-age=600, stale-while-revalidate=3600`.
|
|
127
|
+
- **SDK**: `integrations.listBoxNowLockers(client)`
|
|
128
|
+
- **Components**: checkout locker picker (carrier picker family).
|
|
129
|
+
- **Settings**: BoxNow credentials + enable in admin → Settings →
|
|
130
|
+
Integrations.
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
# The always-true contract: whatever the configuration state (200 with a
|
|
134
|
+
# directory, 503 unconfigured, 502 upstream), the payload carries `lockers`.
|
|
135
|
+
curl -s "$BASE/api/store/integrations/boxnow/lockers" -H "x-client-id: $CLIENT_ID" \
|
|
136
|
+
| grep -q '"lockers"'
|
|
137
|
+
```
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Menus — backend-owned navigation
|
|
2
|
+
|
|
3
|
+
Shopify Storefront `Menu` shape **verbatim** (content-navigation card).
|
|
4
|
+
Items store a typed resource reference; `url` is **computed at read time**
|
|
5
|
+
from the live handle (`src/lib/content/paths.ts`) — a product/page handle
|
|
6
|
+
rename never breaks a menu. Items whose referenced resource is deleted or
|
|
7
|
+
not storefront-visible (draft page/post/product, inactive or internal
|
|
8
|
+
category) are **skipped, subtree included** — the payload only ever contains
|
|
9
|
+
renderable links. Nesting is 3 levels max.
|
|
10
|
+
|
|
11
|
+
## GET /api/store/menus/:handle — one menu
|
|
12
|
+
|
|
13
|
+
- **Purpose**: render the storefront navigation (header `main-menu`, footer
|
|
14
|
+
menus — handles are merchant-chosen in admin → Content → Navigation).
|
|
15
|
+
- **Auth**: anon (`x-client-id`).
|
|
16
|
+
- **Request**: path handle only.
|
|
17
|
+
- **Response**:
|
|
18
|
+
|
|
19
|
+
```jsonc
|
|
20
|
+
{
|
|
21
|
+
"menu": {
|
|
22
|
+
"handle": "main-menu",
|
|
23
|
+
"title": "Main menu",
|
|
24
|
+
"items": [
|
|
25
|
+
{
|
|
26
|
+
"title": "Shop",
|
|
27
|
+
"type": "collection", // frontpage|collection|product|category|page|blog_post|external
|
|
28
|
+
"url": "/collections/all", // computed at read time; "/" for frontpage; verbatim for external
|
|
29
|
+
"resourceId": "pcol_<hex>", // null for frontpage/external
|
|
30
|
+
"items": [ /* same shape, ≤ 3 levels total */ ]
|
|
31
|
+
}
|
|
32
|
+
]
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
- **Errors**: `404 {code: "not_found"}` — unknown OR deleted handle. A
|
|
38
|
+
store with zero menus 404s every handle; the storefront must handle this
|
|
39
|
+
gracefully (render no nav) — the API never 500s for it.
|
|
40
|
+
- **SDK**: `menus.getMenu(client, handle)`
|
|
41
|
+
- **Components**: header navigation, footer link lists, mobile drawer.
|
|
42
|
+
- **Settings**: menus are authored in admin → Content → Navigation;
|
|
43
|
+
deleting a referenced resource silently drops its item here.
|
|
44
|
+
|
|
45
|
+
### ETag / caching semantics
|
|
46
|
+
|
|
47
|
+
- Every 200 carries `ETag` (sha1 of the payload) and
|
|
48
|
+
`Cache-Control: public, s-maxage=60, stale-while-revalidate=3600`.
|
|
49
|
+
- A request with `If-None-Match: <etag>` answers **304** with an empty body
|
|
50
|
+
when the menu is unchanged. Browsers do this transparently. Next.js RSC
|
|
51
|
+
callers: `menus.getMenu(client, handle)` takes no per-call
|
|
52
|
+
`RequestOptions` (none of the SDK domain functions forward them yet) —
|
|
53
|
+
cache at the route level (`export const revalidate = 60`) or supply a
|
|
54
|
+
caching `fetch` in `StorefrontClientConfig.fetch` instead of hand-rolling
|
|
55
|
+
ETag replay.
|
|
56
|
+
- The `menu.updated` domain event fires on every menu mutation — the future
|
|
57
|
+
cache-revalidation hook.
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
# Unknown handle → the clean 404 contract with the stable code (the state
|
|
61
|
+
# every storefront must survive: a deleted menu it still references).
|
|
62
|
+
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
|
|
63
|
+
"$BASE/api/store/menus/doc-no-such-menu-$RUN" -H "x-client-id: $CLIENT_ID")
|
|
64
|
+
test "$STATUS" = 404
|
|
65
|
+
curl -s "$BASE/api/store/menus/doc-no-such-menu-$RUN" -H "x-client-id: $CLIENT_ID" \
|
|
66
|
+
| grep -q '"code":"not_found"'
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
> Menus are admin-authored and the shared dev tenant seeds none, so the
|
|
70
|
+
> happy path (payload shape, ETag → 304 round-trip) is pinned executably by
|
|
71
|
+
> `tests/store/menus-store.test.ts` and the SDK contract test
|
|
72
|
+
> `tests/contract/sdk-content.contract.test.ts`, which create a menu with
|
|
73
|
+
> admin auth.
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# Metaobjects — merchant-defined content types
|
|
2
|
+
|
|
3
|
+
Merchant-defined structured content (size charts, brand profiles, FAQ
|
|
4
|
+
blocks) served by TYPE + HANDLE (metaobjects card). **ACTIVE entries only —
|
|
5
|
+
drafts 404** (lib filter AND anon RLS, defense in depth).
|
|
6
|
+
|
|
7
|
+
Capabilities per definition:
|
|
8
|
+
- `renderable` → payload gains `seo {title, description}` (title falls back
|
|
9
|
+
to `displayName`, description to null);
|
|
10
|
+
- `online_store` → payload gains `url` (`/metaobjects/<type>/<handle>` —
|
|
11
|
+
the storefront prepends its origin).
|
|
12
|
+
|
|
13
|
+
References resolve at **read** time: a target the storefront cannot see
|
|
14
|
+
(deleted, or draft under anon RLS) yields `reference: null` — never
|
|
15
|
+
dropped, never a 500. `by-metafield` is a **reserved type slug** (the
|
|
16
|
+
static route shadows `/:type`; rejected at definition create).
|
|
17
|
+
|
|
18
|
+
Metaobject shape (all endpoints):
|
|
19
|
+
|
|
20
|
+
```jsonc
|
|
21
|
+
{
|
|
22
|
+
"id": "uuid",
|
|
23
|
+
"type": "size-chart",
|
|
24
|
+
"handle": "shirts-eu",
|
|
25
|
+
"displayName": "Shirts (EU)",
|
|
26
|
+
"fields": [
|
|
27
|
+
{ "key": "body", "kind": "rich_text", "value": "<table>…</table>" },
|
|
28
|
+
{ "key": "product", "kind": "product_reference", "value": "prod_…",
|
|
29
|
+
"reference": { "id": "prod_…", "title": "Linen Shirt", "handle": "linen-shirt" } }
|
|
30
|
+
],
|
|
31
|
+
"seo": { "title": "Shirts (EU)", "description": null }, // renderable only
|
|
32
|
+
"url": "/metaobjects/size-chart/shirts-eu", // online_store only
|
|
33
|
+
"updatedAt": "ISO-8601"
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
> Definitions + entries are admin-authored; the shared dev tenant seeds
|
|
38
|
+
> none, so the executable blocks prove the 404/400 contracts and the happy
|
|
39
|
+
> paths are pinned by `tests/store/metaobjects-store.test.ts` (admin-auth
|
|
40
|
+
> fixtures).
|
|
41
|
+
|
|
42
|
+
## GET /api/store/metaobjects/:type — list entries
|
|
43
|
+
|
|
44
|
+
- **Purpose**: list ACTIVE entries of one type (e.g. all FAQ blocks),
|
|
45
|
+
newest-updated first.
|
|
46
|
+
- **Auth**: anon (`x-client-id`).
|
|
47
|
+
- **Request**: query `{limit?, offset?}` — `limit` clamped to 1–100
|
|
48
|
+
(default 20; out-of-range values are clamped, not rejected).
|
|
49
|
+
- **Response**: `{metaobjects: [Metaobject…], count, offset, limit}` — list
|
|
50
|
+
payloads carry **raw** field values (`reference` NOT resolved; the
|
|
51
|
+
by-handle read is the resolve hot path). `seo`/`url` capabilities still
|
|
52
|
+
apply.
|
|
53
|
+
- **Errors**: `404 not_found` (unknown type).
|
|
54
|
+
- **SDK**: `metaobjects.listMetaobjects(client, type, query?)`
|
|
55
|
+
- **Components**: FAQ/content-block section renderers.
|
|
56
|
+
- **Settings**: definitions + entry status in admin → Content → Metaobjects.
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
|
|
60
|
+
"$BASE/api/store/metaobjects/doc-no-such-type-$RUN" -H "x-client-id: $CLIENT_ID")
|
|
61
|
+
test "$STATUS" = 404
|
|
62
|
+
curl -s "$BASE/api/store/metaobjects/doc-no-such-type-$RUN" -H "x-client-id: $CLIENT_ID" \
|
|
63
|
+
| grep -q '"code":"not_found"'
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## GET /api/store/metaobjects/:type/:handle — one entry
|
|
67
|
+
|
|
68
|
+
- **Purpose**: render one entry — references RESOLVED (invisible targets →
|
|
69
|
+
`reference: null`).
|
|
70
|
+
- **Auth**: anon (`x-client-id`).
|
|
71
|
+
- **Response**: `{metaobject: Metaobject}` (shape above, references
|
|
72
|
+
resolved).
|
|
73
|
+
- **Errors**: `404 not_found` — unknown type, unknown handle, or a DRAFT
|
|
74
|
+
entry (drafts are indistinguishable from missing, by design).
|
|
75
|
+
- **SDK**: `metaobjects.getMetaobject(client, type, handle)`
|
|
76
|
+
- **Components**: size-chart modal, brand-profile block.
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
|
|
80
|
+
"$BASE/api/store/metaobjects/doc-no-such-type-$RUN/doc-h-$RUN" \
|
|
81
|
+
-H "x-client-id: $CLIENT_ID")
|
|
82
|
+
test "$STATUS" = 404
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## GET /api/store/metaobjects/by-metafield — the product→chart chain
|
|
86
|
+
|
|
87
|
+
- **Purpose**: the canonical fetch chain — resolve an entity's
|
|
88
|
+
`metaobject_reference` METAFIELD into full metaobject payload(s) in ONE
|
|
89
|
+
call: storefront reads the product (id in hand) → this call with the
|
|
90
|
+
metafield key → the resolved chart.
|
|
91
|
+
- **Auth**: anon (`x-client-id`). Leak boundary (deliberate): only
|
|
92
|
+
`metaobject_reference` definitions resolve here — any other metafield key
|
|
93
|
+
404s, so no other metafield value can exit through this route; entries
|
|
94
|
+
return ACTIVE-only.
|
|
95
|
+
- **Request**: query `{entity_type, entity_id, key}` — all three required.
|
|
96
|
+
- **Response**: `metaobjects` always present, in stored order. `metaobject`
|
|
97
|
+
(first entry or null) is present **only for single-valued definitions** —
|
|
98
|
+
LIST definitions return `{metaobjects}` alone:
|
|
99
|
+
|
|
100
|
+
```jsonc
|
|
101
|
+
// single-valued definition
|
|
102
|
+
{ "metaobject": { /* Metaobject */ }, "metaobjects": [ /* [it] */ ] }
|
|
103
|
+
// list definition
|
|
104
|
+
{ "metaobjects": [ /* Metaobject[] in stored order */ ] }
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
- **Errors**: `400 invalid_data` (missing entity_type/entity_id/key) ·
|
|
108
|
+
`404 not_found` (no such definition, wrong field_type, or no stored
|
|
109
|
+
value for the entity).
|
|
110
|
+
- **SDK**: `metaobjects.getMetaobjectsByMetafield(client, query)`
|
|
111
|
+
- **Components**: PDP size-chart trigger; any metafield-driven block.
|
|
112
|
+
- **Settings**: the metafield definition (entity type + key +
|
|
113
|
+
`metaobject_reference` field type) in admin → Settings → Metafields.
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
# Missing params → 400 invalid_data.
|
|
117
|
+
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
|
|
118
|
+
"$BASE/api/store/metaobjects/by-metafield?entity_type=product" \
|
|
119
|
+
-H "x-client-id: $CLIENT_ID")
|
|
120
|
+
test "$STATUS" = 400
|
|
121
|
+
# Unknown key on a real seeded product → 404 (the leak-boundary contract).
|
|
122
|
+
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
|
|
123
|
+
"$BASE/api/store/metaobjects/by-metafield?entity_type=product&entity_id=prod_01tst00000000000000000001&key=doc-nokey-$RUN" \
|
|
124
|
+
-H "x-client-id: $CLIENT_ID")
|
|
125
|
+
test "$STATUS" = 404
|
|
126
|
+
```
|