@ecomiq/storefront 1.1.5 → 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.
- package/README.md +26 -417
- package/bin/ecomiq.mjs +154 -0
- package/package.json +72 -84
- package/src/api-proxy.ts +194 -0
- package/src/auth/create-storefront-auth.ts +35 -0
- package/src/auth/ecomiq-oauth-password-client.ts +26 -0
- package/src/auth/ecomiq-oauth-password-constants.ts +26 -0
- package/src/auth/ecomiq-oauth-password-plugin.ts +299 -0
- package/src/auth/ecomiq-oauth-password-types.ts +25 -0
- package/src/auth/ecomiq-storefront-sign-in.ts +47 -0
- package/src/auth/env.ts +7 -0
- package/src/cart-session.ts +241 -0
- package/src/client.ts +48 -0
- package/src/codegen.mjs +603 -0
- package/src/components/checkout/cart-sync.ts +143 -0
- package/src/components/checkout/checkout-external-update-banner.tsx +15 -0
- package/src/components/checkout/checkout-form-controls.tsx +139 -0
- package/src/components/checkout/checkout-line-items.tsx +43 -0
- package/src/components/checkout/checkout-order-summary.tsx +156 -0
- package/src/components/checkout/checkout-payment-section.tsx +215 -0
- package/src/components/checkout/checkout-session-view.tsx +240 -0
- package/src/components/checkout/checkout-shipping-address-section.tsx +266 -0
- package/src/components/checkout/checkout-shipping-section.tsx +90 -0
- package/src/components/checkout/checkout-states.tsx +73 -0
- package/src/components/checkout/form-schema.ts +229 -0
- package/src/components/checkout/format.ts +27 -0
- package/src/components/checkout/index.ts +51 -0
- package/src/components/checkout/types.ts +43 -0
- package/src/components/checkout/use-checkout-geo.ts +50 -0
- package/src/components/checkout/use-checkout-session.ts +583 -0
- package/src/components/hero.tsx +291 -0
- package/src/config.d.ts +69 -0
- package/src/config.mjs +165 -0
- package/src/index.ts +274 -0
- package/src/link.tsx +2 -0
- package/src/pages/checkout.$checkoutId.tsx +60 -0
- package/src/pages/checkout.tsx +31 -0
- package/src/pages/thank-you.tsx +53 -0
- package/src/providers/auth.tsx +69 -0
- package/src/providers/cart.tsx +275 -0
- package/src/providers/ecomiq.ts +137 -0
- package/src/providers/index.ts +26 -0
- package/src/public-config.ts +44 -0
- package/src/review-submission.ts +89 -0
- package/src/runtime/context.ts +1 -0
- package/src/runtime/default-layout.tsx +80 -0
- package/src/runtime/head.ts +126 -0
- package/src/runtime/page.tsx +82 -0
- package/src/runtime/postcss-browser-stub.mjs +5 -0
- package/src/server-config-store.ts +26 -0
- package/src/server.ts +279 -0
- package/src/styles.css +76 -0
- package/src/ui/badge.tsx +32 -0
- package/src/ui/button.tsx +31 -0
- package/src/ui/card.tsx +31 -0
- package/src/ui/carousel.tsx +218 -0
- package/src/ui/cn.ts +6 -0
- package/src/ui/index.ts +8 -0
- package/src/ui/separator.tsx +6 -0
- package/src/ui/skeleton.tsx +6 -0
- package/src/ui/storefront-error.tsx +49 -0
- package/src/utils.ts +36 -0
- package/src/vite-inline.mjs +95 -0
- package/templates/__root.tsx +77 -0
- package/templates/router.tsx +19 -0
- package/dist/chunk-3QGB55R6.js +0 -157
- package/dist/chunk-3QGB55R6.js.map +0 -1
- package/dist/chunk-64GLOWMJ.js +0 -216
- package/dist/chunk-64GLOWMJ.js.map +0 -1
- package/dist/chunk-ASFU32ZM.js +0 -236
- package/dist/chunk-ASFU32ZM.js.map +0 -1
- package/dist/chunk-Q4U5OEUT.js +0 -21
- package/dist/chunk-Q4U5OEUT.js.map +0 -1
- package/dist/chunk-QRR7EPCH.js +0 -176
- package/dist/chunk-QRR7EPCH.js.map +0 -1
- package/dist/chunk-S7CPZ4UE.js +0 -6
- package/dist/chunk-S7CPZ4UE.js.map +0 -1
- package/dist/chunk-ZG2XUYGZ.js +0 -1281
- package/dist/chunk-ZG2XUYGZ.js.map +0 -1
- package/dist/chunks/client.css +0 -984
- package/dist/chunks/client.css.map +0 -1
- package/dist/chunks/client.d.ts +0 -10041
- package/dist/chunks/client.js +0 -5363
- package/dist/chunks/client.js.map +0 -1
- package/dist/chunks/config.d.ts +0 -31
- package/dist/chunks/config.js +0 -5
- package/dist/chunks/config.js.map +0 -1
- package/dist/chunks/core-public.d.ts +0 -130
- package/dist/chunks/core-public.js +0 -92
- package/dist/chunks/core-public.js.map +0 -1
- package/dist/chunks/server-nextjs.d.ts +0 -33
- package/dist/chunks/server-nextjs.js +0 -47
- package/dist/chunks/server-nextjs.js.map +0 -1
- package/dist/chunks/server-tanstack.d.ts +0 -22
- package/dist/chunks/server-tanstack.js +0 -34
- package/dist/chunks/server-tanstack.js.map +0 -1
- package/dist/collection-C6bWDLiw.d.ts +0 -3926
- package/dist/ecomiq-oauth-password-plugin-J5wDFqzL.d.ts +0 -3629
- package/dist/ecomiq-shell-ui-SWIFLQVM.js +0 -8
- package/dist/ecomiq-shell-ui-SWIFLQVM.js.map +0 -1
- package/dist/index.d.ts +0 -9
- package/dist/index.js +0 -1
- package/dist/index.js.map +0 -1
- package/dist/index.server.d.ts +0 -9
- package/dist/index.server.js +0 -8
- package/dist/index.server.js.map +0 -1
- package/dist/nextjs/api.d.ts +0 -45
- package/dist/nextjs/api.js +0 -32
- package/dist/nextjs/api.js.map +0 -1
- package/dist/nextjs/globals.css +0 -13
- package/dist/nextjs.d.ts +0 -2
- package/dist/nextjs.js +0 -2
- package/dist/nextjs.js.map +0 -1
- package/dist/nextjs.server.d.ts +0 -1
- package/dist/nextjs.server.js +0 -1
- package/dist/nextjs.server.js.map +0 -1
- package/dist/postcss.config.mjs +0 -6
- package/dist/react/checkout-page.css +0 -855
- package/dist/react/client.css +0 -984
- package/dist/react/payment-provider.css +0 -91
- package/dist/react/thank-you-page.css +0 -105
- package/dist/react.d.ts +0 -1
- package/dist/react.js +0 -2
- package/dist/react.js.map +0 -1
- package/dist/store-proxy-handler-CNzKlTFA.d.ts +0 -10
- package/dist/tanstack/api.d.ts +0 -41
- package/dist/tanstack/api.js +0 -30
- package/dist/tanstack/api.js.map +0 -1
- package/dist/tanstack.d.ts +0 -2
- package/dist/tanstack.js +0 -2
- package/dist/tanstack.js.map +0 -1
- package/dist/tanstack.server.d.ts +0 -1
- package/dist/tanstack.server.js +0 -1
- 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
|
+
}
|
package/src/auth/env.ts
ADDED
|
@@ -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
|
+
}
|