From c53fb99330200f57163fe315f448758ed1a4ac08 Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Tue, 25 Aug 2026 16:41:00 +0330 Subject: [PATCH] Add shared customer auth, cart handoff, and account dropdown. Mirror Meshkee customer sessions onto the storefront, continue checkout on the customer dashboard, and show profile/orders/admin links under the header name. Co-authored-by: Cursor --- src/app/api/addresses/route.ts | 34 ++ src/app/api/auth/handoff/route.ts | 20 ++ src/app/api/auth/login/route.ts | 25 ++ src/app/api/auth/me/route.ts | 15 + src/app/api/auth/register/route.ts | 36 ++ src/app/api/cart/checkout/route.ts | 29 ++ src/app/api/cart/items/[itemId]/route.ts | 47 +++ src/app/api/cart/items/route.ts | 32 ++ src/app/api/cart/route.ts | 34 ++ src/app/api/payments/methods/route.ts | 19 + src/app/checkout/cart/page.tsx | 16 + src/app/checkout/complete/page.tsx | 33 ++ src/app/checkout/page.tsx | 18 + src/app/layout.tsx | 13 +- src/app/products/[slug]/page.tsx | 3 + src/components/cart/CartPopup.tsx | 208 +++++++++++ src/components/checkout/CheckoutCartPage.tsx | 132 +++++++ src/components/checkout/CheckoutPage.tsx | 311 ++++++++++++++++ src/components/home/Header.tsx | 265 ++++++++++++-- .../product/ProductPurchasePanel.tsx | 50 ++- src/components/providers/AppProviders.tsx | 18 + src/components/ui/Toast.tsx | 30 ++ src/lib/auth.tsx | 298 +++++++++++++++ src/lib/cart-types.ts | 114 ++++++ src/lib/cart.tsx | 339 ++++++++++++++++++ src/lib/customer-auth.ts | 72 ++++ src/lib/guest-cart.ts | 119 ++++++ src/lib/meshkee-server.ts | 53 +++ src/lib/meshkee.ts | 18 +- src/lib/shared-auth-cookie.ts | 123 +++++++ 30 files changed, 2492 insertions(+), 32 deletions(-) create mode 100644 src/app/api/addresses/route.ts create mode 100644 src/app/api/auth/handoff/route.ts create mode 100644 src/app/api/auth/login/route.ts create mode 100644 src/app/api/auth/me/route.ts create mode 100644 src/app/api/auth/register/route.ts create mode 100644 src/app/api/cart/checkout/route.ts create mode 100644 src/app/api/cart/items/[itemId]/route.ts create mode 100644 src/app/api/cart/items/route.ts create mode 100644 src/app/api/cart/route.ts create mode 100644 src/app/api/payments/methods/route.ts create mode 100644 src/app/checkout/cart/page.tsx create mode 100644 src/app/checkout/complete/page.tsx create mode 100644 src/app/checkout/page.tsx create mode 100644 src/components/cart/CartPopup.tsx create mode 100644 src/components/checkout/CheckoutCartPage.tsx create mode 100644 src/components/checkout/CheckoutPage.tsx create mode 100644 src/components/providers/AppProviders.tsx create mode 100644 src/components/ui/Toast.tsx create mode 100644 src/lib/auth.tsx create mode 100644 src/lib/cart-types.ts create mode 100644 src/lib/cart.tsx create mode 100644 src/lib/customer-auth.ts create mode 100644 src/lib/guest-cart.ts create mode 100644 src/lib/meshkee-server.ts create mode 100644 src/lib/shared-auth-cookie.ts diff --git a/src/app/api/addresses/route.ts b/src/app/api/addresses/route.ts new file mode 100644 index 0000000..69c0a08 --- /dev/null +++ b/src/app/api/addresses/route.ts @@ -0,0 +1,34 @@ +import { + getBearerToken, + jsonResponse, + meshkeeFetch, + unauthorizedResponse, +} from "@/lib/meshkee-server"; + +export async function GET(request: Request) { + const token = getBearerToken(request); + if (!token) return unauthorizedResponse(); + + const res = await meshkeeFetch("/auth/addresses", { token }); + if (!res.ok) return jsonResponse(res.data ?? { error: "Failed" }, res.status); + return jsonResponse(res.data); +} + +export async function POST(request: Request) { + const token = getBearerToken(request); + if (!token) return unauthorizedResponse(); + + const body = await request.json().catch(() => null); + if (!body?.province || !body?.city || !body?.address) { + return jsonResponse({ error: "آدرس کامل الزامی است" }, 400); + } + + const res = await meshkeeFetch("/auth/addresses", { + method: "POST", + token, + body: JSON.stringify(body), + }); + + if (!res.ok) return jsonResponse(res.data ?? { error: "Failed" }, res.status); + return jsonResponse(res.data, res.status === 201 ? 201 : 200); +} diff --git a/src/app/api/auth/handoff/route.ts b/src/app/api/auth/handoff/route.ts new file mode 100644 index 0000000..af41b78 --- /dev/null +++ b/src/app/api/auth/handoff/route.ts @@ -0,0 +1,20 @@ +import { getBearerToken, jsonResponse, meshkeeFetch, unauthorizedResponse } from "@/lib/meshkee-server"; + +export async function POST(request: Request) { + const token = getBearerToken(request); + if (!token) return unauthorizedResponse(); + + const res = await meshkeeFetch("/auth/handoff", { + method: "POST", + token, + }); + + if (!res.ok) { + return jsonResponse( + { error: "امکان باز کردن پنل مدیریت نیست." }, + res.status, + ); + } + + return jsonResponse(res.data); +} diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts new file mode 100644 index 0000000..d7b8538 --- /dev/null +++ b/src/app/api/auth/login/route.ts @@ -0,0 +1,25 @@ +import { jsonResponse, meshkeeFetch } from "@/lib/meshkee-server"; + +export async function POST(request: Request) { + const body = await request.json().catch(() => null); + if (!body?.cellNumber || !body?.password) { + return jsonResponse({ error: "شماره موبایل و رمز عبور الزامی است" }, 400); + } + + const res = await meshkeeFetch("/auth/login", { + method: "POST", + body: JSON.stringify({ + cellNumber: body.cellNumber, + password: body.password, + }), + }); + + if (!res.ok) { + return jsonResponse( + { error: "ورود ناموفق بود. اطلاعات را بررسی کنید." }, + res.status, + ); + } + + return jsonResponse(res.data); +} diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts new file mode 100644 index 0000000..02cf2e4 --- /dev/null +++ b/src/app/api/auth/me/route.ts @@ -0,0 +1,15 @@ +import { + getBearerToken, + jsonResponse, + meshkeeFetch, + unauthorizedResponse, +} from "@/lib/meshkee-server"; + +export async function GET(request: Request) { + const token = getBearerToken(request); + if (!token) return unauthorizedResponse(); + + const res = await meshkeeFetch("/auth/me", { token }); + if (!res.ok) return jsonResponse({ error: "Unauthorized" }, res.status); + return jsonResponse(res.data); +} diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts new file mode 100644 index 0000000..7e73324 --- /dev/null +++ b/src/app/api/auth/register/route.ts @@ -0,0 +1,36 @@ +import { DOMAIN } from "@/lib/meshkee"; +import { jsonResponse, meshkeeFetch } from "@/lib/meshkee-server"; + +export async function POST(request: Request) { + const body = await request.json().catch(() => null); + if ( + !body?.cellNumber || + !body?.password || + !body?.firstName || + !body?.lastName + ) { + return jsonResponse({ error: "لطفاً همه فیلدهای الزامی را پر کنید" }, 400); + } + + const res = await meshkeeFetch("/auth/register", { + method: "POST", + body: JSON.stringify({ + cellNumber: body.cellNumber, + password: body.password, + firstName: body.firstName, + lastName: body.lastName, + email: body.email, + domain: DOMAIN, + acknowledgeExistingAccount: body.acknowledgeExistingAccount ?? false, + }), + }); + + if (!res.ok) { + return jsonResponse( + { error: "ثبت‌نام ناموفق بود. اطلاعات را بررسی کنید." }, + res.status, + ); + } + + return jsonResponse(res.data, 201); +} diff --git a/src/app/api/cart/checkout/route.ts b/src/app/api/cart/checkout/route.ts new file mode 100644 index 0000000..1b49d5e --- /dev/null +++ b/src/app/api/cart/checkout/route.ts @@ -0,0 +1,29 @@ +import { + getBearerToken, + jsonResponse, + meshkeeFetch, + resolveBusinessId, + unauthorizedResponse, +} from "@/lib/meshkee-server"; + +export async function POST(request: Request) { + const token = getBearerToken(request); + if (!token) return unauthorizedResponse(); + + const body = await request.json().catch(() => null); + if (!body?.payment) { + return jsonResponse({ error: "payment is required" }, 400); + } + + const businessId = await resolveBusinessId(); + if (!businessId) return jsonResponse({ error: "Tenant not found" }, 404); + + const res = await meshkeeFetch(`/businesses/${businessId}/cart/checkout`, { + method: "POST", + token, + body: JSON.stringify(body), + }); + + if (!res.ok) return jsonResponse(res.data ?? { error: "Checkout failed" }, res.status); + return jsonResponse(res.data, res.status === 201 ? 201 : 200); +} diff --git a/src/app/api/cart/items/[itemId]/route.ts b/src/app/api/cart/items/[itemId]/route.ts new file mode 100644 index 0000000..1f03537 --- /dev/null +++ b/src/app/api/cart/items/[itemId]/route.ts @@ -0,0 +1,47 @@ +import { + getBearerToken, + jsonResponse, + meshkeeFetch, + resolveBusinessId, + unauthorizedResponse, +} from "@/lib/meshkee-server"; + +type RouteContext = { params: Promise<{ itemId: string }> }; + +export async function PATCH(request: Request, context: RouteContext) { + const token = getBearerToken(request); + if (!token) return unauthorizedResponse(); + + const { itemId } = await context.params; + const body = await request.json().catch(() => null); + if (!body?.quantity) return jsonResponse({ error: "quantity is required" }, 400); + + const businessId = await resolveBusinessId(); + if (!businessId) return jsonResponse({ error: "Tenant not found" }, 404); + + const res = await meshkeeFetch(`/businesses/${businessId}/cart/items/${itemId}`, { + method: "PATCH", + token, + body: JSON.stringify({ quantity: body.quantity }), + }); + + if (!res.ok) return jsonResponse(res.data ?? { error: "Update failed" }, res.status); + return jsonResponse(res.data); +} + +export async function DELETE(_request: Request, context: RouteContext) { + const token = getBearerToken(_request); + if (!token) return unauthorizedResponse(); + + const { itemId } = await context.params; + const businessId = await resolveBusinessId(); + if (!businessId) return jsonResponse({ error: "Tenant not found" }, 404); + + const res = await meshkeeFetch(`/businesses/${businessId}/cart/items/${itemId}`, { + method: "DELETE", + token, + }); + + if (!res.ok) return jsonResponse(res.data ?? { error: "Remove failed" }, res.status); + return jsonResponse(res.data); +} diff --git a/src/app/api/cart/items/route.ts b/src/app/api/cart/items/route.ts new file mode 100644 index 0000000..0380568 --- /dev/null +++ b/src/app/api/cart/items/route.ts @@ -0,0 +1,32 @@ +import { + getBearerToken, + jsonResponse, + meshkeeFetch, + resolveBusinessId, + unauthorizedResponse, +} from "@/lib/meshkee-server"; + +export async function POST(request: Request) { + const token = getBearerToken(request); + if (!token) return unauthorizedResponse(); + + const body = await request.json().catch(() => null); + if (!body?.storeItemVariantId) { + return jsonResponse({ error: "storeItemVariantId is required" }, 400); + } + + const businessId = await resolveBusinessId(); + if (!businessId) return jsonResponse({ error: "Tenant not found" }, 404); + + const res = await meshkeeFetch(`/businesses/${businessId}/cart/items`, { + method: "POST", + token, + body: JSON.stringify({ + storeItemVariantId: body.storeItemVariantId, + quantity: body.quantity ?? 1, + }), + }); + + if (!res.ok) return jsonResponse(res.data ?? { error: "Add to cart failed" }, res.status); + return jsonResponse(res.data, res.status === 201 ? 201 : 200); +} diff --git a/src/app/api/cart/route.ts b/src/app/api/cart/route.ts new file mode 100644 index 0000000..2a452c3 --- /dev/null +++ b/src/app/api/cart/route.ts @@ -0,0 +1,34 @@ +import { + getBearerToken, + jsonResponse, + meshkeeFetch, + resolveBusinessId, + unauthorizedResponse, +} from "@/lib/meshkee-server"; + +export async function GET(request: Request) { + const token = getBearerToken(request); + if (!token) return unauthorizedResponse(); + + const businessId = await resolveBusinessId(); + if (!businessId) return jsonResponse({ error: "Tenant not found" }, 404); + + const res = await meshkeeFetch(`/businesses/${businessId}/cart`, { token }); + if (!res.ok) return jsonResponse(res.data ?? { error: "Cart error" }, res.status); + return jsonResponse(res.data); +} + +export async function DELETE(request: Request) { + const token = getBearerToken(request); + if (!token) return unauthorizedResponse(); + + const businessId = await resolveBusinessId(); + if (!businessId) return jsonResponse({ error: "Tenant not found" }, 404); + + const res = await meshkeeFetch(`/businesses/${businessId}/cart`, { + method: "DELETE", + token, + }); + if (!res.ok) return jsonResponse(res.data ?? { error: "Cart error" }, res.status); + return jsonResponse(res.data); +} diff --git a/src/app/api/payments/methods/route.ts b/src/app/api/payments/methods/route.ts new file mode 100644 index 0000000..649415f --- /dev/null +++ b/src/app/api/payments/methods/route.ts @@ -0,0 +1,19 @@ +import { + getBearerToken, + jsonResponse, + meshkeeFetch, + resolveBusinessId, + unauthorizedResponse, +} from "@/lib/meshkee-server"; + +export async function GET(request: Request) { + const token = getBearerToken(request); + if (!token) return unauthorizedResponse(); + + const businessId = await resolveBusinessId(); + if (!businessId) return jsonResponse({ error: "Tenant not found" }, 404); + + const res = await meshkeeFetch(`/businesses/${businessId}/payments/methods`, { token }); + if (!res.ok) return jsonResponse(res.data ?? { error: "Failed" }, res.status); + return jsonResponse(res.data); +} diff --git a/src/app/checkout/cart/page.tsx b/src/app/checkout/cart/page.tsx new file mode 100644 index 0000000..750e16c --- /dev/null +++ b/src/app/checkout/cart/page.tsx @@ -0,0 +1,16 @@ +import { Breadcrumb } from "@/components/ui/Breadcrumb"; +import { CheckoutCartPage } from "@/components/checkout/CheckoutCartPage"; + +export default function CheckoutCartRoute() { + return ( +
+ + +
+ ); +} diff --git a/src/app/checkout/complete/page.tsx b/src/app/checkout/complete/page.tsx new file mode 100644 index 0000000..11fc57d --- /dev/null +++ b/src/app/checkout/complete/page.tsx @@ -0,0 +1,33 @@ +import Link from "next/link"; +import { Breadcrumb } from "@/components/ui/Breadcrumb"; + +export default async function CheckoutCompletePage({ + searchParams, +}: { + searchParams: Promise<{ status?: string; orderId?: string }>; +}) { + const { status, orderId } = await searchParams; + const failed = status === "failed"; + + return ( +
+ +
+

+ {failed ? "پرداخت ناموفق بود" : "سفارش شما ثبت شد"} +

+ {orderId && ( +

+ شماره سفارش: {new Intl.NumberFormat("fa-IR").format(Number(orderId) || 0) || orderId} +

+ )} + + بازگشت به فروشگاه + +
+
+ ); +} diff --git a/src/app/checkout/page.tsx b/src/app/checkout/page.tsx new file mode 100644 index 0000000..a98e097 --- /dev/null +++ b/src/app/checkout/page.tsx @@ -0,0 +1,18 @@ +import { Breadcrumb } from "@/components/ui/Breadcrumb"; +import { CheckoutPage } from "@/components/checkout/CheckoutPage"; + +export default function CheckoutRoute() { + return ( +
+ +

تسویه حساب

+ +
+ ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index dca80ce..7230952 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -5,6 +5,8 @@ import "./globals.css"; import { TopBar } from "@/components/home/TopBar"; import { Header } from "@/components/home/Header"; import { Footer } from "@/components/home/Footer"; +import { AppProviders } from "@/components/providers/AppProviders"; +import { Toast } from "@/components/ui/Toast"; import { getBusinessInfo, getCategories, getCategoryTree } from "@/lib/meshkee"; // Body text — headings use the bold weight instead, see globals.css. @@ -45,10 +47,13 @@ export default async function RootLayout({ children }: LayoutProps<"/">) { className={`${iranyekan.variable} ${iranyekanBold.variable} ${syne.variable} h-full antialiased`} > - -
-
{children}
-