Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22f97ee12e | ||
|
|
cd8d2edd8f | ||
|
|
77389aea19 | ||
|
|
7ce11ecbb4 | ||
|
|
da10a4b688 | ||
|
|
0b7dc7b6de | ||
|
|
2601e5e83e | ||
|
|
fecac320d9 | ||
|
|
811e0e0887 | ||
|
|
aee6515890 | ||
|
|
2e47f7b55d | ||
|
|
54be99fa2c |
No files matched your search
@@ -23,7 +23,10 @@ ENV CI=true \
|
||||
S3_BUCKET=ari-shopping \
|
||||
S3_ENDPOINT=http://127.0.0.1:9000 \
|
||||
MEILISEARCH_HOST=http://127.0.0.1:7700 \
|
||||
MEILISEARCH_API_KEY=build-only-meili-key
|
||||
MEILISEARCH_API_KEY=build-only-meili-key \
|
||||
GOOGLE_CLIENT_ID=build-only-google-client-id \
|
||||
GOOGLE_CLIENT_SECRET=build-only-google-client-secret \
|
||||
GOOGLE_CALLBACK_URL=http://localhost:9000/auth/customer/google/callback
|
||||
|
||||
COPY package.json package-lock.json .npmrc ./
|
||||
|
||||
|
||||
@@ -157,6 +157,30 @@ module.exports = defineConfig({
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
resolve: "@medusajs/medusa/auth",
|
||||
options: {
|
||||
providers: [
|
||||
// emailpass se declara explicito junto con google -- una vez
|
||||
// que el modulo auth se customiza, la doc no deja claro si el
|
||||
// provider default (emailpass) se sigue registrando solo.
|
||||
{
|
||||
resolve: "@medusajs/medusa/auth-emailpass",
|
||||
id: "emailpass",
|
||||
options: {},
|
||||
},
|
||||
{
|
||||
resolve: "@medusajs/medusa/auth-google",
|
||||
id: "google",
|
||||
options: {
|
||||
clientId: required("GOOGLE_CLIENT_ID"),
|
||||
clientSecret: required("GOOGLE_CLIENT_SECRET"),
|
||||
callbackUrl: required("GOOGLE_CALLBACK_URL"),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
// ADR 0006 Etapa 2. Sin required(): a diferencia de S3/DB, Meilisearch
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import Medusa from "@medusajs/js-sdk";
|
||||
|
||||
// baseUrl "/" -- esta pagina se sirve dentro del mismo dashboard admin
|
||||
// (mismo origen que la API), no hace falta apuntar a otra URL. auth
|
||||
// "session" reusa la cookie de sesion ya autenticada del dashboard.
|
||||
export const sdk = new Medusa({
|
||||
baseUrl: "/",
|
||||
auth: { type: "session" },
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
import { defineRouteConfig } from "@medusajs/admin-sdk";
|
||||
import { Buildings } from "@medusajs/icons";
|
||||
import { Badge, Button, Checkbox, Container, Heading, Input, Switch, Text, toast } from "@medusajs/ui";
|
||||
import { useEffect, useState } from "react";
|
||||
import { sdk } from "../../lib/sdk";
|
||||
|
||||
// Panel simplificado para alguien no tecnico (la esposa del dueno del
|
||||
// negocio) -- una fila por producto (este catalogo es 1 variante por
|
||||
// producto, confirmado en vivo), switch para activar seguimiento de
|
||||
// stock y un input de cantidad que guarda solo. La vista nativa de
|
||||
// Medusa Admin (Inventory) sigue disponible para el dueno, esto no la
|
||||
// reemplaza, es un atajo.
|
||||
|
||||
const LOW_STOCK_THRESHOLD = 5;
|
||||
|
||||
type Row = {
|
||||
productId: string;
|
||||
variantId: string | null;
|
||||
title: string;
|
||||
thumbnail: string | null;
|
||||
sku: string | null;
|
||||
managed: boolean;
|
||||
quantity: number;
|
||||
};
|
||||
|
||||
async function fetchRows(): Promise<Row[]> {
|
||||
const data = await sdk.client.fetch<{ rows: Row[] }>("/admin/inventory-panel");
|
||||
return data.rows;
|
||||
}
|
||||
|
||||
async function saveRow(row: { productId: string; variantId: string; managed?: boolean; quantity?: number }) {
|
||||
await sdk.client.fetch("/admin/inventory-panel", { method: "post", body: row });
|
||||
}
|
||||
|
||||
const InventoryPanel = () => {
|
||||
const [rows, setRows] = useState<Row[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [savingId, setSavingId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRows().then((data) => {
|
||||
setRows(data);
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
function toggleSelected(productId: string, checked: boolean) {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (checked) next.add(productId);
|
||||
else next.delete(productId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function updateManaged(row: Row, managed: boolean) {
|
||||
if (!row.variantId) return;
|
||||
setSavingId(row.productId);
|
||||
try {
|
||||
await saveRow({ productId: row.productId, variantId: row.variantId, managed });
|
||||
setRows((prev) =>
|
||||
prev.map((r) => (r.productId === row.productId ? { ...r, managed } : r)),
|
||||
);
|
||||
} catch {
|
||||
toast.error("No se pudo guardar", { description: row.title });
|
||||
} finally {
|
||||
setSavingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateQuantity(row: Row, quantity: number) {
|
||||
if (!row.variantId || Number.isNaN(quantity) || quantity < 0) return;
|
||||
setSavingId(row.productId);
|
||||
try {
|
||||
await saveRow({ productId: row.productId, variantId: row.variantId, quantity });
|
||||
setRows((prev) =>
|
||||
prev.map((r) => (r.productId === row.productId ? { ...r, quantity } : r)),
|
||||
);
|
||||
toast.success("Stock actualizado", { description: row.title });
|
||||
} catch {
|
||||
toast.error("No se pudo guardar", { description: row.title });
|
||||
} finally {
|
||||
setSavingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function bulkSetManaged(managed: boolean) {
|
||||
const targets = rows.filter((r) => selected.has(r.productId) && r.variantId);
|
||||
for (const row of targets) {
|
||||
await updateManaged(row, managed);
|
||||
}
|
||||
setSelected(new Set());
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className="divide-y p-0">
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
<div>
|
||||
<Heading level="h2">Inventario</Heading>
|
||||
<Text size="small" className="text-ui-fg-subtle">
|
||||
Activa el seguimiento de stock y ajusta la cantidad disponible por producto.
|
||||
</Text>
|
||||
</div>
|
||||
{selected.size > 0 && (
|
||||
<div className="flex gap-2">
|
||||
<Button size="small" variant="secondary" onClick={() => bulkSetManaged(true)}>
|
||||
Activar seguimiento ({selected.size})
|
||||
</Button>
|
||||
<Button size="small" variant="secondary" onClick={() => bulkSetManaged(false)}>
|
||||
Desactivar seguimiento
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="px-6 py-8">
|
||||
<Text className="text-ui-fg-subtle">Cargando…</Text>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-ui-fg-subtle">
|
||||
<th className="w-10 px-6 py-3" />
|
||||
<th className="px-3 py-3 font-medium">Producto</th>
|
||||
<th className="px-3 py-3 font-medium">SKU</th>
|
||||
<th className="px-3 py-3 font-medium">Trackear stock</th>
|
||||
<th className="px-3 py-3 font-medium">Cantidad disponible</th>
|
||||
<th className="px-3 py-3 font-medium" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.productId} className="border-b last:border-0">
|
||||
<td className="px-6 py-3">
|
||||
<Checkbox
|
||||
checked={selected.has(row.productId)}
|
||||
onCheckedChange={(checked) => toggleSelected(row.productId, checked === true)}
|
||||
/>
|
||||
</td>
|
||||
<td className="flex items-center gap-3 px-3 py-3">
|
||||
{row.thumbnail ? (
|
||||
<img src={row.thumbnail} alt="" className="size-8 rounded object-cover" />
|
||||
) : (
|
||||
<div className="size-8 rounded bg-ui-bg-subtle" />
|
||||
)}
|
||||
<Text>{row.title}</Text>
|
||||
</td>
|
||||
<td className="px-3 py-3">
|
||||
<Text className="text-ui-fg-subtle">{row.sku ?? "—"}</Text>
|
||||
</td>
|
||||
<td className="px-3 py-3">
|
||||
<Switch
|
||||
checked={row.managed}
|
||||
disabled={savingId === row.productId}
|
||||
onCheckedChange={(checked) => updateManaged(row, checked)}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-3">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
defaultValue={row.quantity}
|
||||
disabled={!row.managed || savingId === row.productId}
|
||||
className="w-24"
|
||||
onBlur={(event) => {
|
||||
const value = Number(event.target.value);
|
||||
if (value !== row.quantity) updateQuantity(row, value);
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-3">
|
||||
{row.managed && row.quantity <= LOW_STOCK_THRESHOLD && (
|
||||
<Badge color="red">Stock bajo</Badge>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export const config = defineRouteConfig({
|
||||
label: "Inventario",
|
||||
icon: Buildings,
|
||||
});
|
||||
|
||||
export default InventoryPanel;
|
||||
@@ -0,0 +1,152 @@
|
||||
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework";
|
||||
import { ContainerRegistrationKeys } from "@medusajs/framework/utils";
|
||||
import {
|
||||
createInventoryItemsWorkflow,
|
||||
updateInventoryLevelsWorkflow,
|
||||
updateProductVariantsWorkflow,
|
||||
} from "@medusajs/medusa/core-flows";
|
||||
import { attachInventoryItemToVariantWorkflow } from "../../../workflows/attach-inventory-item-to-variant";
|
||||
|
||||
// Panel simplificado de inventario (pensado para un usuario no tecnico,
|
||||
// no reemplaza la vista nativa de Medusa Admin -- ver ADR/memoria del
|
||||
// bug de Firefox Android, tema aparte). Un producto = una variante en
|
||||
// este catalogo real (confirmado en vivo contra /store/products antes
|
||||
// de asumirlo), asi que la tabla trabaja a nivel de producto.
|
||||
|
||||
const PRODUCT_FIELDS = [
|
||||
"id",
|
||||
"title",
|
||||
"thumbnail",
|
||||
"status",
|
||||
"variants.id",
|
||||
"variants.sku",
|
||||
"variants.manage_inventory",
|
||||
"variants.inventory_items.inventory_item_id",
|
||||
"variants.inventory_items.inventory.location_levels.id",
|
||||
"variants.inventory_items.inventory.location_levels.location_id",
|
||||
"variants.inventory_items.inventory.location_levels.stocked_quantity",
|
||||
];
|
||||
|
||||
async function defaultLocationId(req: MedusaRequest): Promise<string | null> {
|
||||
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY);
|
||||
const { data: locations = [] } = await query.graph({
|
||||
entity: "stock_location",
|
||||
fields: ["id"],
|
||||
});
|
||||
return locations[0]?.id ?? null;
|
||||
}
|
||||
|
||||
export async function GET(req: MedusaRequest, res: MedusaResponse) {
|
||||
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY);
|
||||
const locationId = await defaultLocationId(req);
|
||||
|
||||
const { data: products = [] } = await query.graph({
|
||||
entity: "product",
|
||||
fields: PRODUCT_FIELDS,
|
||||
});
|
||||
|
||||
const rows = products.map((product) => {
|
||||
const variant = product.variants?.[0];
|
||||
const inventoryItem = variant?.inventory_items?.[0];
|
||||
const level = inventoryItem?.inventory?.location_levels?.find(
|
||||
(level: { location_id: string }) => level.location_id === locationId,
|
||||
);
|
||||
return {
|
||||
productId: product.id,
|
||||
variantId: variant?.id ?? null,
|
||||
title: product.title,
|
||||
thumbnail: product.thumbnail,
|
||||
status: product.status,
|
||||
sku: variant?.sku ?? null,
|
||||
managed: variant?.manage_inventory ?? false,
|
||||
quantity: level?.stocked_quantity ?? 0,
|
||||
};
|
||||
});
|
||||
|
||||
res.json({ rows, locationId });
|
||||
}
|
||||
|
||||
type PanelUpdateBody = {
|
||||
productId: string;
|
||||
variantId: string;
|
||||
managed?: boolean;
|
||||
quantity?: number;
|
||||
};
|
||||
|
||||
export async function POST(req: MedusaRequest<PanelUpdateBody>, res: MedusaResponse) {
|
||||
const { productId, variantId, managed, quantity } = req.body;
|
||||
if (!productId || !variantId) {
|
||||
res.status(400).json({ error: "productId y variantId son obligatorios" });
|
||||
return;
|
||||
}
|
||||
|
||||
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY);
|
||||
const locationId = await defaultLocationId(req);
|
||||
if (!locationId) {
|
||||
res.status(400).json({ error: "No hay una bodega (stock location) configurada" });
|
||||
return;
|
||||
}
|
||||
|
||||
const { data: products = [] } = await query.graph({
|
||||
entity: "product",
|
||||
fields: PRODUCT_FIELDS,
|
||||
filters: { id: productId },
|
||||
});
|
||||
const variant = products[0]?.variants?.find((v: { id: string }) => v.id === variantId);
|
||||
const inventoryItem = variant?.inventory_items?.[0];
|
||||
let inventoryItemId: string | undefined = inventoryItem?.inventory_item_id;
|
||||
|
||||
if (managed === true && !inventoryItemId) {
|
||||
// Primera vez que se activa el seguimiento para esta variante -- no
|
||||
// trae InventoryItem todavia (confirmado en vivo: togglear
|
||||
// manage_inventory=true en una variante existente NO crea uno solo,
|
||||
// a diferencia de crear un producto nuevo con manage_inventory
|
||||
// desde el inicio). Se crea con el nivel de stock inicial y se
|
||||
// vincula aparte.
|
||||
const { result: items } = await createInventoryItemsWorkflow(req.scope).run({
|
||||
input: {
|
||||
items: [
|
||||
{
|
||||
title: variant?.sku ? undefined : products[0]?.title,
|
||||
sku: variant?.sku ?? undefined,
|
||||
location_levels: [{ location_id: locationId, stocked_quantity: quantity ?? 0 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
inventoryItemId = items[0]?.id;
|
||||
if (inventoryItemId) {
|
||||
await attachInventoryItemToVariantWorkflow(req.scope).run({
|
||||
input: { inventoryItemId, variantId },
|
||||
});
|
||||
}
|
||||
await updateProductVariantsWorkflow(req.scope).run({
|
||||
input: { product_variants: [{ id: variantId, manage_inventory: true }] },
|
||||
});
|
||||
} else {
|
||||
if (typeof managed === "boolean") {
|
||||
await updateProductVariantsWorkflow(req.scope).run({
|
||||
input: { product_variants: [{ id: variantId, manage_inventory: managed }] },
|
||||
});
|
||||
}
|
||||
if (typeof quantity === "number" && inventoryItemId) {
|
||||
const level = inventoryItem?.inventory?.location_levels?.find(
|
||||
(level: { location_id: string }) => level.location_id === locationId,
|
||||
);
|
||||
await updateInventoryLevelsWorkflow(req.scope).run({
|
||||
input: {
|
||||
updates: [
|
||||
{
|
||||
id: level?.id,
|
||||
inventory_item_id: inventoryItemId,
|
||||
location_id: locationId,
|
||||
stocked_quantity: quantity,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { createWorkflow, WorkflowResponse } from "@medusajs/framework/workflows-sdk";
|
||||
import { attachInventoryItemToVariants } from "@medusajs/medusa/core-flows";
|
||||
|
||||
// attachInventoryItemToVariants es un step suelto (no un workflow
|
||||
// exportado) -- confirmado leyendo el .js real instalado
|
||||
// (@medusajs/core-flows/dist/inventory/steps/attach-inventory-items.js):
|
||||
// internamente resuelve ContainerRegistrationKeys.LINK y crea el link
|
||||
// Product<->Inventory. El campo "tag" del input es el variant_id (nombre
|
||||
// generico del step, no documentado con ese detalle).
|
||||
type Input = { inventoryItemId: string; variantId: string };
|
||||
|
||||
export const attachInventoryItemToVariantWorkflow = createWorkflow(
|
||||
"attach-inventory-item-to-variant",
|
||||
(input: Input) => {
|
||||
return new WorkflowResponse(
|
||||
attachInventoryItemToVariants([
|
||||
{ inventoryItemId: input.inventoryItemId, tag: input.variantId },
|
||||
]),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,14 @@
|
||||
import { handleGoogleCallback } from "@/lib/headless/providers/medusa-customer";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { code, state } = await request.json();
|
||||
if (!code || !state) {
|
||||
return Response.json({ error: "Faltan parámetros de Google" }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
const customer = await handleGoogleCallback(code, state);
|
||||
return Response.json(customer);
|
||||
} catch (error) {
|
||||
return Response.json({ error: (error as Error).message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { getGoogleAuthUrl } from "@/lib/headless/providers/medusa-customer";
|
||||
|
||||
// Navegacion de pagina completa (no fetch/XHR) -- el boton "Continuar
|
||||
// con Google" enlaza directo aca, el browser sigue el 302 hasta la
|
||||
// pantalla real de consentimiento de Google.
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const location = await getGoogleAuthUrl();
|
||||
return Response.redirect(location);
|
||||
} catch (error) {
|
||||
const message = encodeURIComponent((error as Error).message);
|
||||
return Response.redirect(new URL(`/?googleAuthError=${message}`, request.url));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Suspense } from "react";
|
||||
import { GoogleCallbackClient } from "@/components/account/google-callback-client";
|
||||
|
||||
export default function GoogleCallbackPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<GoogleCallbackClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -65,7 +65,7 @@ spec:
|
||||
memory: 64Mi
|
||||
|
||||
- name: migrations
|
||||
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.168
|
||||
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.215
|
||||
imagePullPolicy: IfNotPresent
|
||||
command:
|
||||
- node
|
||||
@@ -86,7 +86,7 @@ spec:
|
||||
|
||||
containers:
|
||||
- name: app
|
||||
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.168
|
||||
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.215
|
||||
imagePullPolicy: IfNotPresent
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
|
||||
@@ -10,6 +10,17 @@ import { useCustomerStore } from "@/store/customer-store";
|
||||
|
||||
type Mode = "login" | "register";
|
||||
|
||||
function GoogleIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 48 48" className="size-4" aria-hidden="true">
|
||||
<path fill="#FFC107" d="M43.6 20.5H42V20H24v8h11.3c-1.6 4.7-6.1 8-11.3 8-6.6 0-12-5.4-12-12s5.4-12 12-12c3.1 0 5.8 1.1 8 3l6-6C34 5.1 29.3 3 24 3 12.4 3 3 12.4 3 24s9.4 21 21 21 21-9.4 21-21c0-1.4-.1-2.7-.4-3.5z" />
|
||||
<path fill="#FF3D00" d="M6.3 14.7l6.6 4.8C14.6 15.9 18.9 13 24 13c3.1 0 5.8 1.1 8 3l6-6C34 5.1 29.3 3 24 3 16.1 3 9.3 7.6 6.3 14.7z" />
|
||||
<path fill="#4CAF50" d="M24 45c5.2 0 9.9-2 13.4-5.2l-6.2-5.2c-2 1.5-4.6 2.4-7.2 2.4-5.2 0-9.6-3.3-11.3-8l-6.5 5C9.2 40.3 16 45 24 45z" />
|
||||
<path fill="#1976D2" d="M43.6 20.5H42V20H24v8h11.3c-.8 2.3-2.2 4.3-4.1 5.6l6.2 5.2C41.4 35.9 45 30.5 45 24c0-1.4-.1-2.7-.4-3.5z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccountDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||
const { customer, hasHydrated, loading, error, login, register, logout } = useCustomerStore();
|
||||
const [mode, setMode] = useState<Mode>("login");
|
||||
@@ -57,6 +68,14 @@ export function AccountDialog({ open, onOpenChange }: { open: boolean; onOpenCha
|
||||
{loading ? "Un momento…" : mode === "login" ? "Iniciar sesión" : "Crear cuenta"}
|
||||
</Button>
|
||||
</form>
|
||||
<div className="mt-3 flex items-center gap-3 text-xs font-bold uppercase tracking-wide text-slate-400">
|
||||
<span className="h-px flex-1 bg-slate-100" />o<span className="h-px flex-1 bg-slate-100" />
|
||||
</div>
|
||||
<Button asChild variant="outline" size="lg" className="mt-3 w-full">
|
||||
<a href="/api/customer/google/start">
|
||||
<GoogleIcon />Continuar con Google
|
||||
</a>
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-3 text-sm font-bold text-ari-marino hover:underline"
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useCustomerStore } from "@/store/customer-store";
|
||||
|
||||
export function GoogleCallbackClient() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const loginWithGoogle = useCustomerStore((state) => state.loginWithGoogle);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const ran = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Guard contra el doble-mount de StrictMode -- el code de Google es
|
||||
// de un solo uso, un segundo intento con el mismo code fallaria.
|
||||
if (ran.current) return;
|
||||
ran.current = true;
|
||||
|
||||
const code = searchParams.get("code");
|
||||
const state = searchParams.get("state");
|
||||
if (!code || !state) {
|
||||
setError("Faltan parámetros de Google en la URL de retorno.");
|
||||
return;
|
||||
}
|
||||
|
||||
loginWithGoogle(code, state).then((ok) => {
|
||||
router.replace(ok ? "/" : "/?googleAuthError=1");
|
||||
});
|
||||
}, [searchParams, loginWithGoogle, router]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-3 text-center">
|
||||
{error ? (
|
||||
<>
|
||||
<p className="text-lg font-black text-rose-600">No se pudo iniciar sesión con Google</p>
|
||||
<p className="text-sm text-slate-600">{error}</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm font-bold text-slate-600">Conectando con Google…</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import type { Category, Product } from "@/lib/headless/types";
|
||||
import { useCartStore } from "@/store/cart-store";
|
||||
import { useCustomerStore } from "@/store/customer-store";
|
||||
import { useWishlistStore } from "@/store/wishlist-store";
|
||||
|
||||
type CategoryNode = Category & { children: Category[] };
|
||||
@@ -34,6 +35,9 @@ export function Navbar({ products, categories }: { products: Product[]; categori
|
||||
const openWishlist = useWishlistStore((state) => state.openWishlist);
|
||||
const count = cartHydrated ? items.reduce((sum, item) => sum + item.quantity, 0) : 0;
|
||||
const wishlistCount = wishlistHydrated ? wishlistProducts.length : 0;
|
||||
const customer = useCustomerStore((state) => state.customer);
|
||||
const customerHydrated = useCustomerStore((state) => state.hasHydrated);
|
||||
const isLoggedIn = customerHydrated && customer !== null;
|
||||
|
||||
const tree = useMemo(() => buildCategoryTree(categories), [categories]);
|
||||
// Derivado de los productos reales (no de un endpoint de colecciones):
|
||||
@@ -62,7 +66,15 @@ export function Navbar({ products, categories }: { products: Product[]; categori
|
||||
</Link>
|
||||
<PredictiveSearch />
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="icon" aria-label="Mi cuenta" className="hidden border-slate-300 bg-white text-slate-900 hover:border-ari-coral hover:bg-ari-coral/10 sm:inline-flex" onClick={() => setAccountOpen(true)}><UserRound /></Button>
|
||||
{/* Antes escondido en mobile (solo alcanzable desde el menu
|
||||
hamburguesa) -- pedido explicito del usuario: visible
|
||||
siempre en el header, con un punto verde cuando hay sesion
|
||||
iniciada (mismo patron que el badge de cantidad del
|
||||
carrito/favoritos, pero de presencia, no de conteo). */}
|
||||
<Button variant="outline" size="icon" aria-label={isLoggedIn ? "Mi cuenta (sesión iniciada)" : "Mi cuenta"} className="relative inline-flex border-slate-300 bg-white text-slate-900 hover:border-ari-coral hover:bg-ari-coral/10" onClick={() => setAccountOpen(true)}>
|
||||
<UserRound />
|
||||
{isLoggedIn && <span className="absolute -right-1 -top-1 size-3 rounded-full border-2 border-white bg-emerald-500" />}
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" aria-label={`Favoritos: ${wishlistCount}`} className="relative hidden border-slate-300 bg-white text-slate-900 hover:border-ari-coral hover:bg-ari-coral/10 sm:inline-flex" onClick={openWishlist}>
|
||||
<Heart />
|
||||
{wishlistCount > 0 && <span className="absolute -right-1.5 -top-1.5 grid size-5 place-items-center rounded-full bg-ari-marino text-[10px] font-black text-white">{wishlistCount}</span>}
|
||||
|
||||
@@ -25,7 +25,7 @@ spec:
|
||||
runAsUser: 1001
|
||||
containers:
|
||||
- name: app
|
||||
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.211
|
||||
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.213
|
||||
env:
|
||||
- name: HEADLESS_PROVIDER
|
||||
value: medusa
|
||||
|
||||
@@ -87,6 +87,56 @@ export async function loginCustomer(email: string, password: string): Promise<Cu
|
||||
return getCurrentCustomer(data.token as string);
|
||||
}
|
||||
|
||||
// Flujo Google verificado en vivo (2026-08-29) contra el pod real, no
|
||||
// solo contra la doc: GET /auth/customer/google funciona igual que el
|
||||
// POST documentado (sin body, no hace falta callback_url -- ya viene de
|
||||
// GOOGLE_CALLBACK_URL en el modulo); la respuesta no trae Set-Cookie, o
|
||||
// sea que el "state" que Google reenvia en el callback es autocontenido
|
||||
// (firmado), no depende de sesion de servidor -- confirmado con un
|
||||
// callback con code/state inventados: 401 "No state provided, or
|
||||
// session expired" (mismo mensaje que con state ausente, no un error de
|
||||
// sesion real). auth/token/refresh confirmado que exige Bearer valido
|
||||
// (401 "Unauthorized" con uno invalido, no 404).
|
||||
function decodeJwtPayload(token: string): Record<string, unknown> {
|
||||
const payload = token.split(".")[1] ?? "";
|
||||
const base64 = payload.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const json = Buffer.from(base64, "base64").toString("utf-8");
|
||||
return JSON.parse(json || "{}");
|
||||
}
|
||||
|
||||
export async function getGoogleAuthUrl(): Promise<string> {
|
||||
const data = await medusaAuthFetch("/auth/customer/google");
|
||||
return data.location as string;
|
||||
}
|
||||
|
||||
export async function handleGoogleCallback(code: string, state: string): Promise<CustomerSummary> {
|
||||
const query = new URLSearchParams({ code, state }).toString();
|
||||
const data = await medusaAuthFetch(`/auth/customer/google/callback?${query}`, { method: "POST" });
|
||||
const initialToken = data.token as string;
|
||||
const payload = decodeJwtPayload(initialToken);
|
||||
const actorId = payload.actor_id as string | undefined;
|
||||
|
||||
if (!actorId) {
|
||||
// Primer login con Google -- todavia no existe el customer real.
|
||||
const userMetadata = (payload.user_metadata as Record<string, unknown> | undefined) ?? {};
|
||||
const email = userMetadata.email as string;
|
||||
const firstName = (userMetadata.given_name as string) ?? (userMetadata.name as string) ?? email.split("@")[0];
|
||||
const lastName = (userMetadata.family_name as string) ?? "";
|
||||
await medusaAuthFetch("/store/customers", {
|
||||
method: "POST",
|
||||
token: initialToken,
|
||||
body: { email, first_name: firstName, last_name: lastName },
|
||||
});
|
||||
}
|
||||
|
||||
// El token del callback (o el de recien crear el customer) todavia no
|
||||
// sirve como sesion final -- hay que refrescarlo, tanto para usuarios
|
||||
// nuevos como para los que ya existian (confirmado contra la doc: los
|
||||
// dos casos convergen en el mismo paso de refresh).
|
||||
const refreshed = await medusaAuthFetch("/auth/token/refresh", { method: "POST", token: initialToken });
|
||||
return getCurrentCustomer(refreshed.token as string);
|
||||
}
|
||||
|
||||
export async function registerCustomer(
|
||||
email: string,
|
||||
password: string,
|
||||
|
||||
@@ -23,6 +23,7 @@ interface CustomerState {
|
||||
setHasHydrated: (value: boolean) => void;
|
||||
register: (email: string, password: string, firstName: string, lastName: string) => Promise<boolean>;
|
||||
login: (email: string, password: string) => Promise<boolean>;
|
||||
loginWithGoogle: (code: string, state: string) => Promise<boolean>;
|
||||
logout: () => void;
|
||||
syncCustomer: () => Promise<void>;
|
||||
}
|
||||
@@ -77,6 +78,17 @@ export const useCustomerStore = create<CustomerState>()(
|
||||
return true;
|
||||
},
|
||||
|
||||
loginWithGoogle: async (code, state) => {
|
||||
set({ loading: true, error: null });
|
||||
const result = await postJson("/api/customer/google/callback", { code, state });
|
||||
if (result.error) {
|
||||
set({ loading: false, error: result.error });
|
||||
return false;
|
||||
}
|
||||
set({ loading: false, token: result.token, customer: toCustomer(result) });
|
||||
return true;
|
||||
},
|
||||
|
||||
logout: () => set({ token: null, customer: null, error: null }),
|
||||
|
||||
// El token guardado en localStorage puede llevar dias o haber sido
|
||||
|
||||
Reference in new issue
Block a user