@ecomiq/storefront 1.1.9 → 2.0.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.
Files changed (134) hide show
  1. package/README.md +26 -418
  2. package/bin/ecomiq.mjs +154 -0
  3. package/package.json +72 -94
  4. package/src/api-proxy.ts +194 -0
  5. package/src/auth/create-storefront-auth.ts +35 -0
  6. package/src/auth/ecomiq-oauth-password-client.ts +26 -0
  7. package/src/auth/ecomiq-oauth-password-constants.ts +26 -0
  8. package/src/auth/ecomiq-oauth-password-plugin.ts +299 -0
  9. package/src/auth/ecomiq-oauth-password-types.ts +25 -0
  10. package/src/auth/ecomiq-storefront-sign-in.ts +47 -0
  11. package/src/auth/env.ts +7 -0
  12. package/src/cart-session.ts +241 -0
  13. package/src/client.ts +48 -0
  14. package/src/codegen.mjs +603 -0
  15. package/src/components/checkout/cart-sync.ts +143 -0
  16. package/src/components/checkout/checkout-external-update-banner.tsx +15 -0
  17. package/src/components/checkout/checkout-form-controls.tsx +139 -0
  18. package/src/components/checkout/checkout-line-items.tsx +43 -0
  19. package/src/components/checkout/checkout-order-summary.tsx +156 -0
  20. package/src/components/checkout/checkout-payment-section.tsx +215 -0
  21. package/src/components/checkout/checkout-session-view.tsx +240 -0
  22. package/src/components/checkout/checkout-shipping-address-section.tsx +266 -0
  23. package/src/components/checkout/checkout-shipping-section.tsx +90 -0
  24. package/src/components/checkout/checkout-states.tsx +73 -0
  25. package/src/components/checkout/form-schema.ts +229 -0
  26. package/src/components/checkout/format.ts +27 -0
  27. package/src/components/checkout/index.ts +51 -0
  28. package/src/components/checkout/types.ts +43 -0
  29. package/src/components/checkout/use-checkout-geo.ts +50 -0
  30. package/src/components/checkout/use-checkout-session.ts +583 -0
  31. package/src/components/hero.tsx +291 -0
  32. package/src/config.d.ts +69 -0
  33. package/src/config.mjs +165 -0
  34. package/src/index.ts +274 -0
  35. package/src/link.tsx +2 -0
  36. package/src/pages/checkout.$checkoutId.tsx +60 -0
  37. package/src/pages/checkout.tsx +31 -0
  38. package/src/pages/thank-you.tsx +53 -0
  39. package/src/providers/auth.tsx +69 -0
  40. package/src/providers/cart.tsx +275 -0
  41. package/src/providers/ecomiq.ts +137 -0
  42. package/src/providers/index.ts +26 -0
  43. package/src/public-config.ts +44 -0
  44. package/src/review-submission.ts +89 -0
  45. package/src/runtime/context.ts +1 -0
  46. package/src/runtime/default-layout.tsx +80 -0
  47. package/src/runtime/head.ts +126 -0
  48. package/src/runtime/page.tsx +82 -0
  49. package/src/runtime/postcss-browser-stub.mjs +5 -0
  50. package/src/server-config-store.ts +26 -0
  51. package/src/server.ts +279 -0
  52. package/src/styles.css +76 -0
  53. package/src/ui/badge.tsx +32 -0
  54. package/src/ui/button.tsx +31 -0
  55. package/src/ui/card.tsx +31 -0
  56. package/src/ui/carousel.tsx +218 -0
  57. package/src/ui/cn.ts +6 -0
  58. package/src/ui/index.ts +8 -0
  59. package/src/ui/separator.tsx +6 -0
  60. package/src/ui/skeleton.tsx +6 -0
  61. package/src/ui/storefront-error.tsx +49 -0
  62. package/src/utils.ts +36 -0
  63. package/src/vite-inline.mjs +95 -0
  64. package/templates/__root.tsx +77 -0
  65. package/templates/router.tsx +19 -0
  66. package/dist/chunk-64GLOWMJ.js +0 -216
  67. package/dist/chunk-64GLOWMJ.js.map +0 -1
  68. package/dist/chunk-AKD5IDEB.js +0 -177
  69. package/dist/chunk-AKD5IDEB.js.map +0 -1
  70. package/dist/chunk-EFEVJJWF.js +0 -1345
  71. package/dist/chunk-EFEVJJWF.js.map +0 -1
  72. package/dist/chunk-NAJQB5DN.js +0 -155
  73. package/dist/chunk-NAJQB5DN.js.map +0 -1
  74. package/dist/chunk-OABDGTBI.js +0 -253
  75. package/dist/chunk-OABDGTBI.js.map +0 -1
  76. package/dist/chunk-Q4U5OEUT.js +0 -21
  77. package/dist/chunk-Q4U5OEUT.js.map +0 -1
  78. package/dist/chunk-S7CPZ4UE.js +0 -6
  79. package/dist/chunk-S7CPZ4UE.js.map +0 -1
  80. package/dist/chunks/client.css +0 -984
  81. package/dist/chunks/client.css.map +0 -1
  82. package/dist/chunks/client.d.ts +0 -10759
  83. package/dist/chunks/client.js +0 -5398
  84. package/dist/chunks/client.js.map +0 -1
  85. package/dist/chunks/config.d.ts +0 -31
  86. package/dist/chunks/config.js +0 -5
  87. package/dist/chunks/config.js.map +0 -1
  88. package/dist/chunks/core-public.d.ts +0 -130
  89. package/dist/chunks/core-public.js +0 -92
  90. package/dist/chunks/core-public.js.map +0 -1
  91. package/dist/chunks/server-nextjs.d.ts +0 -33
  92. package/dist/chunks/server-nextjs.js +0 -59
  93. package/dist/chunks/server-nextjs.js.map +0 -1
  94. package/dist/chunks/server-tanstack.d.ts +0 -22
  95. package/dist/chunks/server-tanstack.js +0 -40
  96. package/dist/chunks/server-tanstack.js.map +0 -1
  97. package/dist/collection-DU7gREXS.d.ts +0 -4062
  98. package/dist/ecomiq-oauth-password-plugin-B_gMdGuc.d.ts +0 -3631
  99. package/dist/ecomiq-shell-ui-SWIFLQVM.js +0 -8
  100. package/dist/ecomiq-shell-ui-SWIFLQVM.js.map +0 -1
  101. package/dist/index.d.ts +0 -9
  102. package/dist/index.js +0 -1
  103. package/dist/index.js.map +0 -1
  104. package/dist/index.server.d.ts +0 -9
  105. package/dist/index.server.js +0 -8
  106. package/dist/index.server.js.map +0 -1
  107. package/dist/nextjs/api.d.ts +0 -45
  108. package/dist/nextjs/api.js +0 -32
  109. package/dist/nextjs/api.js.map +0 -1
  110. package/dist/nextjs/globals.css +0 -13
  111. package/dist/nextjs.d.ts +0 -2
  112. package/dist/nextjs.js +0 -2
  113. package/dist/nextjs.js.map +0 -1
  114. package/dist/nextjs.server.d.ts +0 -18
  115. package/dist/nextjs.server.js +0 -1
  116. package/dist/nextjs.server.js.map +0 -1
  117. package/dist/postcss.config.mjs +0 -6
  118. package/dist/react/checkout-page.css +0 -855
  119. package/dist/react/client.css +0 -984
  120. package/dist/react/payment-provider.css +0 -91
  121. package/dist/react/thank-you-page.css +0 -105
  122. package/dist/react.d.ts +0 -1
  123. package/dist/react.js +0 -2
  124. package/dist/react.js.map +0 -1
  125. package/dist/store-proxy-handler-CNzKlTFA.d.ts +0 -10
  126. package/dist/tanstack/api.d.ts +0 -41
  127. package/dist/tanstack/api.js +0 -30
  128. package/dist/tanstack/api.js.map +0 -1
  129. package/dist/tanstack.d.ts +0 -1
  130. package/dist/tanstack.js +0 -2
  131. package/dist/tanstack.js.map +0 -1
  132. package/dist/tanstack.server.d.ts +0 -18
  133. package/dist/tanstack.server.js +0 -1
  134. package/dist/tanstack.server.js.map +0 -1
@@ -0,0 +1,299 @@
1
+ import {
2
+ createAuthEndpoint,
3
+ createAuthMiddleware,
4
+ formCsrfMiddleware,
5
+ } from "better-auth/api";
6
+ import { APIError, type BetterAuthPlugin } from "better-auth";
7
+ import { setSessionCookie } from "better-auth/cookies";
8
+ import { parseUserOutput } from "better-auth/db";
9
+
10
+ import { readProcessEnv } from "./env";
11
+ import {
12
+ DEFAULT_STOREFRONT_API_URL,
13
+ PLUGIN_ID,
14
+ STOREFRONT_API_PATH_PREFIX,
15
+ STOREFRONT_ECOMIQ_OAUTH_COOKIE_NAME,
16
+ STOREFRONT_ECOMIQ_OAUTH_COOKIE_PATH,
17
+ STOREFRONT_HEADERS,
18
+ } from "./ecomiq-oauth-password-constants";
19
+ import {
20
+ customerLoginResponseSchema,
21
+ signInBody,
22
+ } from "./ecomiq-oauth-password-types";
23
+
24
+ type StoreIdentityContext = {
25
+ identityScope?: string | null;
26
+ tenantId?: string | null;
27
+ storeId?: string | null;
28
+ };
29
+
30
+ /**
31
+ * Inicia sesión contra `POST /v1/customer/login` del storefront Ecomiq,
32
+ * crea/actualiza el usuario en Better Auth y guarda el bearer en cookie HttpOnly
33
+ * acotada a `/api/store` (proxy BFF).
34
+ */
35
+ export function ecomiqOAuthPassword(): BetterAuthPlugin {
36
+ const secureCookie = true;
37
+ const clearStorefrontOAuthCookieMiddleware = createAuthMiddleware(async (ctx) => {
38
+ ctx.setCookie(STOREFRONT_ECOMIQ_OAUTH_COOKIE_NAME, "", {
39
+ path: STOREFRONT_ECOMIQ_OAUTH_COOKIE_PATH,
40
+ httpOnly: true,
41
+ sameSite: "lax",
42
+ secure: secureCookie,
43
+ maxAge: 0,
44
+ });
45
+ });
46
+
47
+ return {
48
+ id: PLUGIN_ID,
49
+ schema: {
50
+ user: {
51
+ fields: {
52
+ firstName: { type: "string", required: false },
53
+ lastName: { type: "string", required: false },
54
+ phone: { type: "string", required: false },
55
+ },
56
+ },
57
+ },
58
+ hooks: {
59
+ after: [
60
+ {
61
+ matcher: (hookCtx) => hookCtx.path === "/sign-out",
62
+ handler: clearStorefrontOAuthCookieMiddleware,
63
+ },
64
+ ],
65
+ },
66
+ endpoints: {
67
+ signIn: createAuthEndpoint(
68
+ "/ecomiq-oauth/sign-in",
69
+ {
70
+ method: "POST",
71
+ use: [formCsrfMiddleware],
72
+ body: signInBody,
73
+ },
74
+ async (ctx) => {
75
+ const { email: loginEmail, password, callbackURL, rememberMe } = ctx.body;
76
+
77
+ const login = await customerPasswordLogin(loginEmail, password, {
78
+ identityScope: ctx.getHeader(STOREFRONT_HEADERS.identityScope),
79
+ tenantId: ctx.getHeader(STOREFRONT_HEADERS.tenantId),
80
+ storeId: ctx.getHeader(STOREFRONT_HEADERS.storeId),
81
+ });
82
+
83
+ const canonicalEmail = loginEmail.toLowerCase().trim();
84
+ const displayName = canonicalEmail.split("@")[0] || "Cliente";
85
+
86
+ const existing = await ctx.context.internalAdapter.findUserByEmail(canonicalEmail);
87
+ let user = existing?.user;
88
+ if (!user) {
89
+ user = await ctx.context.internalAdapter.createUser({
90
+ email: canonicalEmail,
91
+ name: displayName,
92
+ emailVerified: login.customer.isVerified,
93
+ });
94
+ } else if (user.name !== displayName) {
95
+ await ctx.context.internalAdapter.updateUser(user.id, { name: displayName });
96
+ user = { ...user, name: displayName };
97
+ }
98
+
99
+ const oauthSessionExpires =
100
+ login.expiresIn !== undefined && login.expiresIn > 0
101
+ ? new Date(Date.now() + login.expiresIn * 1000)
102
+ : undefined;
103
+
104
+ const session = await ctx.context.internalAdapter.createSession(
105
+ user.id,
106
+ rememberMe === false,
107
+ oauthSessionExpires ? { expiresAt: oauthSessionExpires } : {},
108
+ Boolean(oauthSessionExpires),
109
+ );
110
+
111
+ if (!session) {
112
+ throw APIError.from("INTERNAL_SERVER_ERROR", {
113
+ code: "ECOMIQ_SESSION_FAILED",
114
+ message: "No se pudo crear la sesión",
115
+ });
116
+ }
117
+
118
+ await setSessionCookie(ctx, { session, user }, rememberMe === false);
119
+
120
+ const oauthMaxAgeSec =
121
+ oauthSessionExpires !== undefined
122
+ ? Math.max(60, Math.ceil((oauthSessionExpires.getTime() - Date.now()) / 1000))
123
+ : login.expiresIn !== undefined && login.expiresIn > 0
124
+ ? login.expiresIn
125
+ : 60 * 60 * 24;
126
+
127
+ await ctx.setCookie(STOREFRONT_ECOMIQ_OAUTH_COOKIE_NAME, login.accessToken, {
128
+ path: STOREFRONT_ECOMIQ_OAUTH_COOKIE_PATH,
129
+ httpOnly: true,
130
+ sameSite: "lax",
131
+ secure: secureCookie,
132
+ maxAge: oauthMaxAgeSec,
133
+ });
134
+
135
+ if (callbackURL) ctx.setHeader("Location", callbackURL);
136
+
137
+ return ctx.json({
138
+ redirect: !!callbackURL,
139
+ token: session.token,
140
+ url: callbackURL,
141
+ user: parseUserOutput(ctx.context.options, user),
142
+ });
143
+ },
144
+ ),
145
+ },
146
+ } satisfies BetterAuthPlugin;
147
+ }
148
+
149
+ function resolveCustomerLoginUrl(): string {
150
+ const base = (readProcessEnv("STOREFRONT_API_URL") ?? DEFAULT_STOREFRONT_API_URL).replace(
151
+ /\/$/,
152
+ "",
153
+ );
154
+ return `${base}/${STOREFRONT_API_PATH_PREFIX}/customer/login`;
155
+ }
156
+
157
+ type ApiErrorBody = {
158
+ error?: string;
159
+ message?: string;
160
+ title?: string;
161
+ detail?: string;
162
+ };
163
+
164
+ async function customerPasswordLogin(
165
+ email: string,
166
+ password: string,
167
+ context: StoreIdentityContext,
168
+ ): Promise<ReturnType<typeof customerLoginResponseSchema.parse>> {
169
+ const headers = new Headers({
170
+ "content-type": "application/json",
171
+ accept: "application/json",
172
+ });
173
+
174
+ const identityScope = context.identityScope?.trim();
175
+ const tenantId = context.tenantId?.trim();
176
+ const storeId = context.storeId?.trim();
177
+
178
+ if (identityScope) {
179
+ headers.set(STOREFRONT_HEADERS.identityScope, identityScope);
180
+ }
181
+ if (tenantId) {
182
+ headers.set(STOREFRONT_HEADERS.tenantId, tenantId);
183
+ }
184
+ if (storeId) {
185
+ headers.set(STOREFRONT_HEADERS.storeId, storeId);
186
+ }
187
+
188
+ let res: Response;
189
+ let text: string;
190
+ try {
191
+ res = await fetch(resolveCustomerLoginUrl(), {
192
+ method: "POST",
193
+ headers,
194
+ body: JSON.stringify({ email, password }),
195
+ });
196
+ text = await res.text();
197
+ } catch (e) {
198
+ throw mapCustomerLoginFetchFailure(e);
199
+ }
200
+
201
+ const json = safeParseJson(text);
202
+ if (!res.ok) {
203
+ throwBadRequestFromLoginApi(json, res.statusText || text || `HTTP ${res.status}`);
204
+ }
205
+
206
+ const parsed = customerLoginResponseSchema.safeParse(json);
207
+ if (!parsed.success) {
208
+ throw APIError.from("UNAUTHORIZED", {
209
+ code: "ECOMIQ_LOGIN_SHAPE",
210
+ message: "Respuesta de login con formato inválido",
211
+ });
212
+ }
213
+
214
+ if (parsed.data.customer.status !== "active") {
215
+ throw APIError.from("UNAUTHORIZED", {
216
+ code: "ECOMIQ_CUSTOMER_INACTIVE",
217
+ message: "La cuenta del cliente no está activa",
218
+ });
219
+ }
220
+
221
+ return parsed.data;
222
+ }
223
+
224
+ function safeParseJson(text: string): unknown {
225
+ try {
226
+ return text ? JSON.parse(text) : {};
227
+ } catch {
228
+ return {};
229
+ }
230
+ }
231
+
232
+ function throwBadRequestFromLoginApi(payload: unknown, fallbackMessage: string): never {
233
+ const err = payload as ApiErrorBody;
234
+ const errorCode =
235
+ typeof err.error === "string" && err.error.trim() ? err.error.trim() : "invalid_credentials";
236
+ const errorMessage =
237
+ (typeof err.message === "string" && err.message.trim()) ||
238
+ (typeof err.detail === "string" && err.detail.trim()) ||
239
+ (typeof err.title === "string" && err.title.trim()) ||
240
+ (typeof err.error === "string" && err.error.trim()) ||
241
+ fallbackMessage.trim() ||
242
+ "Credenciales inválidas.";
243
+ throw APIError.from("BAD_REQUEST", {
244
+ code: errorCode,
245
+ message: errorMessage.slice(0, 500),
246
+ });
247
+ }
248
+
249
+ function mapCustomerLoginFetchFailure(e: unknown): never {
250
+ const codes = collectNetworkErrorCodes(e);
251
+ const msg = e instanceof Error ? e.message : String(e);
252
+
253
+ const isDnsError = codes.some((c) => c === "ENOTFOUND" || c === "EAI_AGAIN");
254
+ if (isDnsError) {
255
+ throw APIError.from("BAD_GATEWAY", {
256
+ code: "ECOMIQ_LOGIN_DNS",
257
+ message: "No se pudo resolver el servidor de la tienda.",
258
+ });
259
+ }
260
+
261
+ const isConnectionRefused = codes.includes("ECONNREFUSED");
262
+ if (isConnectionRefused) {
263
+ throw APIError.from("BAD_GATEWAY", {
264
+ code: "ECOMIQ_LOGIN_REFUSED",
265
+ message: "Conexión rechazada al servidor de la tienda.",
266
+ });
267
+ }
268
+
269
+ const looksLikeAbortOrSocketClose =
270
+ codes.some((c) =>
271
+ ["UND_ERR_SOCKET", "ECONNRESET", "EPIPE", "ECONNABORTED"].includes(c),
272
+ ) || /terminated|abort|closed|FIN|socket/i.test(msg);
273
+
274
+ if (looksLikeAbortOrSocketClose) {
275
+ throwBadRequestFromLoginApi({}, "Credenciales inválidas o conexión cerrada por el servidor.");
276
+ }
277
+
278
+ throw APIError.from("INTERNAL_SERVER_ERROR", {
279
+ code: "ECOMIQ_LOGIN_REQUEST_FAILED",
280
+ message: msg.slice(0, 280) || "Error al contactar el servidor de la tienda.",
281
+ });
282
+ }
283
+
284
+ function collectNetworkErrorCodes(e: unknown): string[] {
285
+ const out: string[] = [];
286
+ const visit = (x: unknown): void => {
287
+ if (!x || typeof x !== "object") return;
288
+ const code =
289
+ "code" in x && typeof (x as { code?: unknown }).code === "string"
290
+ ? (x as { code: string }).code
291
+ : undefined;
292
+ if (code) out.push(code);
293
+ if ("cause" in x && (x as { cause?: unknown }).cause) {
294
+ visit((x as { cause: unknown }).cause);
295
+ }
296
+ };
297
+ visit(e);
298
+ return out;
299
+ }
@@ -0,0 +1,25 @@
1
+ import { z } from "zod";
2
+
3
+ export const customerLoginCustomerSchema = z.object({
4
+ id: z.coerce.number(),
5
+ publicId: z.union([z.coerce.number(), z.string()]),
6
+ status: z.string(),
7
+ isVerified: z.boolean(),
8
+ });
9
+
10
+ /** Respuesta de `POST /v1/customer/login`. */
11
+ export const customerLoginResponseSchema = z.object({
12
+ accessToken: z.string().min(1),
13
+ refreshToken: z.string().optional(),
14
+ /** Segundos hasta expiración (ej. `86399`). Sincroniza la sesión Better Auth con el bearer. */
15
+ expiresIn: z.coerce.number().int().positive().optional(),
16
+ tokenType: z.string().optional(),
17
+ customer: customerLoginCustomerSchema,
18
+ });
19
+
20
+ export const signInBody = z.object({
21
+ email: z.string().email(),
22
+ password: z.string().min(1),
23
+ callbackURL: z.string().optional(),
24
+ rememberMe: z.boolean().optional().default(true),
25
+ });
@@ -0,0 +1,47 @@
1
+ import type { StorefrontSettings } from '@ecomiq/core'
2
+ import { STOREFRONT_HEADERS } from './ecomiq-oauth-password-constants'
3
+
4
+ export type EcomiqSignInCredentials = {
5
+ email: string
6
+ password: string
7
+ }
8
+
9
+ /** Cliente Better Auth con `$fetch` (p. ej. resultado de `createStorefrontAuthClient`). */
10
+ export type StorefrontAuthFetchClient = {
11
+ $fetch: (
12
+ path: string,
13
+ init: {
14
+ method: string
15
+ headers: Record<string, string | undefined>
16
+ body: EcomiqSignInCredentials
17
+ },
18
+ ) => Promise<{ error?: { message?: string } | null }>
19
+ }
20
+
21
+ /**
22
+ * Inicia sesión con email/contraseña vía plugin `ecomiqOAuthPassword`
23
+ * (Better Auth → `/v1/customer/login`).
24
+ */
25
+ export async function signInWithEcomiqCredentials(
26
+ authClient: StorefrontAuthFetchClient,
27
+ settings: StorefrontSettings | null | undefined,
28
+ credentials: EcomiqSignInCredentials,
29
+ ) {
30
+ const response = await authClient.$fetch('/ecomiq-oauth/sign-in', {
31
+ method: 'POST',
32
+ headers: {
33
+ 'Content-Type': 'application/json',
34
+ [STOREFRONT_HEADERS.storeId]: settings?.store.id,
35
+ [STOREFRONT_HEADERS.tenantId]: settings?.store.tenantId,
36
+ [STOREFRONT_HEADERS.identityScope]:
37
+ settings?.store.checkoutSettings.general.identityScope,
38
+ },
39
+ body: credentials,
40
+ })
41
+
42
+ if (response.error) {
43
+ throw new Error(response.error.message ?? 'Error al iniciar sesión')
44
+ }
45
+
46
+ return response
47
+ }
@@ -0,0 +1,7 @@
1
+ /** Lee una variable de entorno sin depender de `@types/node`. */
2
+ export function readProcessEnv(name: string): string | undefined {
3
+ const env = (globalThis as { process?: { env?: Record<string, string | undefined> } })
4
+ .process?.env
5
+ const value = env?.[name]
6
+ return typeof value === 'string' && value.length > 0 ? value : undefined
7
+ }
@@ -0,0 +1,241 @@
1
+ import {
2
+ ApiError,
3
+ cartVariantIdFromProduct,
4
+ type RemoveCartItemParams,
5
+ type StorefrontCart,
6
+ type StorefrontClient,
7
+ type UpdateCartItemQuantityParams,
8
+ } from '@ecomiq/core'
9
+ import { z } from 'zod'
10
+
11
+ type CartClient = Pick<StorefrontClient, 'getCart' | 'createCart' | 'addCartItem'>
12
+
13
+ export interface LoadActiveCartInput {
14
+ cartId: string
15
+ }
16
+
17
+ export type ActiveCartLoadResult =
18
+ | { ok: true; value: StorefrontCart | null }
19
+ | {
20
+ ok: false
21
+ error: {
22
+ kind: 'failed'
23
+ message: string
24
+ }
25
+ }
26
+
27
+ export type CartItemMutationResult =
28
+ | { ok: true; value: StorefrontCart }
29
+ | {
30
+ ok: false
31
+ error: {
32
+ kind: 'cart-unavailable' | 'unavailable' | 'failed'
33
+ message: string
34
+ }
35
+ }
36
+
37
+ export interface AddProductToCartInput {
38
+ cartId?: string
39
+ currencyCode: string
40
+ variantId: string
41
+ quantity: number
42
+ }
43
+
44
+ export interface AddProductToCartResult {
45
+ cartId: string
46
+ cart: StorefrontCart
47
+ created: boolean
48
+ }
49
+
50
+ export type ProductCartMutationResult =
51
+ | { ok: true; value: AddProductToCartResult }
52
+ | {
53
+ ok: false
54
+ error: {
55
+ kind: 'unavailable' | 'failed'
56
+ message: string
57
+ }
58
+ }
59
+
60
+ const StorefrontProblemSchema = z.object({
61
+ code: z.string().optional(),
62
+ }).passthrough()
63
+
64
+ function cartIsUnavailable(error: unknown) {
65
+ if (!(error instanceof ApiError)) return false
66
+ const problem = StorefrontProblemSchema.safeParse(error.body)
67
+ if (!problem.success) return false
68
+ if (error.status === 404 && problem.data.code === 'Carts.NotFound') return true
69
+ return error.status === 400 && problem.data.code === 'Carts.NotActive'
70
+ }
71
+
72
+ function cartIsActive(cart: StorefrontCart) {
73
+ return cart.status === 'inProgress' || cart.status === 'pending'
74
+ }
75
+
76
+ function cartItemMutationFailure(error: ApiError): CartItemMutationResult {
77
+ if (cartIsUnavailable(error)) {
78
+ return {
79
+ ok: false,
80
+ error: {
81
+ kind: 'cart-unavailable',
82
+ message: 'Este carrito ya no está disponible.',
83
+ },
84
+ }
85
+ }
86
+
87
+ const problem = StorefrontProblemSchema.safeParse(error.body)
88
+ if (problem.success && problem.data.code === 'Catalog.InsufficientStock') {
89
+ return {
90
+ ok: false,
91
+ error: {
92
+ kind: 'unavailable',
93
+ message: 'No hay stock suficiente para esa cantidad.',
94
+ },
95
+ }
96
+ }
97
+ if (problem.success && problem.data.code === 'Carts.ItemNotFound') {
98
+ return {
99
+ ok: false,
100
+ error: {
101
+ kind: 'unavailable',
102
+ message: 'Este producto ya no está en el carrito.',
103
+ },
104
+ }
105
+ }
106
+
107
+ return {
108
+ ok: false,
109
+ error: {
110
+ kind: 'failed',
111
+ message: 'No pudimos actualizar el carrito. Inténtalo nuevamente.',
112
+ },
113
+ }
114
+ }
115
+
116
+ export async function loadActiveCart(
117
+ client: Pick<StorefrontClient, 'getCart'>,
118
+ input: LoadActiveCartInput,
119
+ ): Promise<ActiveCartLoadResult> {
120
+ try {
121
+ const cart = await client.getCart({ cartId: input.cartId })
122
+ return { ok: true as const, value: cartIsActive(cart) ? cart : null }
123
+ } catch (error) {
124
+ if (cartIsUnavailable(error)) return { ok: true as const, value: null }
125
+ if (error instanceof ApiError) {
126
+ return {
127
+ ok: false,
128
+ error: {
129
+ kind: 'failed',
130
+ message: 'No pudimos cargar el carrito. Inténtalo nuevamente.',
131
+ },
132
+ }
133
+ }
134
+ throw error
135
+ }
136
+ }
137
+
138
+ export async function submitCartItemQuantity(
139
+ client: Pick<StorefrontClient, 'updateCartItemQuantity'>,
140
+ input: UpdateCartItemQuantityParams,
141
+ ): Promise<CartItemMutationResult> {
142
+ try {
143
+ const cart = await client.updateCartItemQuantity(input)
144
+ return { ok: true, value: cart }
145
+ } catch (error) {
146
+ if (!(error instanceof ApiError)) throw error
147
+ return cartItemMutationFailure(error)
148
+ }
149
+ }
150
+
151
+ export async function submitCartItemRemoval(
152
+ client: Pick<StorefrontClient, 'updateCartItemQuantity'>,
153
+ input: RemoveCartItemParams,
154
+ ): Promise<CartItemMutationResult> {
155
+ try {
156
+ const cart = await client.updateCartItemQuantity({ ...input, quantity: 0 })
157
+ return { ok: true, value: cart }
158
+ } catch (error) {
159
+ if (!(error instanceof ApiError)) throw error
160
+ return cartItemMutationFailure(error)
161
+ }
162
+ }
163
+
164
+ export async function addProductToCart(
165
+ client: CartClient,
166
+ input: AddProductToCartInput,
167
+ ): Promise<AddProductToCartResult> {
168
+ const variantId = cartVariantIdFromProduct(input.variantId)
169
+ let current: StorefrontCart | undefined
170
+ let created = false
171
+
172
+ if (input.cartId) {
173
+ try {
174
+ const persisted = await client.getCart({ cartId: input.cartId })
175
+ if (cartIsActive(persisted)) current = persisted
176
+ } catch (error) {
177
+ if (!cartIsUnavailable(error)) throw error
178
+ }
179
+ }
180
+
181
+ if (!current) {
182
+ current = await client.createCart({ currencyCode: input.currencyCode })
183
+ created = true
184
+ }
185
+
186
+ const add = (cartId: string) => client.addCartItem({
187
+ cartId,
188
+ variantId,
189
+ quantity: input.quantity,
190
+ })
191
+
192
+ try {
193
+ const cart = await add(current.id)
194
+ return { cartId: cart.id, cart, created }
195
+ } catch (error) {
196
+ if (!cartIsUnavailable(error)) throw error
197
+ const replacement = await client.createCart({ currencyCode: input.currencyCode })
198
+ const cart = await add(replacement.id)
199
+ return { cartId: cart.id, cart, created: true }
200
+ }
201
+ }
202
+
203
+ export async function submitProductToCart(
204
+ client: CartClient,
205
+ input: AddProductToCartInput,
206
+ ): Promise<ProductCartMutationResult> {
207
+ try {
208
+ return { ok: true, value: await addProductToCart(client, input) }
209
+ } catch (error) {
210
+ if (!(error instanceof ApiError)) throw error
211
+
212
+ const problem = StorefrontProblemSchema.safeParse(error.body)
213
+ const code = problem.success ? problem.data.code : undefined
214
+ if (code === 'Catalog.InsufficientStock') {
215
+ return {
216
+ ok: false,
217
+ error: {
218
+ kind: 'unavailable',
219
+ message: 'No hay stock suficiente para esa cantidad.',
220
+ },
221
+ }
222
+ }
223
+ if (code === 'Catalog.VariantNotFound') {
224
+ return {
225
+ ok: false,
226
+ error: {
227
+ kind: 'unavailable',
228
+ message: 'Esta variante ya no está disponible.',
229
+ },
230
+ }
231
+ }
232
+
233
+ return {
234
+ ok: false,
235
+ error: {
236
+ kind: 'failed',
237
+ message: 'No pudimos actualizar el carrito. Inténtalo nuevamente.',
238
+ },
239
+ }
240
+ }
241
+ }
package/src/client.ts ADDED
@@ -0,0 +1,48 @@
1
+ import {
2
+ createClient as createCoreClient,
3
+ type CreateApiOptions,
4
+ type StorefrontClient,
5
+ } from '@ecomiq/core'
6
+ import { getServerConfig } from './server-config-store'
7
+
8
+ let sharedClient: StorefrontClient | null = null
9
+
10
+ /** Cliente storefront compartido (cache de settings incluido). */
11
+ export function getStorefrontClient(options: CreateApiOptions = {}): StorefrontClient {
12
+ if (!sharedClient) {
13
+ const config = getServerConfig()
14
+ const headers = {
15
+ ...config.api?.headers,
16
+ ...options.headers,
17
+ }
18
+ for (const key of Object.keys(headers)) {
19
+ const lower = key.toLowerCase()
20
+ if (lower === 'x-store-id' || lower === 'x-store-domain') delete headers[key]
21
+ }
22
+ sharedClient = createCoreClient({
23
+ slug: config.slug,
24
+ ...config.api,
25
+ ...options,
26
+ headers,
27
+ })
28
+ }
29
+ return sharedClient
30
+ }
31
+
32
+ /** Invalida el singleton (tests / hot reload). */
33
+ export function resetStorefrontClient(): void {
34
+ sharedClient?.clearCache()
35
+ sharedClient = null
36
+ }
37
+
38
+ /**
39
+ * Cliente storefront. Slug y defaults de API salen del config de la tienda.
40
+ *
41
+ * ```ts
42
+ * export const api = createClient()
43
+ * const collections = await api.getCollections()
44
+ * ```
45
+ */
46
+ export function createClient(_options: CreateApiOptions = {}): StorefrontClient {
47
+ return getStorefrontClient(_options)
48
+ }