@asteby/storefront 0.0.0-stage → 0.2.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 +201 -0
- package/README.md +164 -2
- package/dist/index.cjs +326 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +261 -0
- package/dist/index.d.ts +261 -0
- package/dist/index.js +314 -0
- package/dist/index.js.map +1 -0
- package/dist/mock.cjs +998 -0
- package/dist/mock.cjs.map +1 -0
- package/dist/mock.d.cts +95 -0
- package/dist/mock.d.ts +95 -0
- package/dist/mock.js +993 -0
- package/dist/mock.js.map +1 -0
- package/dist/types-CTXB8Dnl.d.cts +465 -0
- package/dist/types-CTXB8Dnl.d.ts +465 -0
- package/package.json +59 -3
package/dist/mock.cjs
ADDED
|
@@ -0,0 +1,998 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/mock/seed.ts
|
|
4
|
+
var MOCK_IMAGE_URL = (seed, width = 800, height = 800) => `https://picsum.photos/seed/${encodeURIComponent(seed)}/${width}/${height}`;
|
|
5
|
+
var MOCK_CURRENCY = "MXN";
|
|
6
|
+
var MOCK_CUSTOMER_EMAIL = "cliente@example.com";
|
|
7
|
+
var OPT = {
|
|
8
|
+
size: (values = ["S", "M", "L"]) => ({ name: "Talla", values }),
|
|
9
|
+
color: (values) => ({ name: "Color", values })
|
|
10
|
+
};
|
|
11
|
+
var COLLECTION_SPECS = [
|
|
12
|
+
{ handle: "ropa", title: "Ropa", description: "Prendas b\xE1sicas para todos los d\xEDas." },
|
|
13
|
+
{ handle: "hogar", title: "Hogar", description: "Objetos \xFAtiles y bonitos para tu casa." },
|
|
14
|
+
{ handle: "accesorios", title: "Accesorios", description: "Complementos para llevar contigo." },
|
|
15
|
+
{ handle: "llantas", title: "Llantas", description: "Llantas para auto por medida." }
|
|
16
|
+
];
|
|
17
|
+
var tire = (width, aspect, rim) => ({
|
|
18
|
+
width,
|
|
19
|
+
aspect,
|
|
20
|
+
rim,
|
|
21
|
+
size_key: `${width}/${aspect}R${rim}`
|
|
22
|
+
});
|
|
23
|
+
var PRODUCT_SPECS = [
|
|
24
|
+
// Ropa
|
|
25
|
+
{ handle: "playera-basica", title: "Playera b\xE1sica", collection: "ropa", type: "Playera", vendor: "Casa Textil", price: 249, compareAt: 299, options: [OPT.size(), OPT.color(["Blanco", "Negro"])], tags: ["algod\xF3n", "nuevo"], stock: (i) => i === 5 ? 0 : 15, description: "Playera de algod\xF3n peinado, corte recto." },
|
|
26
|
+
{ handle: "sudadera-capucha", title: "Sudadera con capucha", collection: "ropa", type: "Sudadera", vendor: "Casa Textil", price: 699, options: [OPT.size(["S", "M", "L", "XL"]), OPT.color(["Gris", "Azul"])], priceDelta: { XL: 50 }, tags: ["invierno"], description: "Sudadera afelpada con bolsa canguro." },
|
|
27
|
+
{ handle: "pantalon-chino", title: "Pantal\xF3n chino", collection: "ropa", type: "Pantal\xF3n", vendor: "Taller Urbano", price: 899, options: [OPT.size(["28", "30", "32", "34"])], stock: (i) => i === 0 ? 0 : 6, description: "Pantal\xF3n de gabardina con un toque de elasticidad." },
|
|
28
|
+
{ handle: "chamarra-ligera", title: "Chamarra ligera", collection: "ropa", type: "Chamarra", vendor: "Taller Urbano", price: 1299, compareAt: 1599, options: [OPT.size()], tags: ["oferta"], description: "Chamarra rompevientos plegable." },
|
|
29
|
+
{ handle: "gorra-clasica", title: "Gorra cl\xE1sica", collection: "ropa", type: "Gorra", vendor: "Casa Textil", price: 199, options: [OPT.color(["Negro", "Beige", "Verde"])], description: "Gorra de seis paneles con ajuste trasero." },
|
|
30
|
+
{ handle: "calcetines-paquete", title: "Calcetines (paquete de 3)", collection: "ropa", type: "Calcetines", vendor: "Casa Textil", price: 149, stock: 0, description: "Tres pares de calcetines de algod\xF3n." },
|
|
31
|
+
// Hogar
|
|
32
|
+
{ handle: "taza-ceramica", title: "Taza de cer\xE1mica", collection: "hogar", type: "Cocina", vendor: "Barro Vivo", price: 129, options: [OPT.color(["Blanco", "Terracota"])], tags: ["regalo"], description: "Taza de 350 ml hecha a mano." },
|
|
33
|
+
{ handle: "lampara-mesa", title: "L\xE1mpara de mesa", collection: "hogar", type: "Iluminaci\xF3n", vendor: "Luz Serena", price: 1199, description: "L\xE1mpara con pantalla de lino y base de madera." },
|
|
34
|
+
{ handle: "cojin-decorativo", title: "Coj\xEDn decorativo", collection: "hogar", type: "Textil", vendor: "Casa Textil", price: 349, compareAt: 399, options: [OPT.color(["Mostaza", "Verde", "Gris"])], description: "Coj\xEDn de 45 \xD7 45 cm con funda removible." },
|
|
35
|
+
{ handle: "set-sartenes", title: "Set de sartenes", collection: "hogar", type: "Cocina", vendor: "Cocina Diaria", price: 1899, compareAt: 2299, tags: ["oferta"], description: "Tres sartenes antiadherentes de 20, 24 y 28 cm." },
|
|
36
|
+
{ handle: "manta-tejida", title: "Manta tejida", collection: "hogar", type: "Textil", vendor: "Casa Textil", price: 799, description: "Manta de punto grueso, 130 \xD7 170 cm." },
|
|
37
|
+
{ handle: "maceta-barro", title: "Maceta de barro", collection: "hogar", type: "Jard\xEDn", vendor: "Barro Vivo", price: 189, options: [{ name: "Tama\xF1o", values: ["Chica", "Mediana", "Grande"] }], priceDelta: { Mediana: 60, Grande: 140 }, description: "Maceta de barro natural con plato." },
|
|
38
|
+
// Accesorios
|
|
39
|
+
{ handle: "mochila-diaria", title: "Mochila diaria", collection: "accesorios", type: "Mochila", vendor: "Ruta Libre", price: 999, options: [OPT.color(["Negro", "Azul"])], tags: ["nuevo"], description: "Mochila de 20 L con compartimento para laptop." },
|
|
40
|
+
{ handle: "botella-termica", title: "Botella t\xE9rmica", collection: "accesorios", type: "Botella", vendor: "Ruta Libre", price: 399, options: [{ name: "Capacidad", values: ["500 ml", "750 ml"] }], priceDelta: { "750 ml": 80 }, description: "Mantiene fr\xEDo 24 h y caliente 12 h." },
|
|
41
|
+
{ handle: "lentes-sol", title: "Lentes de sol", collection: "accesorios", type: "Lentes", vendor: "Vista Clara", price: 599, compareAt: 749, description: "Lentes polarizados con protecci\xF3n UV400." },
|
|
42
|
+
{ handle: "reloj-analogico", title: "Reloj anal\xF3gico", collection: "accesorios", type: "Reloj", vendor: "Vista Clara", price: 1499, description: "Reloj con correa de piel y cristal mineral." },
|
|
43
|
+
{ handle: "cartera-piel", title: "Cartera de piel", collection: "accesorios", type: "Cartera", vendor: "Taller Urbano", price: 549, options: [OPT.color(["Caf\xE9", "Negro"])], description: "Cartera delgada con seis ranuras." },
|
|
44
|
+
{ handle: "paraguas-compacto", title: "Paraguas compacto", collection: "accesorios", type: "Paraguas", vendor: "Ruta Libre", price: 299, stock: 3, description: "Paraguas plegable resistente al viento." },
|
|
45
|
+
// Llantas (exercise attribute facets)
|
|
46
|
+
{ handle: "llanta-205-55-r16-touring", title: "Llanta 205/55 R16 Touring", collection: "llantas", type: "Llanta", vendor: "Marca Norte", price: 1899, compareAt: 2099, attributes: { ...tire(205, 55, 16), load_index: 91, speed_rating: "V", runflat: false }, tags: ["auto"], description: "Llanta de turismo silenciosa y de bajo consumo." },
|
|
47
|
+
{ handle: "llanta-205-55-r16-sport", title: "Llanta 205/55 R16 Sport", collection: "llantas", type: "Llanta", vendor: "Marca Sur", price: 2199, attributes: { ...tire(205, 55, 16), load_index: 94, speed_rating: "W", runflat: false }, tags: ["auto"], description: "Agarre deportivo en seco y mojado." },
|
|
48
|
+
{ handle: "llanta-195-65-r15-eco", title: "Llanta 195/65 R15 Eco", collection: "llantas", type: "Llanta", vendor: "Marca Norte", price: 1499, attributes: { ...tire(195, 65, 15), load_index: 91, speed_rating: "H", runflat: false }, tags: ["auto"], description: "Baja resistencia al rodamiento." },
|
|
49
|
+
{ handle: "llanta-225-45-r17-runflat", title: "Llanta 225/45 R17 Run Flat", collection: "llantas", type: "Llanta", vendor: "Marca Centro", price: 3299, attributes: { ...tire(225, 45, 17), load_index: 91, speed_rating: "W", runflat: true }, tags: ["auto"], stock: 2, description: "Permite rodar hasta 80 km sin presi\xF3n." },
|
|
50
|
+
{ handle: "llanta-265-70-r16-at", title: "Llanta 265/70 R16 All Terrain", collection: "llantas", type: "Llanta", vendor: "Marca Sur", price: 3599, attributes: { ...tire(265, 70, 16), load_index: 112, speed_rating: "T", runflat: false }, tags: ["camioneta"], description: "Para camioneta, asfalto y terracer\xEDa." },
|
|
51
|
+
{ handle: "llanta-175-70-r13-city", title: "Llanta 175/70 R13 City", collection: "llantas", type: "Llanta", vendor: "Marca Centro", price: 999, attributes: { ...tire(175, 70, 13), load_index: 82, speed_rating: "T", runflat: false }, tags: ["auto"], stock: 0, description: "Econ\xF3mica y durable para ciudad." }
|
|
52
|
+
];
|
|
53
|
+
var money = (n, currency) => ({ amount: n.toFixed(2), currency_code: currency });
|
|
54
|
+
function cartesian(options) {
|
|
55
|
+
let combos = [{}];
|
|
56
|
+
for (const opt of options) {
|
|
57
|
+
const next = [];
|
|
58
|
+
for (const c of combos) for (const v of opt.values) next.push({ ...c, [opt.name]: v });
|
|
59
|
+
combos = next;
|
|
60
|
+
}
|
|
61
|
+
return combos;
|
|
62
|
+
}
|
|
63
|
+
var BASE_DATE = Date.UTC(2026, 8, 30, 12, 0, 0);
|
|
64
|
+
function image(url, seed, alt) {
|
|
65
|
+
return { url: url(seed), alt, width: 800, height: 800 };
|
|
66
|
+
}
|
|
67
|
+
function buildProducts(currency, imageUrl = MOCK_IMAGE_URL) {
|
|
68
|
+
return PRODUCT_SPECS.map((spec, pi) => {
|
|
69
|
+
const options = spec.options ?? [];
|
|
70
|
+
const combos = options.length ? cartesian(options) : [{}];
|
|
71
|
+
const images = [image(imageUrl, spec.handle, spec.title), image(imageUrl, `${spec.handle}-2`, `${spec.title}, detalle`)];
|
|
72
|
+
const variants = combos.map((opts, vi) => {
|
|
73
|
+
const delta = Object.values(opts).reduce((sum, v) => sum + (spec.priceDelta?.[v] ?? 0), 0);
|
|
74
|
+
const price = spec.price + delta;
|
|
75
|
+
const stock = typeof spec.stock === "function" ? spec.stock(vi, opts) : spec.stock ?? 12;
|
|
76
|
+
const title = Object.values(opts).join(" / ") || "Predeterminado";
|
|
77
|
+
const firstValue = Object.values(opts)[0];
|
|
78
|
+
return {
|
|
79
|
+
id: `var_${pi + 1}_${vi + 1}`,
|
|
80
|
+
sku: `${spec.handle.toUpperCase().replace(/-/g, "")}-${vi + 1}`.slice(0, 32),
|
|
81
|
+
title,
|
|
82
|
+
selected_options: Object.entries(opts).map(([name, value]) => ({ name, value })),
|
|
83
|
+
price: money(price, currency),
|
|
84
|
+
compare_at_price: spec.compareAt ? money(spec.compareAt + delta, currency) : null,
|
|
85
|
+
available_for_sale: stock > 0,
|
|
86
|
+
quantity_available: stock,
|
|
87
|
+
image: firstValue ? image(imageUrl, `${spec.handle}-${firstValue}`, `${spec.title} ${firstValue}`) : null,
|
|
88
|
+
barcode: `75000000${String(pi * 100 + vi).padStart(5, "0")}`
|
|
89
|
+
};
|
|
90
|
+
});
|
|
91
|
+
const prices = variants.map((v) => Number(v.price.amount));
|
|
92
|
+
const compares = variants.map((v) => v.compare_at_price ? Number(v.compare_at_price.amount) : null).filter((n) => n !== null);
|
|
93
|
+
const collection = COLLECTION_SPECS.find((c) => c.handle === spec.collection);
|
|
94
|
+
return {
|
|
95
|
+
id: `prod_${pi + 1}`,
|
|
96
|
+
handle: spec.handle,
|
|
97
|
+
title: spec.title,
|
|
98
|
+
description: spec.description,
|
|
99
|
+
description_html: `<p>${spec.description}</p>`,
|
|
100
|
+
vendor: spec.vendor,
|
|
101
|
+
product_type: spec.type,
|
|
102
|
+
category: { id: `cat_${spec.collection}`, handle: collection.handle, title: collection.title },
|
|
103
|
+
tags: spec.tags ?? [],
|
|
104
|
+
attributes: spec.attributes ?? {},
|
|
105
|
+
featured_image: images[0] ?? null,
|
|
106
|
+
images,
|
|
107
|
+
options,
|
|
108
|
+
variants,
|
|
109
|
+
price_range: { min: money(Math.min(...prices), currency), max: money(Math.max(...prices), currency) },
|
|
110
|
+
compare_at_price_range: compares.length ? { min: money(Math.min(...compares), currency), max: money(Math.max(...compares), currency) } : null,
|
|
111
|
+
available_for_sale: variants.some((v) => v.available_for_sale),
|
|
112
|
+
seo: { title: `${spec.title} | Mi tienda`, description: spec.description },
|
|
113
|
+
updated_at: new Date(BASE_DATE - pi * 864e5).toISOString()
|
|
114
|
+
};
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
function buildMemberships() {
|
|
118
|
+
const out = {};
|
|
119
|
+
for (const p of PRODUCT_SPECS) out[p.handle] = [p.collection];
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
function buildCollections(imageUrl = MOCK_IMAGE_URL) {
|
|
123
|
+
return COLLECTION_SPECS.map((c) => ({
|
|
124
|
+
id: `col_${c.handle}`,
|
|
125
|
+
handle: c.handle,
|
|
126
|
+
title: c.title,
|
|
127
|
+
description: c.description,
|
|
128
|
+
image: image(imageUrl, `col-${c.handle}`, c.title),
|
|
129
|
+
seo: { title: `${c.title} | Mi tienda`, description: c.description },
|
|
130
|
+
parent_handle: c.parent ?? null
|
|
131
|
+
}));
|
|
132
|
+
}
|
|
133
|
+
function buildShop(currency, imageUrl = MOCK_IMAGE_URL) {
|
|
134
|
+
return {
|
|
135
|
+
name: "Mi tienda",
|
|
136
|
+
description: "Tienda de ejemplo para desarrollar sin backend.",
|
|
137
|
+
domain: "mi-tienda.example",
|
|
138
|
+
currency_code: currency,
|
|
139
|
+
locale: "es-MX",
|
|
140
|
+
locales: ["es-MX", "en-US"],
|
|
141
|
+
country_code: "MX",
|
|
142
|
+
logo: { url: imageUrl("logo", 256, 256), alt: "Mi tienda", width: 256, height: 256 },
|
|
143
|
+
contact: { email: "hola@mi-tienda.example", phone: "+52 55 0000 0000", whatsapp: "+5215500000000" },
|
|
144
|
+
branches: [
|
|
145
|
+
{ id: "br_centro", name: "Sucursal Centro", address: "Av. Principal 100, Col. Centro", city: "Ciudad de M\xE9xico", phone: "+52 55 0000 0001", lat: 19.4326, lng: -99.1332, hours: "Mo-Fr 09:00-19:00, Sa 09:00-14:00" },
|
|
146
|
+
{ id: "br_norte", name: "Sucursal Norte", address: "Calle Secundaria 25, Col. Norte", city: "Monterrey", phone: null, lat: 25.6866, lng: -100.3161, hours: "Mo-Sa 10:00-18:00" }
|
|
147
|
+
],
|
|
148
|
+
policies: { free_shipping_min: money(999, currency), returns_days: 30 }
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
function buildTheme() {
|
|
152
|
+
return {
|
|
153
|
+
tokens: {
|
|
154
|
+
background: "#ffffff",
|
|
155
|
+
foreground: "#111827",
|
|
156
|
+
primary: "#0f766e",
|
|
157
|
+
"primary-foreground": "#ffffff",
|
|
158
|
+
secondary: "#f1f5f9",
|
|
159
|
+
"secondary-foreground": "#0f172a",
|
|
160
|
+
muted: "#f8fafc",
|
|
161
|
+
"muted-foreground": "#64748b",
|
|
162
|
+
accent: "#f59e0b",
|
|
163
|
+
"accent-foreground": "#111827",
|
|
164
|
+
border: "#e2e8f0",
|
|
165
|
+
destructive: "#dc2626"
|
|
166
|
+
},
|
|
167
|
+
fonts: { heading: "Inter", body: "Inter" },
|
|
168
|
+
radius: "0.5rem"
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
function buildPages(imageUrl = MOCK_IMAGE_URL) {
|
|
172
|
+
return [
|
|
173
|
+
{
|
|
174
|
+
handle: "home",
|
|
175
|
+
title: "Inicio",
|
|
176
|
+
seo: { title: "Mi tienda", description: "Ropa, hogar, accesorios y llantas con env\xEDo a todo el pa\xEDs." },
|
|
177
|
+
sections: [
|
|
178
|
+
{ id: "hero-1", type: "hero", props: { title: "Todo lo que necesitas, en un solo lugar", subtitle: "Env\xEDo gratis desde $999", cta_label: "Ver productos", cta_url: "/collections/ropa", image: imageUrl("hero", 1600, 700) } },
|
|
179
|
+
{ id: "grid-1", type: "product_grid", props: { title: "Lo m\xE1s nuevo", collection: "ropa", limit: 8 } },
|
|
180
|
+
{ id: "collections-1", type: "collection_list", props: { title: "Compra por categor\xEDa", handles: ["ropa", "hogar", "accesorios", "llantas"] } },
|
|
181
|
+
{ id: "text-1", type: "rich_text", props: { title: "Sobre nosotros", html: "<p>Somos una tienda de ejemplo. Edita este texto desde el panel.</p>" } },
|
|
182
|
+
{
|
|
183
|
+
id: "faq-1",
|
|
184
|
+
type: "faq",
|
|
185
|
+
props: {
|
|
186
|
+
title: "Preguntas frecuentes",
|
|
187
|
+
items: [
|
|
188
|
+
{ question: "\xBFCu\xE1nto tarda el env\xEDo?", answer: "De 2 a 5 d\xEDas h\xE1biles." },
|
|
189
|
+
{ question: "\xBFPuedo recoger en tienda?", answer: "S\xED, elige recoger en sucursal al pagar." },
|
|
190
|
+
{ question: "\xBFC\xF3mo pido factura?", answer: "Desde tu cuenta, en el detalle del pedido." }
|
|
191
|
+
]
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
]
|
|
195
|
+
},
|
|
196
|
+
legal("terminos", "T\xE9rminos y condiciones", "Al comprar en Mi tienda aceptas estos t\xE9rminos."),
|
|
197
|
+
legal("privacidad", "Aviso de privacidad", "Tratamos tus datos solo para procesar tus pedidos."),
|
|
198
|
+
legal("devoluciones", "Pol\xEDtica de devoluciones", "Tienes 30 d\xEDas para devolver tu compra.")
|
|
199
|
+
];
|
|
200
|
+
}
|
|
201
|
+
function legal(handle, title, body) {
|
|
202
|
+
return {
|
|
203
|
+
handle,
|
|
204
|
+
title,
|
|
205
|
+
seo: { title: `${title} | Mi tienda`, description: body },
|
|
206
|
+
sections: [{ id: `${handle}-text`, type: "rich_text", props: { title, html: `<p>${body}</p>` } }]
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
function buildMenus() {
|
|
210
|
+
return [
|
|
211
|
+
{
|
|
212
|
+
handle: "main",
|
|
213
|
+
items: [
|
|
214
|
+
{ title: "Inicio", url: "/" },
|
|
215
|
+
{
|
|
216
|
+
title: "Tienda",
|
|
217
|
+
url: "/search",
|
|
218
|
+
items: COLLECTION_SPECS.map((c) => ({ title: c.title, url: `/collections/${c.handle}` }))
|
|
219
|
+
},
|
|
220
|
+
{ title: "Blog", url: "/blogs/noticias" }
|
|
221
|
+
]
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
handle: "footer",
|
|
225
|
+
items: [
|
|
226
|
+
{ title: "T\xE9rminos y condiciones", url: "/pages/terminos" },
|
|
227
|
+
{ title: "Aviso de privacidad", url: "/pages/privacidad" },
|
|
228
|
+
{ title: "Devoluciones", url: "/pages/devoluciones" }
|
|
229
|
+
]
|
|
230
|
+
}
|
|
231
|
+
];
|
|
232
|
+
}
|
|
233
|
+
function buildArticles(imageUrl = MOCK_IMAGE_URL) {
|
|
234
|
+
return [
|
|
235
|
+
{
|
|
236
|
+
id: "art_1",
|
|
237
|
+
handle: "como-elegir-llantas",
|
|
238
|
+
title: "C\xF3mo elegir las llantas correctas",
|
|
239
|
+
excerpt: "Lee la medida en el costado de tu llanta: ancho, perfil y rin.",
|
|
240
|
+
content_html: "<p>La medida 205/55 R16 indica 205 mm de ancho, perfil del 55 % y rin de 16 pulgadas.</p>",
|
|
241
|
+
image: image(imageUrl, "blog-llantas", "Llantas"),
|
|
242
|
+
author: "Equipo Mi tienda",
|
|
243
|
+
tags: ["llantas", "gu\xEDas"],
|
|
244
|
+
published_at: "2026-09-15T15:00:00.000Z",
|
|
245
|
+
seo: { title: "C\xF3mo elegir llantas | Mi tienda", description: "Gu\xEDa r\xE1pida para leer la medida de tus llantas." }
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
id: "art_2",
|
|
249
|
+
handle: "cuidado-prendas-algodon",
|
|
250
|
+
title: "Cuida tus prendas de algod\xF3n",
|
|
251
|
+
excerpt: "Tres consejos para que tus playeras duren m\xE1s.",
|
|
252
|
+
content_html: "<p>Lava en fr\xEDo, seca a la sombra y plancha al rev\xE9s.</p>",
|
|
253
|
+
image: image(imageUrl, "blog-algodon", "Prendas de algod\xF3n"),
|
|
254
|
+
author: "Equipo Mi tienda",
|
|
255
|
+
tags: ["ropa", "gu\xEDas"],
|
|
256
|
+
published_at: "2026-09-01T15:00:00.000Z",
|
|
257
|
+
seo: { title: "Cuida tus prendas | Mi tienda", description: "Consejos de lavado para prendas de algod\xF3n." }
|
|
258
|
+
}
|
|
259
|
+
];
|
|
260
|
+
}
|
|
261
|
+
function buildCustomers() {
|
|
262
|
+
return [
|
|
263
|
+
{
|
|
264
|
+
id: "cus_1",
|
|
265
|
+
email: MOCK_CUSTOMER_EMAIL,
|
|
266
|
+
phone: "+525500000002",
|
|
267
|
+
first_name: "Cliente",
|
|
268
|
+
last_name: "Demo",
|
|
269
|
+
default_address_id: null,
|
|
270
|
+
created_at: "2026-01-10T12:00:00.000Z"
|
|
271
|
+
}
|
|
272
|
+
];
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// src/mock/index.ts
|
|
276
|
+
var HttpError = class extends Error {
|
|
277
|
+
constructor(status, code, message, field) {
|
|
278
|
+
super(message);
|
|
279
|
+
this.status = status;
|
|
280
|
+
this.code = code;
|
|
281
|
+
this.field = field;
|
|
282
|
+
}
|
|
283
|
+
status;
|
|
284
|
+
code;
|
|
285
|
+
field;
|
|
286
|
+
};
|
|
287
|
+
var PUBLIC_CACHE = "public, max-age=60, stale-while-revalidate=600";
|
|
288
|
+
var PRIVATE_CACHE = "private, no-store";
|
|
289
|
+
var toCents = (amount) => Math.round(Number.parseFloat(amount) * 100);
|
|
290
|
+
var fromCents = (cents, currency) => ({ amount: (cents / 100).toFixed(2), currency_code: currency });
|
|
291
|
+
function fnv1a(input) {
|
|
292
|
+
let h = 2166136261;
|
|
293
|
+
for (let i = 0; i < input.length; i++) {
|
|
294
|
+
h ^= input.charCodeAt(i);
|
|
295
|
+
h = Math.imul(h, 16777619);
|
|
296
|
+
}
|
|
297
|
+
return (h >>> 0).toString(16);
|
|
298
|
+
}
|
|
299
|
+
function encodeCursor(offset) {
|
|
300
|
+
return btoa(`o:${offset}`);
|
|
301
|
+
}
|
|
302
|
+
function decodeCursor(cursor) {
|
|
303
|
+
if (!cursor) return 0;
|
|
304
|
+
try {
|
|
305
|
+
const raw = atob(cursor);
|
|
306
|
+
if (!raw.startsWith("o:")) throw new Error("bad cursor");
|
|
307
|
+
const n = Number(raw.slice(2));
|
|
308
|
+
if (!Number.isInteger(n) || n < 0) throw new Error("bad cursor");
|
|
309
|
+
return n;
|
|
310
|
+
} catch {
|
|
311
|
+
throw new HttpError(400, "validation", "Invalid cursor", "after");
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
function paginate(items, search, defaultFirst = 24, maxFirst = 100) {
|
|
315
|
+
const first = search.has("first") ? Number(search.get("first")) : defaultFirst;
|
|
316
|
+
if (!Number.isInteger(first) || first < 1 || first > maxFirst) throw new HttpError(400, "validation", `\`first\` must be between 1 and ${maxFirst}`, "first");
|
|
317
|
+
const start = decodeCursor(search.get("after"));
|
|
318
|
+
const nodes = items.slice(start, start + first);
|
|
319
|
+
const end = start + nodes.length;
|
|
320
|
+
return {
|
|
321
|
+
nodes,
|
|
322
|
+
page_info: { has_next_page: end < items.length, end_cursor: nodes.length ? encodeCursor(end) : null },
|
|
323
|
+
total_count: items.length
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
var now = () => (/* @__PURE__ */ new Date()).toISOString();
|
|
327
|
+
function createMockStorefront(options = {}) {
|
|
328
|
+
const currency = options.currency ?? MOCK_CURRENCY;
|
|
329
|
+
const imageUrl = options.imageUrl ?? MOCK_IMAGE_URL;
|
|
330
|
+
const otpCode = options.otpCode ?? "000000";
|
|
331
|
+
const discountCodes = Object.fromEntries(
|
|
332
|
+
Object.entries(options.discountCodes ?? { BIENVENIDA10: 10 }).map(([k, v]) => [k.toUpperCase(), v])
|
|
333
|
+
);
|
|
334
|
+
const flatShippingCents = Math.round((options.flatShipping ?? 149) * 100);
|
|
335
|
+
const enforceScopes = options.enforceScopes ?? true;
|
|
336
|
+
function seed() {
|
|
337
|
+
return {
|
|
338
|
+
shop: { ...buildShop(currency, imageUrl), ...options.shop },
|
|
339
|
+
products: structuredClone(options.products ?? buildProducts(currency, imageUrl)),
|
|
340
|
+
collections: structuredClone(options.collections ?? buildCollections(imageUrl)),
|
|
341
|
+
memberships: structuredClone(options.memberships ?? buildMemberships()),
|
|
342
|
+
theme: structuredClone(options.theme ?? buildTheme()),
|
|
343
|
+
pages: structuredClone(options.pages ?? buildPages(imageUrl)),
|
|
344
|
+
menus: structuredClone(options.menus ?? buildMenus()),
|
|
345
|
+
articles: structuredClone(options.articles ?? buildArticles(imageUrl)),
|
|
346
|
+
carts: /* @__PURE__ */ new Map(),
|
|
347
|
+
customers: buildCustomers(),
|
|
348
|
+
addresses: /* @__PURE__ */ new Map(),
|
|
349
|
+
orders: [
|
|
350
|
+
{
|
|
351
|
+
id: "ord_seed_1",
|
|
352
|
+
number: "1000",
|
|
353
|
+
status: "fulfilled",
|
|
354
|
+
created_at: "2026-08-20T18:00:00.000Z",
|
|
355
|
+
total: fromCents(24900, currency),
|
|
356
|
+
customer_id: "cus_1",
|
|
357
|
+
lines: [
|
|
358
|
+
{ title: "Playera b\xE1sica", variant_title: "M / Blanco", sku: null, quantity: 1, unit: fromCents(24900, currency), total: fromCents(24900, currency), image: null }
|
|
359
|
+
]
|
|
360
|
+
}
|
|
361
|
+
],
|
|
362
|
+
otps: /* @__PURE__ */ new Map(),
|
|
363
|
+
sessions: /* @__PURE__ */ new Map(),
|
|
364
|
+
idempotency: /* @__PURE__ */ new Map(),
|
|
365
|
+
seq: 0
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
let state = seed();
|
|
369
|
+
const nextId = (prefix) => `${prefix}_${(++state.seq).toString(36)}${Math.random().toString(36).slice(2, 8)}`;
|
|
370
|
+
function findVariant(variantId) {
|
|
371
|
+
for (const product of state.products) {
|
|
372
|
+
const variant = product.variants.find((v) => v.id === variantId);
|
|
373
|
+
if (variant) return { product, variant };
|
|
374
|
+
}
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
const minPriceCents = (p) => toCents(p.price_range.min.amount);
|
|
378
|
+
function attrValue(p, key) {
|
|
379
|
+
if (key === "vendor") return p.vendor;
|
|
380
|
+
if (key === "product_type") return p.product_type;
|
|
381
|
+
if (key === "tag") return null;
|
|
382
|
+
const v = p.attributes[key];
|
|
383
|
+
return v === void 0 ? null : String(v);
|
|
384
|
+
}
|
|
385
|
+
function matchesText(p, q) {
|
|
386
|
+
const terms = q.toLowerCase().split(/\s+/).filter(Boolean);
|
|
387
|
+
const hay = [p.title, p.description, p.vendor ?? "", p.product_type ?? "", ...p.tags, ...Object.values(p.attributes).map(String)].join(" ").toLowerCase();
|
|
388
|
+
let score = 0;
|
|
389
|
+
for (const t of terms) {
|
|
390
|
+
if (!hay.includes(t)) return 0;
|
|
391
|
+
score += p.title.toLowerCase().includes(t) ? 2 : 1;
|
|
392
|
+
}
|
|
393
|
+
return score;
|
|
394
|
+
}
|
|
395
|
+
function listProducts(search) {
|
|
396
|
+
let items = state.products.slice();
|
|
397
|
+
const q = search.get("q")?.trim();
|
|
398
|
+
const scores = /* @__PURE__ */ new Map();
|
|
399
|
+
if (q) {
|
|
400
|
+
items = items.filter((p) => {
|
|
401
|
+
const s = matchesText(p, q);
|
|
402
|
+
scores.set(p.id, s);
|
|
403
|
+
return s > 0;
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
const collection = search.get("collection");
|
|
407
|
+
if (collection) {
|
|
408
|
+
if (!state.collections.some((c) => c.handle === collection)) throw new HttpError(404, "not_found", `Collection ${collection} not found`, "collection");
|
|
409
|
+
items = items.filter((p) => state.memberships[p.handle]?.includes(collection));
|
|
410
|
+
}
|
|
411
|
+
const handles = search.get("handles");
|
|
412
|
+
if (handles) {
|
|
413
|
+
const list = handles.split(",").map((h) => h.trim()).filter(Boolean);
|
|
414
|
+
if (list.length > 100) throw new HttpError(400, "validation", "At most 100 handles", "handles");
|
|
415
|
+
items = items.filter((p) => list.includes(p.handle));
|
|
416
|
+
}
|
|
417
|
+
const base = items;
|
|
418
|
+
if (search.get("available") === "true") items = items.filter((p) => p.available_for_sale);
|
|
419
|
+
const priceMin = search.get("price_min");
|
|
420
|
+
const priceMax = search.get("price_max");
|
|
421
|
+
if (priceMin) items = items.filter((p) => minPriceCents(p) >= Math.round(Number(priceMin) * 100));
|
|
422
|
+
if (priceMax) items = items.filter((p) => minPriceCents(p) <= Math.round(Number(priceMax) * 100));
|
|
423
|
+
const filters = /* @__PURE__ */ new Map();
|
|
424
|
+
for (const [key, value] of search) {
|
|
425
|
+
if (!key.startsWith("filter.")) continue;
|
|
426
|
+
const name = key.slice(7);
|
|
427
|
+
filters.set(name, [...filters.get(name) ?? [], value]);
|
|
428
|
+
}
|
|
429
|
+
for (const [key, values] of filters) {
|
|
430
|
+
if (key === "tag") items = items.filter((p) => values.some((v) => p.tags.includes(v)));
|
|
431
|
+
else items = items.filter((p) => values.includes(attrValue(p, key) ?? "\0"));
|
|
432
|
+
}
|
|
433
|
+
const sort = search.get("sort") ?? "relevance";
|
|
434
|
+
const sorters = {
|
|
435
|
+
relevance: (a, b) => (scores.get(b.id) ?? 0) - (scores.get(a.id) ?? 0),
|
|
436
|
+
price_asc: (a, b) => minPriceCents(a) - minPriceCents(b),
|
|
437
|
+
price_desc: (a, b) => minPriceCents(b) - minPriceCents(a),
|
|
438
|
+
newest: (a, b) => b.updated_at.localeCompare(a.updated_at),
|
|
439
|
+
title_asc: (a, b) => a.title.localeCompare(b.title, "es")
|
|
440
|
+
};
|
|
441
|
+
const sorter = sorters[sort];
|
|
442
|
+
if (!sorter) throw new HttpError(400, "validation", `Unknown sort ${sort}`, "sort");
|
|
443
|
+
items = items.slice().sort(sorter);
|
|
444
|
+
return { ...paginate(items, search), facets: buildFacets(base) };
|
|
445
|
+
}
|
|
446
|
+
function buildFacets(products) {
|
|
447
|
+
const facets = [];
|
|
448
|
+
const listFacet = (key, label, pick) => {
|
|
449
|
+
const counts = /* @__PURE__ */ new Map();
|
|
450
|
+
for (const p of products) {
|
|
451
|
+
const v = pick(p);
|
|
452
|
+
if (v !== null && v !== "") counts.set(v, (counts.get(v) ?? 0) + 1);
|
|
453
|
+
}
|
|
454
|
+
if (counts.size === 0) return;
|
|
455
|
+
const values = [...counts.entries()].sort(([a], [b]) => a.localeCompare(b, "es", { numeric: true })).map(([value, count]) => ({ value, label: value, count }));
|
|
456
|
+
facets.push({ key, label, type: "list", values });
|
|
457
|
+
};
|
|
458
|
+
listFacet("vendor", "Marca", (p) => p.vendor);
|
|
459
|
+
listFacet("product_type", "Tipo", (p) => p.product_type);
|
|
460
|
+
const attrKeys = /* @__PURE__ */ new Set();
|
|
461
|
+
for (const p of products) for (const k of Object.keys(p.attributes)) attrKeys.add(k);
|
|
462
|
+
const labels = { width: "Ancho", aspect: "Perfil", rim: "Rin", size_key: "Medida", load_index: "\xCDndice de carga", speed_rating: "\xCDndice de velocidad", runflat: "Run flat" };
|
|
463
|
+
for (const k of [...attrKeys].sort()) listFacet(k, labels[k] ?? k, (p) => p.attributes[k] === void 0 ? null : String(p.attributes[k]));
|
|
464
|
+
if (products.length) {
|
|
465
|
+
const prices = products.map(minPriceCents);
|
|
466
|
+
const min = Math.min(...prices);
|
|
467
|
+
const max = Math.max(...prices);
|
|
468
|
+
facets.push({
|
|
469
|
+
key: "price",
|
|
470
|
+
label: "Precio",
|
|
471
|
+
type: "price_range",
|
|
472
|
+
values: [
|
|
473
|
+
{ value: (min / 100).toFixed(2), label: "min", count: products.length },
|
|
474
|
+
{ value: (max / 100).toFixed(2), label: "max", count: products.length }
|
|
475
|
+
]
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
return facets;
|
|
479
|
+
}
|
|
480
|
+
function getCartRecord(id) {
|
|
481
|
+
const cart = state.carts.get(id);
|
|
482
|
+
if (!cart) throw new HttpError(404, "not_found", `Cart ${id} not found`, "id");
|
|
483
|
+
return cart;
|
|
484
|
+
}
|
|
485
|
+
function validateLines(lines, cart) {
|
|
486
|
+
if (!Array.isArray(lines)) throw new HttpError(400, "validation", "`lines` must be an array", "lines");
|
|
487
|
+
return lines.map((raw, i) => {
|
|
488
|
+
const l = raw;
|
|
489
|
+
if (typeof l.variant_id !== "string") throw new HttpError(400, "validation", "variant_id is required", `lines[${i}].variant_id`);
|
|
490
|
+
const quantity = l.quantity === void 0 ? 1 : l.quantity;
|
|
491
|
+
if (typeof quantity !== "number" || !Number.isInteger(quantity) || quantity < 1) {
|
|
492
|
+
throw new HttpError(400, "validation", "quantity must be a positive integer", `lines[${i}].quantity`);
|
|
493
|
+
}
|
|
494
|
+
const found = findVariant(l.variant_id);
|
|
495
|
+
if (!found) throw new HttpError(400, "validation", `Unknown variant ${l.variant_id}`, `lines[${i}].variant_id`);
|
|
496
|
+
const already = cart?.lines.find((x) => x.variant_id === l.variant_id)?.quantity ?? 0;
|
|
497
|
+
checkStock(found.variant, already + quantity, `lines[${i}].quantity`);
|
|
498
|
+
return { variant_id: l.variant_id, quantity };
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
function checkStock(variant, quantity, field) {
|
|
502
|
+
if (!variant.available_for_sale || variant.quantity_available !== null && quantity > variant.quantity_available) {
|
|
503
|
+
throw new HttpError(409, "insufficient_stock", `Only ${variant.quantity_available ?? 0} available for ${variant.sku}`, field);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
function addLines(cart, lines) {
|
|
507
|
+
for (const l of lines) {
|
|
508
|
+
const existing = cart.lines.find((x) => x.variant_id === l.variant_id);
|
|
509
|
+
if (existing) existing.quantity += l.quantity;
|
|
510
|
+
else cart.lines.push({ id: nextId("line"), variant_id: l.variant_id, quantity: l.quantity });
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
const isKnownCode = (code) => code.toUpperCase() in discountCodes;
|
|
514
|
+
const percentFor = (code) => discountCodes[code.toUpperCase()] ?? 0;
|
|
515
|
+
function shippingRates(subtotalCents) {
|
|
516
|
+
const freeMin = state.shop.policies.free_shipping_min;
|
|
517
|
+
const free = freeMin !== null && subtotalCents >= toCents(freeMin.amount);
|
|
518
|
+
return [
|
|
519
|
+
{ id: "flat", title: free ? "Env\xEDo est\xE1ndar (gratis)" : "Env\xEDo est\xE1ndar", cost: fromCents(free ? 0 : flatShippingCents, currency), eta: "2 a 5 d\xEDas h\xE1biles" },
|
|
520
|
+
{ id: "express", title: "Env\xEDo expr\xE9s", cost: fromCents(flatShippingCents + 1e4, currency), eta: "1 a 2 d\xEDas h\xE1biles" }
|
|
521
|
+
];
|
|
522
|
+
}
|
|
523
|
+
function renderCart(cart) {
|
|
524
|
+
const lines = [];
|
|
525
|
+
let subtotal = 0;
|
|
526
|
+
let quantity = 0;
|
|
527
|
+
for (const l of cart.lines) {
|
|
528
|
+
const found = findVariant(l.variant_id);
|
|
529
|
+
if (!found) continue;
|
|
530
|
+
const { product, variant } = found;
|
|
531
|
+
const unit = toCents(variant.price.amount);
|
|
532
|
+
subtotal += unit * l.quantity;
|
|
533
|
+
quantity += l.quantity;
|
|
534
|
+
lines.push({
|
|
535
|
+
id: l.id,
|
|
536
|
+
quantity: l.quantity,
|
|
537
|
+
merchandise: {
|
|
538
|
+
...variant,
|
|
539
|
+
product: { handle: product.handle, title: product.title, featured_image: product.featured_image }
|
|
540
|
+
},
|
|
541
|
+
cost: {
|
|
542
|
+
unit: variant.price,
|
|
543
|
+
total: fromCents(unit * l.quantity, currency),
|
|
544
|
+
compare_at_unit: variant.compare_at_price
|
|
545
|
+
}
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
const codes = cart.codes.map((code) => ({ code, applicable: isKnownCode(code) && subtotal > 0 }));
|
|
549
|
+
const percent = codes.filter((d) => d.applicable).reduce((max, d) => Math.max(max, percentFor(d.code)), 0);
|
|
550
|
+
const discount = Math.round(subtotal * percent / 100);
|
|
551
|
+
let shipping = null;
|
|
552
|
+
if (cart.delivery.method === "pickup") shipping = 0;
|
|
553
|
+
else if (cart.delivery.method === "shipping" && cart.delivery.rate_id) {
|
|
554
|
+
const rate = shippingRates(subtotal - discount).find((r) => r.id === cart.delivery.rate_id);
|
|
555
|
+
shipping = rate ? toCents(rate.cost.amount) : null;
|
|
556
|
+
}
|
|
557
|
+
const total = subtotal - discount + (shipping ?? 0);
|
|
558
|
+
const tax = Math.round(total - total / 1.16);
|
|
559
|
+
return {
|
|
560
|
+
id: cart.id,
|
|
561
|
+
lines,
|
|
562
|
+
total_quantity: quantity,
|
|
563
|
+
discount_codes: codes,
|
|
564
|
+
buyer: { ...cart.buyer },
|
|
565
|
+
shipping_address: cart.shipping_address ? { ...cart.shipping_address } : null,
|
|
566
|
+
delivery: { ...cart.delivery },
|
|
567
|
+
cost: {
|
|
568
|
+
subtotal: fromCents(subtotal, currency),
|
|
569
|
+
discount: fromCents(discount, currency),
|
|
570
|
+
shipping: shipping === null ? null : fromCents(shipping, currency),
|
|
571
|
+
tax: fromCents(tax, currency),
|
|
572
|
+
total: fromCents(total, currency),
|
|
573
|
+
tax_included: true
|
|
574
|
+
},
|
|
575
|
+
updated_at: cart.updated_at
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
function requireCustomer(headers) {
|
|
579
|
+
const token = headers.get("x-customer-token");
|
|
580
|
+
const id = token ? state.sessions.get(token) : void 0;
|
|
581
|
+
const customer = id ? state.customers.find((c) => c.id === id) : void 0;
|
|
582
|
+
if (!customer) throw new HttpError(401, "unauthorized", "Missing or invalid customer session");
|
|
583
|
+
return customer;
|
|
584
|
+
}
|
|
585
|
+
function identifierOf(body) {
|
|
586
|
+
const email = typeof body.email === "string" ? body.email.trim().toLowerCase() : "";
|
|
587
|
+
const phone = typeof body.phone === "string" ? body.phone.replace(/\s+/g, "") : "";
|
|
588
|
+
if (!email && !phone) throw new HttpError(400, "validation", "email or phone is required", "email");
|
|
589
|
+
if (email && !/^[^@\s]+@[^@\s]+$/.test(email)) throw new HttpError(400, "validation", "Invalid email", "email");
|
|
590
|
+
return email ? `email:${email}` : `phone:${phone}`;
|
|
591
|
+
}
|
|
592
|
+
function validateAddress(body, partial = false) {
|
|
593
|
+
const required = ["address1", "city", "zip", "country_code"];
|
|
594
|
+
if (!partial) {
|
|
595
|
+
for (const f of required) {
|
|
596
|
+
if (typeof body[f] !== "string" || !body[f].trim()) throw new HttpError(400, "validation", `${f} is required`, f);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
const fields = ["first_name", "last_name", "company", "address1", "address2", "city", "province", "zip", "country_code", "phone"];
|
|
600
|
+
const out = {};
|
|
601
|
+
for (const f of fields) {
|
|
602
|
+
if (f in body) out[f] = body[f] ?? null;
|
|
603
|
+
else if (!partial) out[f] = null;
|
|
604
|
+
}
|
|
605
|
+
return out;
|
|
606
|
+
}
|
|
607
|
+
const routes = [];
|
|
608
|
+
const route = (method, path, scope, handler) => {
|
|
609
|
+
const pattern = new RegExp("^" + path.replace(/:[a-z_]+/g, "([^/]+)") + "$");
|
|
610
|
+
routes.push({ method, pattern, scope, handler });
|
|
611
|
+
};
|
|
612
|
+
const requireNode = (value, what, field = "handle") => {
|
|
613
|
+
if (value === void 0) throw new HttpError(404, "not_found", `${what} not found`, field);
|
|
614
|
+
return value;
|
|
615
|
+
};
|
|
616
|
+
route("GET", "/shop", "catalog:read", () => ({ body: state.shop, cache: "public" }));
|
|
617
|
+
route("GET", "/products", "catalog:read", ({ search }) => ({ body: listProducts(search), cache: "public" }));
|
|
618
|
+
route("GET", "/products/:handle", "catalog:read", ({ params }) => ({
|
|
619
|
+
body: requireNode(state.products.find((p) => p.handle === params[0]), `Product ${params[0]}`),
|
|
620
|
+
cache: "public"
|
|
621
|
+
}));
|
|
622
|
+
route("GET", "/products/:handle/recommendations", "catalog:read", ({ params }) => {
|
|
623
|
+
const product = requireNode(state.products.find((p) => p.handle === params[0]), `Product ${params[0]}`);
|
|
624
|
+
const cols = state.memberships[product.handle] ?? [];
|
|
625
|
+
const nodes = state.products.filter((p) => p.id !== product.id && p.available_for_sale && (state.memberships[p.handle] ?? []).some((c) => cols.includes(c))).slice(0, 4);
|
|
626
|
+
return { body: { nodes }, cache: "public" };
|
|
627
|
+
});
|
|
628
|
+
route("GET", "/collections", "catalog:read", ({ search }) => ({ body: paginate(state.collections, search), cache: "public" }));
|
|
629
|
+
route("GET", "/collections/:handle", "catalog:read", ({ params }) => ({
|
|
630
|
+
body: requireNode(state.collections.find((c) => c.handle === params[0]), `Collection ${params[0]}`),
|
|
631
|
+
cache: "public"
|
|
632
|
+
}));
|
|
633
|
+
route("GET", "/stock", "catalog:read", ({ search }) => {
|
|
634
|
+
const skus = search.getAll("sku");
|
|
635
|
+
if (skus.length > 250) throw new HttpError(400, "validation", "At most 250 SKUs", "sku");
|
|
636
|
+
const nodes = skus.flatMap((sku) => {
|
|
637
|
+
for (const p of state.products) {
|
|
638
|
+
const v = p.variants.find((x) => x.sku === sku);
|
|
639
|
+
if (v) return [{ sku, quantity_available: v.quantity_available }];
|
|
640
|
+
}
|
|
641
|
+
return [];
|
|
642
|
+
});
|
|
643
|
+
return { body: { nodes }, cache: "public" };
|
|
644
|
+
});
|
|
645
|
+
route("POST", "/carts", "cart:write", ({ body }) => {
|
|
646
|
+
const cart = {
|
|
647
|
+
id: nextId("cart"),
|
|
648
|
+
lines: [],
|
|
649
|
+
codes: [],
|
|
650
|
+
buyer: { email: null, phone: null, customer_id: null },
|
|
651
|
+
shipping_address: null,
|
|
652
|
+
delivery: { method: null, rate_id: null, branch_id: null },
|
|
653
|
+
updated_at: now()
|
|
654
|
+
};
|
|
655
|
+
if (body.lines !== void 0) addLines(cart, validateLines(body.lines));
|
|
656
|
+
state.carts.set(cart.id, cart);
|
|
657
|
+
return { status: 201, body: renderCart(cart), cache: "private" };
|
|
658
|
+
});
|
|
659
|
+
route("GET", "/carts/:id", "cart:write", ({ params }) => ({ body: renderCart(getCartRecord(params[0])), cache: "private" }));
|
|
660
|
+
route("POST", "/carts/:id/lines", "cart:write", ({ params, body }) => {
|
|
661
|
+
const cart = getCartRecord(params[0]);
|
|
662
|
+
addLines(cart, validateLines(body.lines, cart));
|
|
663
|
+
cart.updated_at = now();
|
|
664
|
+
return { body: renderCart(cart), cache: "private" };
|
|
665
|
+
});
|
|
666
|
+
route("PATCH", "/carts/:id/lines", "cart:write", ({ params, body }) => {
|
|
667
|
+
const cart = getCartRecord(params[0]);
|
|
668
|
+
if (!Array.isArray(body.lines)) throw new HttpError(400, "validation", "`lines` must be an array", "lines");
|
|
669
|
+
const updates = body.lines;
|
|
670
|
+
updates.forEach((u, i) => {
|
|
671
|
+
const line = cart.lines.find((l) => l.id === u.id);
|
|
672
|
+
if (!line) throw new HttpError(400, "validation", `Unknown line ${String(u.id)}`, `lines[${i}].id`);
|
|
673
|
+
if (typeof u.quantity !== "number" || !Number.isInteger(u.quantity) || u.quantity < 0) {
|
|
674
|
+
throw new HttpError(400, "validation", "quantity must be a non-negative integer", `lines[${i}].quantity`);
|
|
675
|
+
}
|
|
676
|
+
if (u.quantity > 0) checkStock(findVariant(line.variant_id).variant, u.quantity, `lines[${i}].quantity`);
|
|
677
|
+
});
|
|
678
|
+
for (const u of updates) {
|
|
679
|
+
const line = cart.lines.find((l) => l.id === u.id);
|
|
680
|
+
line.quantity = u.quantity;
|
|
681
|
+
}
|
|
682
|
+
cart.lines = cart.lines.filter((l) => l.quantity > 0);
|
|
683
|
+
cart.updated_at = now();
|
|
684
|
+
return { body: renderCart(cart), cache: "private" };
|
|
685
|
+
});
|
|
686
|
+
route("PUT", "/carts/:id/discount-codes", "cart:write", ({ params, body }) => {
|
|
687
|
+
const cart = getCartRecord(params[0]);
|
|
688
|
+
if (!Array.isArray(body.codes) || body.codes.some((c) => typeof c !== "string")) {
|
|
689
|
+
throw new HttpError(400, "validation", "`codes` must be an array of strings", "codes");
|
|
690
|
+
}
|
|
691
|
+
cart.codes = [...new Set(body.codes.map((c) => c.trim().toUpperCase()).filter(Boolean))];
|
|
692
|
+
cart.updated_at = now();
|
|
693
|
+
return { body: renderCart(cart), cache: "private" };
|
|
694
|
+
});
|
|
695
|
+
route("PUT", "/carts/:id/buyer", "cart:write", ({ params, body, headers }) => {
|
|
696
|
+
const cart = getCartRecord(params[0]);
|
|
697
|
+
const email = typeof body.email === "string" ? body.email.trim() : null;
|
|
698
|
+
const phone = typeof body.phone === "string" ? body.phone.trim() : null;
|
|
699
|
+
if (email && !/^[^@\s]+@[^@\s]+$/.test(email)) throw new HttpError(400, "validation", "Invalid email", "email");
|
|
700
|
+
const token = headers.get("x-customer-token");
|
|
701
|
+
const customerId = token ? state.sessions.get(token) ?? null : null;
|
|
702
|
+
cart.buyer = { email: email || null, phone: phone || null, customer_id: customerId };
|
|
703
|
+
cart.updated_at = now();
|
|
704
|
+
return { body: renderCart(cart), cache: "private" };
|
|
705
|
+
});
|
|
706
|
+
route("PUT", "/carts/:id/shipping-address", "cart:write", ({ params, body }) => {
|
|
707
|
+
const cart = getCartRecord(params[0]);
|
|
708
|
+
cart.shipping_address = validateAddress(body);
|
|
709
|
+
cart.updated_at = now();
|
|
710
|
+
return { body: renderCart(cart), cache: "private" };
|
|
711
|
+
});
|
|
712
|
+
route("GET", "/carts/:id/delivery-options", "cart:write", ({ params }) => {
|
|
713
|
+
const cart = renderCart(getCartRecord(params[0]));
|
|
714
|
+
const net = toCents(cart.cost.subtotal.amount) - toCents(cart.cost.discount.amount);
|
|
715
|
+
return {
|
|
716
|
+
body: { rates: shippingRates(net), pickup: state.shop.branches.map((b) => ({ branch_id: b.id, name: b.name })), partial: false, unavailable: [] },
|
|
717
|
+
cache: "private"
|
|
718
|
+
};
|
|
719
|
+
});
|
|
720
|
+
route("GET", "/carts/:id/payment-methods", "cart:write", ({ params }) => {
|
|
721
|
+
getCartRecord(params[0]);
|
|
722
|
+
return { body: { methods: [{ key: "mock", kind: "builtin", default: true }], market: null }, cache: "private" };
|
|
723
|
+
});
|
|
724
|
+
route("PUT", "/carts/:id/delivery", "cart:write", ({ params, body }) => {
|
|
725
|
+
const cart = getCartRecord(params[0]);
|
|
726
|
+
if (body.method === "shipping") {
|
|
727
|
+
const rateId = body.rate_id;
|
|
728
|
+
if (typeof rateId !== "string" || !shippingRates(0).some((r) => r.id === rateId)) {
|
|
729
|
+
throw new HttpError(400, "validation", "Unknown rate_id", "rate_id");
|
|
730
|
+
}
|
|
731
|
+
cart.delivery = { method: "shipping", rate_id: rateId, branch_id: null };
|
|
732
|
+
} else if (body.method === "pickup") {
|
|
733
|
+
const branchId = body.branch_id;
|
|
734
|
+
if (typeof branchId !== "string" || !state.shop.branches.some((b) => b.id === branchId)) {
|
|
735
|
+
throw new HttpError(400, "validation", "Unknown branch_id", "branch_id");
|
|
736
|
+
}
|
|
737
|
+
cart.delivery = { method: "pickup", rate_id: null, branch_id: branchId };
|
|
738
|
+
} else {
|
|
739
|
+
throw new HttpError(400, "validation", "method must be shipping or pickup", "method");
|
|
740
|
+
}
|
|
741
|
+
cart.updated_at = now();
|
|
742
|
+
return { body: renderCart(cart), cache: "private" };
|
|
743
|
+
});
|
|
744
|
+
route("POST", "/carts/:id/checkout", "checkout:write", ({ params, body, headers }) => {
|
|
745
|
+
const key = headers.get("idempotency-key");
|
|
746
|
+
if (key) {
|
|
747
|
+
const prev = state.idempotency.get(`${params[0]}:${key}`);
|
|
748
|
+
if (prev) return { status: prev.status, body: prev.body, cache: "private" };
|
|
749
|
+
}
|
|
750
|
+
const record = getCartRecord(params[0]);
|
|
751
|
+
const cart = renderCart(record);
|
|
752
|
+
if (cart.lines.length === 0) throw new HttpError(422, "invalid_state", "Cart is empty", "lines");
|
|
753
|
+
if (!cart.buyer.email && !cart.buyer.phone) throw new HttpError(422, "invalid_state", "Buyer email or phone is required", "buyer");
|
|
754
|
+
if (!cart.delivery.method) throw new HttpError(422, "invalid_state", "Delivery method is required", "delivery");
|
|
755
|
+
if (cart.delivery.method === "shipping" && !cart.shipping_address) {
|
|
756
|
+
throw new HttpError(422, "invalid_state", "Shipping address is required", "shipping_address");
|
|
757
|
+
}
|
|
758
|
+
if (typeof body.payment_method !== "string" || !body.payment_method) throw new HttpError(400, "validation", "payment_method is required", "payment_method");
|
|
759
|
+
if (typeof body.return_url !== "string" || !body.return_url) throw new HttpError(400, "validation", "return_url is required", "return_url");
|
|
760
|
+
record.lines.forEach((l, i) => checkStock(findVariant(l.variant_id).variant, l.quantity, `lines[${i}].quantity`));
|
|
761
|
+
for (const l of record.lines) {
|
|
762
|
+
const v = findVariant(l.variant_id).variant;
|
|
763
|
+
if (v.quantity_available !== null) {
|
|
764
|
+
v.quantity_available -= l.quantity;
|
|
765
|
+
v.available_for_sale = v.quantity_available > 0;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
for (const p of state.products) p.available_for_sale = p.variants.some((v) => v.available_for_sale);
|
|
769
|
+
const orderId = nextId("ord");
|
|
770
|
+
const number = String(1001 + state.orders.length - 1);
|
|
771
|
+
const customerId = cart.buyer.customer_id ?? state.customers.find((c) => c.email && c.email === cart.buyer.email)?.id ?? null;
|
|
772
|
+
state.orders.push({
|
|
773
|
+
id: orderId,
|
|
774
|
+
number,
|
|
775
|
+
status: "pending_payment",
|
|
776
|
+
created_at: now(),
|
|
777
|
+
total: cart.cost.total,
|
|
778
|
+
customer_id: customerId,
|
|
779
|
+
lines: cart.lines.map((l) => ({
|
|
780
|
+
title: l.merchandise.product.title,
|
|
781
|
+
variant_title: l.merchandise.title,
|
|
782
|
+
sku: l.merchandise.sku,
|
|
783
|
+
quantity: l.quantity,
|
|
784
|
+
unit: l.cost.unit,
|
|
785
|
+
total: l.cost.total,
|
|
786
|
+
image: l.merchandise.image ?? l.merchandise.product.featured_image
|
|
787
|
+
}))
|
|
788
|
+
});
|
|
789
|
+
const redirect = new URL(`https://mock.local/pay/${orderId}`);
|
|
790
|
+
redirect.searchParams.set("return_url", body.return_url);
|
|
791
|
+
const checkoutId = nextId("chk");
|
|
792
|
+
const result = {
|
|
793
|
+
order: { id: orderId, number },
|
|
794
|
+
payment: { provider: "mock", status: "pending", redirect_url: redirect.toString(), client_secret: null, instructions: null },
|
|
795
|
+
checkout: {
|
|
796
|
+
id: checkoutId,
|
|
797
|
+
status: "pending",
|
|
798
|
+
external_order_id: orderId,
|
|
799
|
+
total: cart.cost.total,
|
|
800
|
+
stock_hold_expires_at: new Date(Date.now() + 30 * 6e4).toISOString()
|
|
801
|
+
}
|
|
802
|
+
};
|
|
803
|
+
if (key) state.idempotency.set(`${params[0]}:${key}`, { status: 201, body: result });
|
|
804
|
+
return { status: 201, body: result, cache: "private" };
|
|
805
|
+
});
|
|
806
|
+
route("POST", "/customer/otp", "customer:write", ({ body }) => {
|
|
807
|
+
state.otps.set(identifierOf(body), otpCode);
|
|
808
|
+
return { status: 204 };
|
|
809
|
+
});
|
|
810
|
+
route("POST", "/customer/session", "customer:write", ({ body }) => {
|
|
811
|
+
const id = identifierOf(body);
|
|
812
|
+
if (typeof body.code !== "string" || state.otps.get(id) !== body.code) {
|
|
813
|
+
throw new HttpError(401, "unauthorized", "Invalid or expired code", "code");
|
|
814
|
+
}
|
|
815
|
+
state.otps.delete(id);
|
|
816
|
+
const [kind, value] = id.split(/:(.*)/s);
|
|
817
|
+
let customer = state.customers.find((c) => kind === "email" ? c.email === value : c.phone === value);
|
|
818
|
+
if (!customer) {
|
|
819
|
+
customer = {
|
|
820
|
+
id: nextId("cus"),
|
|
821
|
+
email: kind === "email" ? value : null,
|
|
822
|
+
phone: kind === "phone" ? value : null,
|
|
823
|
+
first_name: null,
|
|
824
|
+
last_name: null,
|
|
825
|
+
default_address_id: null,
|
|
826
|
+
created_at: now()
|
|
827
|
+
};
|
|
828
|
+
state.customers.push(customer);
|
|
829
|
+
}
|
|
830
|
+
const token = `ct_${customer.id}_${Math.random().toString(36).slice(2)}`;
|
|
831
|
+
state.sessions.set(token, customer.id);
|
|
832
|
+
return {
|
|
833
|
+
status: 201,
|
|
834
|
+
body: { token, expires_at: new Date(Date.now() + 30 * 864e5).toISOString(), customer },
|
|
835
|
+
cache: "private"
|
|
836
|
+
};
|
|
837
|
+
});
|
|
838
|
+
route("GET", "/customer", "customer:read", ({ headers }) => ({ body: requireCustomer(headers), cache: "private" }));
|
|
839
|
+
const stripOrder = ({ customer_id: _c, ...o }) => o;
|
|
840
|
+
route("GET", "/customer/orders", "orders:read", ({ headers, search }) => {
|
|
841
|
+
const c = requireCustomer(headers);
|
|
842
|
+
const orders = state.orders.filter((o) => o.customer_id === c.id).sort((a, b) => b.created_at.localeCompare(a.created_at)).map(stripOrder);
|
|
843
|
+
return { body: paginate(orders, search, 20), cache: "private" };
|
|
844
|
+
});
|
|
845
|
+
const findOrder = (headers, id) => {
|
|
846
|
+
const c = requireCustomer(headers);
|
|
847
|
+
return requireNode(state.orders.find((o) => o.id === id && o.customer_id === c.id), `Order ${id}`, "id");
|
|
848
|
+
};
|
|
849
|
+
route("GET", "/customer/orders/:id", "orders:read", ({ headers, params }) => ({ body: stripOrder(findOrder(headers, params[0])), cache: "private" }));
|
|
850
|
+
route("POST", "/customer/orders/:id/returns", "customer:write", ({ headers, params, body }) => {
|
|
851
|
+
const order = findOrder(headers, params[0]);
|
|
852
|
+
if (!Array.isArray(body.lines) || body.lines.length === 0) throw new HttpError(400, "validation", "lines are required", "lines");
|
|
853
|
+
return { status: 201, body: { id: nextId("ret"), order_id: order.id, status: "requested" }, cache: "private" };
|
|
854
|
+
});
|
|
855
|
+
route("POST", "/customer/orders/:id/invoice", "customer:write", ({ headers, params }) => {
|
|
856
|
+
findOrder(headers, params[0]);
|
|
857
|
+
return { status: 202, body: { status: "requested", url: null }, cache: "private" };
|
|
858
|
+
});
|
|
859
|
+
route("GET", "/customer/addresses", "customer:read", ({ headers }) => {
|
|
860
|
+
const c = requireCustomer(headers);
|
|
861
|
+
return { body: { nodes: state.addresses.get(c.id) ?? [] }, cache: "private" };
|
|
862
|
+
});
|
|
863
|
+
route("POST", "/customer/addresses", "customer:write", ({ headers, body }) => {
|
|
864
|
+
const c = requireCustomer(headers);
|
|
865
|
+
const address = { ...validateAddress(body), id: nextId("addr") };
|
|
866
|
+
state.addresses.set(c.id, [...state.addresses.get(c.id) ?? [], address]);
|
|
867
|
+
if (!c.default_address_id) c.default_address_id = address.id;
|
|
868
|
+
return { status: 201, body: address, cache: "private" };
|
|
869
|
+
});
|
|
870
|
+
route("PATCH", "/customer/addresses/:id", "customer:write", ({ headers, params, body }) => {
|
|
871
|
+
const c = requireCustomer(headers);
|
|
872
|
+
const list = state.addresses.get(c.id) ?? [];
|
|
873
|
+
const idx = list.findIndex((a) => a.id === params[0]);
|
|
874
|
+
if (idx < 0) throw new HttpError(404, "not_found", "Address not found", "id");
|
|
875
|
+
const updated = { ...list[idx], ...validateAddress(body, true), id: list[idx].id };
|
|
876
|
+
list[idx] = updated;
|
|
877
|
+
return { body: updated, cache: "private" };
|
|
878
|
+
});
|
|
879
|
+
route("DELETE", "/customer/addresses/:id", "customer:write", ({ headers, params }) => {
|
|
880
|
+
const c = requireCustomer(headers);
|
|
881
|
+
const list = state.addresses.get(c.id) ?? [];
|
|
882
|
+
if (!list.some((a) => a.id === params[0])) throw new HttpError(404, "not_found", "Address not found", "id");
|
|
883
|
+
state.addresses.set(c.id, list.filter((a) => a.id !== params[0]));
|
|
884
|
+
if (c.default_address_id === params[0]) c.default_address_id = null;
|
|
885
|
+
return { status: 204 };
|
|
886
|
+
});
|
|
887
|
+
const previewCache = (search) => search.has("preview") ? "private" : "public";
|
|
888
|
+
route("GET", "/content/theme", "content:read", () => ({ body: state.theme, cache: "public" }));
|
|
889
|
+
route("GET", "/content/pages/:handle", "content:read", ({ params, search }) => {
|
|
890
|
+
const page = requireNode(state.pages.find((p) => p.handle === params[0]), `Page ${params[0]}`);
|
|
891
|
+
const body = search.has("preview") ? { ...page, title: `${page.title} (borrador)` } : page;
|
|
892
|
+
return { body, cache: previewCache(search) };
|
|
893
|
+
});
|
|
894
|
+
route("GET", "/content/menus/:handle", "content:read", ({ params }) => ({
|
|
895
|
+
body: requireNode(state.menus.find((m) => m.handle === params[0]), `Menu ${params[0]}`),
|
|
896
|
+
cache: "public"
|
|
897
|
+
}));
|
|
898
|
+
route("GET", "/content/articles", "content:read", ({ search }) => {
|
|
899
|
+
let items = state.articles.slice().sort((a, b) => b.published_at.localeCompare(a.published_at));
|
|
900
|
+
const tag = search.get("tag");
|
|
901
|
+
if (tag) items = items.filter((a) => a.tags.includes(tag));
|
|
902
|
+
const q = search.get("q")?.toLowerCase();
|
|
903
|
+
if (q) items = items.filter((a) => `${a.title} ${a.excerpt ?? ""}`.toLowerCase().includes(q));
|
|
904
|
+
return { body: paginate(items, search, 10, 50), cache: "public" };
|
|
905
|
+
});
|
|
906
|
+
route("GET", "/content/articles/:handle", "content:read", ({ params, search }) => ({
|
|
907
|
+
body: requireNode(state.articles.find((a) => a.handle === params[0]), `Article ${params[0]}`),
|
|
908
|
+
cache: previewCache(search)
|
|
909
|
+
}));
|
|
910
|
+
function scopesFor(token) {
|
|
911
|
+
if (!token) return null;
|
|
912
|
+
if (token.startsWith("pk_")) return /* @__PURE__ */ new Set(["catalog:read", "content:read", "cart:write"]);
|
|
913
|
+
if (token.startsWith("sk_")) return /* @__PURE__ */ new Set(["catalog:read", "content:read", "cart:write", "checkout:write", "customer:read", "customer:write", "orders:read"]);
|
|
914
|
+
return null;
|
|
915
|
+
}
|
|
916
|
+
function json(status, body, headers = {}) {
|
|
917
|
+
return new Response(status === 204 || body === void 0 ? null : JSON.stringify(body), {
|
|
918
|
+
status,
|
|
919
|
+
headers: { "Content-Type": "application/json; charset=utf-8", ...headers }
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
function errorResponse(err) {
|
|
923
|
+
return json(err.status, { error: { code: err.code, message: err.message, ...err.field ? { field: err.field } : {} } }, { "Cache-Control": PRIVATE_CACHE });
|
|
924
|
+
}
|
|
925
|
+
async function handle(request) {
|
|
926
|
+
const url = new URL(request.url);
|
|
927
|
+
const idx = url.pathname.indexOf("/storefront/");
|
|
928
|
+
if (idx < 0) return errorResponse(new HttpError(404, "not_found", `No route for ${url.pathname}`));
|
|
929
|
+
const path = url.pathname.slice(idx + "/storefront".length).replace(/\/+$/, "") || "/";
|
|
930
|
+
const method = request.method.toUpperCase();
|
|
931
|
+
const matching = routes.filter((r2) => r2.pattern.test(path));
|
|
932
|
+
if (matching.length === 0) return errorResponse(new HttpError(404, "not_found", `No route for ${method} ${path}`));
|
|
933
|
+
const r = matching.find((x) => x.method === method);
|
|
934
|
+
if (!r) return json(405, { error: { code: "method_not_allowed", message: `${method} not allowed on ${path}` } }, { Allow: matching.map((x) => x.method).join(", ") });
|
|
935
|
+
const auth = request.headers.get("authorization");
|
|
936
|
+
const token = auth?.match(/^Bearer\s+(.+)$/i)?.[1] ?? null;
|
|
937
|
+
const scopes = scopesFor(token);
|
|
938
|
+
if (!scopes) return errorResponse(new HttpError(401, "unauthorized", "Missing or invalid storefront token"));
|
|
939
|
+
if (enforceScopes && !scopes.has(r.scope)) return errorResponse(new HttpError(403, "forbidden", `Token lacks scope ${r.scope}`));
|
|
940
|
+
let body = {};
|
|
941
|
+
if (method !== "GET" && method !== "DELETE") {
|
|
942
|
+
const text = await request.text();
|
|
943
|
+
if (text) {
|
|
944
|
+
try {
|
|
945
|
+
const parsed = JSON.parse(text);
|
|
946
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("not an object");
|
|
947
|
+
body = parsed;
|
|
948
|
+
} catch {
|
|
949
|
+
return errorResponse(new HttpError(400, "validation", "Body must be a JSON object"));
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
const params = (r.pattern.exec(path) ?? []).slice(1).map((p) => decodeURIComponent(p));
|
|
954
|
+
try {
|
|
955
|
+
const result = await r.handler({ method, params, search: url.searchParams, headers: request.headers, body, scopes });
|
|
956
|
+
const status = result.status ?? 200;
|
|
957
|
+
const headers = { ...result.headers };
|
|
958
|
+
if (result.cache === "public") headers["Cache-Control"] = PUBLIC_CACHE;
|
|
959
|
+
if (result.cache === "private") headers["Cache-Control"] = PRIVATE_CACHE;
|
|
960
|
+
if (method === "GET" && result.cache === "public" && status === 200) {
|
|
961
|
+
const etag = `W/"${fnv1a(JSON.stringify(result.body))}"`;
|
|
962
|
+
headers.ETag = etag;
|
|
963
|
+
if (request.headers.get("if-none-match") === etag) return new Response(null, { status: 304, headers });
|
|
964
|
+
}
|
|
965
|
+
return json(status, result.body, headers);
|
|
966
|
+
} catch (err) {
|
|
967
|
+
if (err instanceof HttpError) return errorResponse(err);
|
|
968
|
+
return json(500, { error: { code: "server_error", message: err instanceof Error ? err.message : "Internal error" } });
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
const mockFetch = async (input, init) => {
|
|
972
|
+
if (options.latencyMs) await new Promise((r) => setTimeout(r, options.latencyMs));
|
|
973
|
+
const request = input instanceof Request && !init ? input : new Request(input instanceof Request ? input.url : String(input), init);
|
|
974
|
+
if (request.signal?.aborted) throw request.signal.reason ?? new DOMException("Aborted", "AbortError");
|
|
975
|
+
return handle(request);
|
|
976
|
+
};
|
|
977
|
+
return {
|
|
978
|
+
/** Drop-in `fetch` serving `/storefront/*` from memory. */
|
|
979
|
+
fetch: mockFetch,
|
|
980
|
+
/** Live in-memory state (mutable; useful in tests). */
|
|
981
|
+
get state() {
|
|
982
|
+
return state;
|
|
983
|
+
},
|
|
984
|
+
/** Restores the seed data and clears carts, sessions and orders. */
|
|
985
|
+
reset() {
|
|
986
|
+
state = seed();
|
|
987
|
+
},
|
|
988
|
+
/** OTP code accepted by `/customer/session`. */
|
|
989
|
+
otpCode
|
|
990
|
+
};
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
exports.MOCK_CURRENCY = MOCK_CURRENCY;
|
|
994
|
+
exports.MOCK_CUSTOMER_EMAIL = MOCK_CUSTOMER_EMAIL;
|
|
995
|
+
exports.MOCK_IMAGE_URL = MOCK_IMAGE_URL;
|
|
996
|
+
exports.createMockStorefront = createMockStorefront;
|
|
997
|
+
//# sourceMappingURL=mock.cjs.map
|
|
998
|
+
//# sourceMappingURL=mock.cjs.map
|