@proxima-io/storefront-core 0.1.0 → 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/README.md +614 -0
- package/dist/index.d.ts +1255 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1535 -19
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
ADDED
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
# @proxima-io/storefront-core
|
|
2
|
+
|
|
3
|
+
Cliente HTTP para la API Proxima. Cubre autenticación de compradores, carrito, órdenes,
|
|
4
|
+
libro de direcciones, wishlist, búsqueda, listados de catálogo y analytics.
|
|
5
|
+
|
|
6
|
+
## Instalación
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pnpm add @proxima-io/storefront-core
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Arquitectura: composition-first
|
|
15
|
+
|
|
16
|
+
El storefront usa un modelo **composition-first**: cada página se resuelve con una sola
|
|
17
|
+
llamada a `fetchProximaComposition`, que devuelve el layout CMS completo con todos los datos
|
|
18
|
+
ya embebidos (productos, categorías, marcas via smart collections; entidad principal via
|
|
19
|
+
`resolved_data`). **No se necesitan llamadas adicionales al catálogo para el primer render.**
|
|
20
|
+
|
|
21
|
+
Las funciones de catálogo de este SDK (`searchStorefront`, `fetchCategoryProducts`, etc.)
|
|
22
|
+
son para **interacciones client-side** que ocurren después del primer render: filtros,
|
|
23
|
+
paginación, búsqueda en vivo.
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
Cada página:
|
|
27
|
+
fetchProximaWebsite(domain) → resuelve el website (cachear)
|
|
28
|
+
fetchProximaComposition(config, ws) → layout + datos completos para SSR
|
|
29
|
+
|
|
30
|
+
Interacciones posteriores (client-side):
|
|
31
|
+
searchStorefront(...) → resultados de búsqueda
|
|
32
|
+
fetchCategoryProducts(...) → paginación / filtros en CLP
|
|
33
|
+
fetchBrandProducts(...) → paginación / filtros en BLP
|
|
34
|
+
fetchStorefrontProducts(...) → listado general con filtros
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## Website & Composición
|
|
40
|
+
|
|
41
|
+
### `fetchProximaWebsite(config)`
|
|
42
|
+
|
|
43
|
+
Resuelve el website por dominio. Llamar una vez por request y cachear el resultado.
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
const website = await fetchProximaWebsite({
|
|
47
|
+
baseUrl: import.meta.env.PROXIMA_API_URL,
|
|
48
|
+
domain: Astro.url.hostname,
|
|
49
|
+
});
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### `fetchProximaComposition(config, website)`
|
|
53
|
+
|
|
54
|
+
Obtiene la composición completa de una página: secciones CMS, SEO y datos resueltos.
|
|
55
|
+
Es la llamada principal para el render SSR de cualquier página.
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
const composition = await fetchProximaComposition(
|
|
59
|
+
{ ...config, path: Astro.url.pathname },
|
|
60
|
+
website,
|
|
61
|
+
);
|
|
62
|
+
// composition.sections → secciones CMS con datos de smart collections
|
|
63
|
+
// composition.resolved_data → entidad principal (product, category, brand, blog)
|
|
64
|
+
// composition.seo → meta tags
|
|
65
|
+
// composition.resolver_kind → tipo de página ("product_detail", "category_detail", …)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### `makeBuilderPreviewWebsite(config)`
|
|
69
|
+
|
|
70
|
+
Crea un `ProximaWebsiteResponse` sintético para el preview del Builder visual.
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
const website = makeBuilderPreviewWebsite({
|
|
74
|
+
websiteId: Astro.url.searchParams.get('builder_website_id'),
|
|
75
|
+
businessId: Astro.url.searchParams.get('builder_business_id'),
|
|
76
|
+
domain: Astro.url.hostname,
|
|
77
|
+
});
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### `fetchProximaWebsiteList(config)`
|
|
81
|
+
|
|
82
|
+
Lista todos los websites del tenant. Requiere `serviceKey`. Uso: scripts de build o CI.
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
const websites = await fetchProximaWebsiteList({ baseUrl, serviceKey });
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## Catálogo storefront
|
|
91
|
+
|
|
92
|
+
Estas funciones se usan para interacciones client-side después del primer render SSR,
|
|
93
|
+
o para sitemap / navegación. **No usarlas para el render inicial de página** — esos datos
|
|
94
|
+
ya vienen en la composición.
|
|
95
|
+
|
|
96
|
+
### `searchStorefront(config, website, params)`
|
|
97
|
+
|
|
98
|
+
Búsqueda de productos por texto. Usar para la barra de búsqueda y la página de resultados
|
|
99
|
+
(`resolver_kind: "search"` tiene `resolved_data = null` — los resultados se deben pedir aquí).
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
const results = await searchStorefront(config, website, { q: 'zapatillas', limit: 10 });
|
|
103
|
+
// results.hits: StorefrontProductSummary[]
|
|
104
|
+
// results.total: number
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### `fetchStorefrontProducts(config, website, params?)`
|
|
108
|
+
|
|
109
|
+
Listado general de productos con filtros y paginación. Para la página de todos los productos
|
|
110
|
+
cuando el usuario cambia filtros o avanza de página.
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
const listing = await fetchStorefrontProducts(config, website, {
|
|
114
|
+
page: 2, pageSize: 24, sort: 'price_asc',
|
|
115
|
+
category: 'zapatillas', brand: 'nike',
|
|
116
|
+
});
|
|
117
|
+
// listing.items, listing.pagination, listing.brand_facets, listing.category_facets
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
> **Nota:** `fetchProximaProducts` está obsoleta. Usar `fetchStorefrontProducts` en su lugar.
|
|
121
|
+
|
|
122
|
+
### `fetchCategoryProducts(config, website, params)`
|
|
123
|
+
|
|
124
|
+
Productos de una categoría con paginación y filtros. Para los cambios de página / filtro
|
|
125
|
+
en las páginas de categoría (CLP) después del SSR inicial.
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
const listing = await fetchCategoryProducts(config, website, {
|
|
129
|
+
slug: 'zapatillas', page: 2, sort: 'price_asc', brand: 'adidas',
|
|
130
|
+
});
|
|
131
|
+
// listing.category, listing.items, listing.pagination, listing.brand_facets
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### `fetchBrandProducts(config, website, params)`
|
|
135
|
+
|
|
136
|
+
Productos de una marca con paginación y filtros. Para páginas de marca (BLP).
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
const listing = await fetchBrandProducts(config, website, {
|
|
140
|
+
slug: 'nike', page: 1, category: 'running',
|
|
141
|
+
});
|
|
142
|
+
// listing.brand, listing.items, listing.pagination, listing.category_facets
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### `fetchCategoriesDirectory(config, website)`
|
|
146
|
+
|
|
147
|
+
Directorio completo de categorías con conteo de productos. Para menús de navegación y sitemap.
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
const { items, total } = await fetchCategoriesDirectory(config, website);
|
|
151
|
+
// items: [{ id, name, slug, href, product_count, image_url? }]
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### `fetchCategoryNavTree(config, website, params?)`
|
|
155
|
+
|
|
156
|
+
Árbol recursivo de categorías para mega menú de navegación. Cada nodo incluye
|
|
157
|
+
`children[]` con subnodos anidados y `href` con la ruta del storefront lista para
|
|
158
|
+
renderizar.
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
const tree = await fetchCategoryNavTree(config, website, { maxDepth: 3 });
|
|
162
|
+
// tree.nodes: CategoryNavNode[]
|
|
163
|
+
// node.href = "/categoria/{slug}"
|
|
164
|
+
// node.children: CategoryNavNode[] (recursivo)
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### `fetchBrandsDirectory(config, website)`
|
|
168
|
+
|
|
169
|
+
Directorio completo de marcas. Para menús de navegación y sitemap.
|
|
170
|
+
|
|
171
|
+
```ts
|
|
172
|
+
const { items, total } = await fetchBrandsDirectory(config, website);
|
|
173
|
+
// items: [{ id, name, slug, href, product_count, logo_url? }]
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
## SEO y structured data
|
|
179
|
+
|
|
180
|
+
### `buildPageSeo(seoData, website, locale, currentUrl)`
|
|
181
|
+
|
|
182
|
+
Construye el objeto `PageSeoMeta` completo para una página. Prioridad: campos
|
|
183
|
+
admin-set > datos de la entidad (producto/categoría) > defaults del website.
|
|
184
|
+
Pasar el resultado directamente a `<SiteLayout seo={seo} />`.
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
const seo = buildPageSeo(composition.seo, website, website.locale, canonicalUrl);
|
|
188
|
+
// seo.title, seo.description, seo.ogImage, seo.canonicalUrl, seo.robots, …
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### JSON-LD builders (schema.org)
|
|
192
|
+
|
|
193
|
+
```ts
|
|
194
|
+
// En SiteLayout.astro — sitewide
|
|
195
|
+
const websiteJsonLd = buildWebSiteJsonLd(website);
|
|
196
|
+
const orgJsonLd = buildOrganizationJsonLd(website); // null si no hay logo_url
|
|
197
|
+
|
|
198
|
+
// En ProductDetail.astro — por producto
|
|
199
|
+
const productJsonLd = buildProductJsonLd(product, website);
|
|
200
|
+
// product debe satisfacer JsonLdProductMeta: { name, slug, priceRaw, images?, brand?, sku? }
|
|
201
|
+
|
|
202
|
+
// En cualquier página — breadcrumbs
|
|
203
|
+
const breadcrumbJsonLd = buildBreadcrumbJsonLd(
|
|
204
|
+
[{ label: 'Inicio', href: '/' }, { label: 'GPUs', href: '/categoria/gpus' }, { label: product.name }],
|
|
205
|
+
`https://${website.domain}`
|
|
206
|
+
);
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
```astro
|
|
210
|
+
<!-- En el <head> del layout -->
|
|
211
|
+
<script type="application/ld+json" set:html={JSON.stringify(websiteJsonLd)} />
|
|
212
|
+
{orgJsonLd && <script type="application/ld+json" set:html={JSON.stringify(orgJsonLd)} />}
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
### `generateSitemapXml(website, apiUrl, options?)`
|
|
216
|
+
|
|
217
|
+
Genera el `sitemap.xml` completo: páginas estáticas, categorías (árbol), marcas y
|
|
218
|
+
productos (paginados). Usa las funciones de catálogo del SDK internamente.
|
|
219
|
+
|
|
220
|
+
```ts
|
|
221
|
+
// apps/{slug}/src/pages/sitemap.xml.ts
|
|
222
|
+
import type { APIRoute } from 'astro';
|
|
223
|
+
import { resolveWebsiteOnly } from '@/lib/resolver';
|
|
224
|
+
import { generateSitemapXml } from '@proxima-io/storefront-core';
|
|
225
|
+
|
|
226
|
+
export const GET: APIRoute = async () => {
|
|
227
|
+
const website = await resolveWebsiteOnly();
|
|
228
|
+
const xml = await generateSitemapXml(
|
|
229
|
+
website,
|
|
230
|
+
import.meta.env.PROXIMA_API_URL,
|
|
231
|
+
{ maxProducts: 3000 }
|
|
232
|
+
);
|
|
233
|
+
return new Response(xml, {
|
|
234
|
+
headers: { 'Content-Type': 'application/xml; charset=utf-8' },
|
|
235
|
+
});
|
|
236
|
+
};
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
### `generateRobotsTxt(website)`
|
|
240
|
+
|
|
241
|
+
Genera `robots.txt` con `Disallow` en rutas privadas del buyer y `Sitemap:` directive.
|
|
242
|
+
|
|
243
|
+
```ts
|
|
244
|
+
// apps/{slug}/src/pages/robots.txt.ts
|
|
245
|
+
export const GET: APIRoute = async () => {
|
|
246
|
+
const website = await resolveWebsiteOnly();
|
|
247
|
+
return new Response(generateRobotsTxt(website), {
|
|
248
|
+
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
|
249
|
+
});
|
|
250
|
+
};
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
### `notifyIndexNow(apiKey, siteUrl, urls)`
|
|
254
|
+
|
|
255
|
+
Notifica a Bing/Yandex (y opcionalmente Google) sobre URLs actualizadas para re-crawl
|
|
256
|
+
inmediato. Llamar desde webhooks de catálogo o tras publicar páginas.
|
|
257
|
+
|
|
258
|
+
```ts
|
|
259
|
+
await notifyIndexNow(
|
|
260
|
+
import.meta.env.PROXIMA_INDEXNOW_KEY,
|
|
261
|
+
`https://${website.domain}`,
|
|
262
|
+
[`https://${website.domain}/producto/${slug}`]
|
|
263
|
+
);
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
---
|
|
267
|
+
|
|
268
|
+
## Autenticación de compradores
|
|
269
|
+
|
|
270
|
+
### Formulario de registro dinámico
|
|
271
|
+
|
|
272
|
+
```ts
|
|
273
|
+
// Obtener el formulario configurado por el comercio
|
|
274
|
+
const form = await fetchRegistrationForm(config, website);
|
|
275
|
+
// form.mode: "single_step" | "multi_step"
|
|
276
|
+
// form.steps[0].fields: RegistrationFormField[] (email y password ya incluidos)
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
### `registerBuyer(config, website, params)`
|
|
280
|
+
|
|
281
|
+
Registra un nuevo comprador. Los campos requeridos dependen de la configuración del comercio
|
|
282
|
+
(ver `fetchRegistrationForm`). Lanza `MissingFieldsError` si faltan campos obligatorios.
|
|
283
|
+
|
|
284
|
+
```ts
|
|
285
|
+
try {
|
|
286
|
+
const session = await registerBuyer(config, website, {
|
|
287
|
+
email: 'juan@ejemplo.com',
|
|
288
|
+
password: 'secret',
|
|
289
|
+
fullName: 'Juan Pérez',
|
|
290
|
+
phone: '+51999999999',
|
|
291
|
+
docType: 1, // 1=DNI 2=CE 3=Pasaporte 6=RUC
|
|
292
|
+
docNumber: '12345678',
|
|
293
|
+
birthDate: '1990-05-15',
|
|
294
|
+
newsletterSubscribed: true,
|
|
295
|
+
});
|
|
296
|
+
// session.access_token, session.refresh_token
|
|
297
|
+
} catch (e) {
|
|
298
|
+
if (e instanceof MissingFieldsError) {
|
|
299
|
+
// e.missingFields: [{ field: 'phone', msg: 'FIELD_REQUIRED' }]
|
|
300
|
+
e.missingFields.forEach(({ field }) => markFieldError(field));
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
### `loginBuyer(config, website, params)`
|
|
306
|
+
|
|
307
|
+
```ts
|
|
308
|
+
const session = await loginBuyer(config, website, { email, password });
|
|
309
|
+
// session.access_token, session.refresh_token
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
### `logoutBuyer(config, website, params)`
|
|
313
|
+
|
|
314
|
+
Invalida el token en el servidor (best-effort).
|
|
315
|
+
|
|
316
|
+
```ts
|
|
317
|
+
await logoutBuyer(config, website, { token });
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
### `refreshBuyerToken(config, website, params)`
|
|
321
|
+
|
|
322
|
+
Obtiene un nuevo `access_token` usando el `refresh_token`. Usar en middleware Astro para
|
|
323
|
+
refrescar sesiones expiradas silenciosamente.
|
|
324
|
+
|
|
325
|
+
```ts
|
|
326
|
+
try {
|
|
327
|
+
const session = await refreshBuyerToken(config, website, { refreshToken });
|
|
328
|
+
// Guardar session.access_token en cookie
|
|
329
|
+
} catch {
|
|
330
|
+
// refresh_token expirado → limpiar cookies, redirigir al login
|
|
331
|
+
}
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
### `fetchBuyerProfile(config, website, params)`
|
|
335
|
+
|
|
336
|
+
```ts
|
|
337
|
+
const profile = await fetchBuyerProfile(config, website, { token });
|
|
338
|
+
// profile.email, profile.full_name, profile.doc_type, profile.newsletter_subscribed, …
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
### `updateBuyerProfile(config, website, params)`
|
|
342
|
+
|
|
343
|
+
Actualización parcial — solo los campos enviados se modifican.
|
|
344
|
+
|
|
345
|
+
```ts
|
|
346
|
+
const updated = await updateBuyerProfile(config, website, {
|
|
347
|
+
token,
|
|
348
|
+
fullName: 'Juan García',
|
|
349
|
+
newsletterSubscribed: true,
|
|
350
|
+
// password: 'newPass' → cambia la contraseña sin invalidar el token actual
|
|
351
|
+
});
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
---
|
|
355
|
+
|
|
356
|
+
## Recuperación de contraseña
|
|
357
|
+
|
|
358
|
+
```ts
|
|
359
|
+
// 1. Enviar email de recuperación (siempre resuelve, no confirma si el email existe)
|
|
360
|
+
await forgotPassword(config, website, { email: 'juan@ejemplo.com' });
|
|
361
|
+
// → Mostrar siempre: "Si el email existe, recibirás un enlace."
|
|
362
|
+
|
|
363
|
+
// 2. Cambiar contraseña con el token del email (?token=xxx)
|
|
364
|
+
try {
|
|
365
|
+
await resetPassword(config, { token, newPassword: 'nueva' });
|
|
366
|
+
// → Redirigir al login ("Contraseña actualizada")
|
|
367
|
+
} catch (e: any) {
|
|
368
|
+
if (e.data?.detail === BUYER_AUTH_ERRORS.RESET_TOKEN_INVALID) {
|
|
369
|
+
// Token inválido o expirado
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
## Verificación de email
|
|
375
|
+
|
|
376
|
+
```ts
|
|
377
|
+
// Verificar con el token del email (?token=xxx)
|
|
378
|
+
try {
|
|
379
|
+
await verifyEmail(config, { token });
|
|
380
|
+
} catch (e: any) {
|
|
381
|
+
if (e.data?.detail === BUYER_AUTH_ERRORS.VERIFY_TOKEN_INVALID) { /* … */ }
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Reenviar el email de verificación (requiere estar autenticado)
|
|
385
|
+
try {
|
|
386
|
+
await resendVerification(config, website, { token: accessToken });
|
|
387
|
+
} catch (e: any) {
|
|
388
|
+
if (e.data?.detail === BUYER_AUTH_ERRORS.EMAIL_ALREADY_VERIFIED) { /* … */ }
|
|
389
|
+
}
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
### `BUYER_AUTH_ERRORS`
|
|
393
|
+
|
|
394
|
+
Constantes para comparar errores de la API sin hardcodear strings:
|
|
395
|
+
|
|
396
|
+
```ts
|
|
397
|
+
import { BUYER_AUTH_ERRORS } from '@proxima-io/storefront-core';
|
|
398
|
+
|
|
399
|
+
BUYER_AUTH_ERRORS.RESET_TOKEN_INVALID // "RESET_TOKEN_INVALID"
|
|
400
|
+
BUYER_AUTH_ERRORS.VERIFY_TOKEN_INVALID // "VERIFY_TOKEN_INVALID"
|
|
401
|
+
BUYER_AUTH_ERRORS.EMAIL_ALREADY_VERIFIED // "EMAIL_ALREADY_VERIFIED"
|
|
402
|
+
BUYER_AUTH_ERRORS.EMAIL_TAKEN // "Email already registered in this store"
|
|
403
|
+
BUYER_AUTH_ERRORS.MISSING_REQUIRED_FIELDS // "MISSING_REQUIRED_FIELDS"
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
---
|
|
407
|
+
|
|
408
|
+
## Carrito
|
|
409
|
+
|
|
410
|
+
El carrito soporta compradores guest (sin token) y autenticados.
|
|
411
|
+
|
|
412
|
+
```ts
|
|
413
|
+
const cart = await fetchCart(config, website, { token }); // token opcional
|
|
414
|
+
const cart = await addToCart(config, website, { variantId: 42, quantity: 1, token });
|
|
415
|
+
const cart = await updateCartItem(config, website, { variantId: 42, quantity: 3, token });
|
|
416
|
+
const cart = await removeCartItem(config, website, { variantId: 42, token });
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
### `mergeGuestCart(config, website, params)`
|
|
420
|
+
|
|
421
|
+
Fusiona el carrito guest con el carrito del comprador al hacer login.
|
|
422
|
+
Llamar inmediatamente después de un login exitoso si existe una sesión guest activa.
|
|
423
|
+
|
|
424
|
+
```ts
|
|
425
|
+
const sessionId = localStorage.getItem('proxima_session_id');
|
|
426
|
+
if (sessionId) {
|
|
427
|
+
await mergeGuestCart(config, website, { token: session.access_token, sessionId });
|
|
428
|
+
}
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
### `validateCoupon(config, website, params)`
|
|
432
|
+
|
|
433
|
+
Valida un cupón antes del checkout. Siempre resuelve — verificar `result.valid`.
|
|
434
|
+
|
|
435
|
+
```ts
|
|
436
|
+
const result = await validateCoupon(config, website, { code: 'PROMO10', amount: 150.00 });
|
|
437
|
+
if (result.valid) {
|
|
438
|
+
showDiscount(result.discount_amount); // Monto a descontar
|
|
439
|
+
} else {
|
|
440
|
+
showError(result.error); // Mensaje de error (expirado, mínimo no alcanzado, etc.)
|
|
441
|
+
}
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
---
|
|
445
|
+
|
|
446
|
+
## Órdenes
|
|
447
|
+
|
|
448
|
+
```ts
|
|
449
|
+
// Checkout (convierte el carrito en orden)
|
|
450
|
+
const order = await createOrder(config, website, {
|
|
451
|
+
token,
|
|
452
|
+
checkout: {
|
|
453
|
+
customer_name: 'Juan Pérez',
|
|
454
|
+
customer_phone: '999888777',
|
|
455
|
+
customer_email: 'juan@ejemplo.com',
|
|
456
|
+
shipping_address: 'Av. Javier Prado 1234, Lima',
|
|
457
|
+
coupon_code: 'PROMO10', // opcional
|
|
458
|
+
},
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
// Historial de pedidos
|
|
462
|
+
const { items, total } = await fetchOrders(config, website, { token, page: 1, size: 10 });
|
|
463
|
+
|
|
464
|
+
// Detalle de un pedido
|
|
465
|
+
const order = await fetchOrder(config, website, { token, orderId: 'ord_abc123' });
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
---
|
|
469
|
+
|
|
470
|
+
## Libro de direcciones
|
|
471
|
+
|
|
472
|
+
```ts
|
|
473
|
+
const addresses = await fetchCustomerAddresses(config, website, { token });
|
|
474
|
+
|
|
475
|
+
const address = await createCustomerAddress(config, website, {
|
|
476
|
+
token,
|
|
477
|
+
address: {
|
|
478
|
+
line1: 'Av. Larco 345',
|
|
479
|
+
ubigeo_code: '150122', // Código ubigeo de 6 dígitos
|
|
480
|
+
reference: 'Frente al parque',
|
|
481
|
+
is_default: true,
|
|
482
|
+
latitude: -12.1219, // opcional
|
|
483
|
+
longitude: -77.0299, // opcional
|
|
484
|
+
geocoding_source: 'google_maps',
|
|
485
|
+
},
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
await updateCustomerAddress(config, website, { token, addressId: 5, address: { line1: 'Nueva dirección' } });
|
|
489
|
+
await deleteCustomerAddress(config, website, { token, addressId: 5 });
|
|
490
|
+
await setDefaultAddress(config, website, { token, addressId: 5 });
|
|
491
|
+
|
|
492
|
+
// Buscar ubigeos peruanos por texto
|
|
493
|
+
const results = await searchUbigeo(config, { q: 'miraflores' });
|
|
494
|
+
// [{ code: '150122', department: 'Lima', province: 'Lima', district: 'Miraflores', full_name: '…' }]
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
---
|
|
498
|
+
|
|
499
|
+
## Wishlist
|
|
500
|
+
|
|
501
|
+
```ts
|
|
502
|
+
// Listar items del wishlist
|
|
503
|
+
const items = await fetchWishlist(config, website, { token });
|
|
504
|
+
|
|
505
|
+
// Agregar producto (idempotente — no crea duplicados)
|
|
506
|
+
const item = await addToWishlist(config, website, {
|
|
507
|
+
token,
|
|
508
|
+
productId: 'uuid-del-producto',
|
|
509
|
+
variantId: 'uuid-variante', // opcional
|
|
510
|
+
notes: 'Lo quiero en azul', // opcional
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
// Eliminar producto del wishlist (lanza { status: 404 } si no existía)
|
|
514
|
+
await removeFromWishlist(config, website, { token, productId: 'uuid-del-producto' });
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
---
|
|
518
|
+
|
|
519
|
+
## Server-side helpers
|
|
520
|
+
|
|
521
|
+
Los `process*` helpers combinan `fetchProximaWebsite` + la operación correspondiente.
|
|
522
|
+
Diseñados para rutas de API Astro — reducen el boilerplate a ~5 líneas.
|
|
523
|
+
|
|
524
|
+
```ts
|
|
525
|
+
interface BuyerServerEnv {
|
|
526
|
+
apiUrl: string; // import.meta.env.PROXIMA_API_URL
|
|
527
|
+
domain: string; // Astro.url.hostname
|
|
528
|
+
serviceKey?: string; // import.meta.env.PROXIMA_SERVICE_KEY
|
|
529
|
+
}
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
```ts
|
|
533
|
+
// src/pages/api/buyer/login.ts
|
|
534
|
+
import {
|
|
535
|
+
processBuyerLogin,
|
|
536
|
+
BUYER_COOKIE_NAME,
|
|
537
|
+
BUYER_REFRESH_COOKIE_NAME,
|
|
538
|
+
BUYER_COOKIE_OPTIONS,
|
|
539
|
+
} from '@proxima-io/storefront-core';
|
|
540
|
+
|
|
541
|
+
export const POST = async ({ request, cookies }) => {
|
|
542
|
+
const { email, password } = await request.json();
|
|
543
|
+
const { access_token, refresh_token, next } = await processBuyerLogin(env, { email, password });
|
|
544
|
+
cookies.set(BUYER_COOKIE_NAME, access_token, BUYER_COOKIE_OPTIONS);
|
|
545
|
+
if (refresh_token) cookies.set(BUYER_REFRESH_COOKIE_NAME, refresh_token, BUYER_COOKIE_OPTIONS);
|
|
546
|
+
return Response.redirect(next);
|
|
547
|
+
};
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
| Helper | Descripción |
|
|
551
|
+
|--------|-------------|
|
|
552
|
+
| `processBuyerLogin(env, params)` | Website → login → `{ access_token, refresh_token, next }` |
|
|
553
|
+
| `processBuyerRegister(env, params)` | Website → register → `{ access_token, refresh_token, next }`. Propaga `MissingFieldsError` |
|
|
554
|
+
| `processBuyerLogout(env, params)` | Logout best-effort, nunca lanza |
|
|
555
|
+
| `processRefreshToken(env, params)` | Website → refresh → `{ access_token, refresh_token }` |
|
|
556
|
+
| `processForgotPassword(env, params)` | Website → forgot password. Nunca lanza |
|
|
557
|
+
| `processResetPassword(env, params)` | Reset password con token del email |
|
|
558
|
+
| `processVerifyEmail(env, params)` | Verificar email con token del email |
|
|
559
|
+
| `processGetCart(env, params)` | Website → fetch cart |
|
|
560
|
+
| `processAddToCart(env, params)` | Website → add to cart |
|
|
561
|
+
| `processRemoveCartItem(env, params)` | Website → remove cart item |
|
|
562
|
+
| `processBuyerCheckout(env, params)` | Website → create order → `{ orderId }` |
|
|
563
|
+
| `processSetDefaultAddress(env, params)` | Website → set default address |
|
|
564
|
+
| `processDeleteAddress(env, params)` | Website → delete address |
|
|
565
|
+
|
|
566
|
+
### Patrón recomendado de middleware (refresh silencioso)
|
|
567
|
+
|
|
568
|
+
```
|
|
569
|
+
1. Leer cookie buyer_token
|
|
570
|
+
2. Si no existe → continuar sin sesión
|
|
571
|
+
3. Si existe → fetchBuyerProfile
|
|
572
|
+
4. Si devuelve 401 → processRefreshToken con buyer_refresh_token
|
|
573
|
+
5. Si OK → actualizar cookie buyer_token, continuar
|
|
574
|
+
6. Si falla → limpiar ambas cookies, continuar sin sesión
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
**Constantes de cookie:**
|
|
578
|
+
|
|
579
|
+
```ts
|
|
580
|
+
BUYER_COOKIE_NAME // 'buyer_token'
|
|
581
|
+
BUYER_REFRESH_COOKIE_NAME // 'buyer_refresh_token'
|
|
582
|
+
BUYER_COOKIE_OPTIONS // { path: '/', httpOnly: true, sameSite: 'lax', maxAge: 604800 }
|
|
583
|
+
```
|
|
584
|
+
|
|
585
|
+
---
|
|
586
|
+
|
|
587
|
+
## Analytics
|
|
588
|
+
|
|
589
|
+
Cliente client-side con queue automático y flush por batch.
|
|
590
|
+
|
|
591
|
+
```ts
|
|
592
|
+
import { analytics } from '@proxima-io/storefront-core';
|
|
593
|
+
|
|
594
|
+
// En SiteLayout.astro <script> — llamar una sola vez:
|
|
595
|
+
analytics.init({
|
|
596
|
+
apiUrl: 'https://api.proxima.io',
|
|
597
|
+
websiteId: 'uuid-website',
|
|
598
|
+
businessId: 'uuid-negocio',
|
|
599
|
+
locale: 'es',
|
|
600
|
+
flushInterval: 3000, // ms (default: 3000)
|
|
601
|
+
debug: false,
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
// En cualquier componente client-side:
|
|
605
|
+
analytics.track('product_view', { product_slug: 'titan-mx-pro' });
|
|
606
|
+
analytics.track('add_to_cart', { product_slug, variant_id: 42, price: 199.90 });
|
|
607
|
+
analytics.track('order_completed', { order_id: 'ord_123', order_total: 399.80 });
|
|
608
|
+
analytics.track('search', { query: 'zapatillas', results_count: 24 });
|
|
609
|
+
```
|
|
610
|
+
|
|
611
|
+
- `page_view` se dispara automáticamente al iniciar y en cada `astro:page-load`.
|
|
612
|
+
- Los eventos se encolan y se envían en batch a `POST /api/v1/store/events`.
|
|
613
|
+
- Al cerrar la pestaña (`visibilitychange: hidden`) se usa `navigator.sendBeacon`.
|
|
614
|
+
- Es seguro llamar `analytics.track()` antes de `analytics.init()` — los eventos se reproducen al inicializar.
|