@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 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.