Add shopping cart, store-items pages, contact form toast; skip workshops+portfolio
Neither workshops nor portfolio is in this tenant's enabledModules
(["products","blog","finance"]) - same reasoning as before, page/nav entry
withheld until either is actually enabled.
Shopping cart (new):
- CartProvider (client context): live cart state, add/update/remove/refresh,
reads AUTH_TOKEN_COOKIE, calls the real /businesses/{id}/cart* endpoints
(bearer-only, no guest cart - by design, not a gap)
- CartButton: header/bottom-nav icon with a live count badge and a popup
preview; "ادامه" hands off to the customer portal's own cart/checkout,
since this storefront has no checkout UI (address/payment) to hand off to
internally - out of scope for this pass
- ProductBuyPanel: quantity stepper, wired to CartProvider.addItem() instead
of its own ad-hoc fetch+token logic, toast on success
- Cart/CartItem types are a best-effort guess at an undocumented response
shape (OpenAPI gives no field-level schema) - unverified against real data
since store-items are still empty; read every field defensively
Store-items pages (new): /store-items (flat listing of purchasable variants,
always priced unlike /products) and /store-items/{id} (detail - no slug,
resolves the variant's productId and reuses the product's own gallery/
description/technical-info/comments/related-products, just with that
variant preselected).
Contact form: now shows a toast + inline success text and resets while
staying on screen, instead of swapping to a separate "thank you" panel.
New shared providers: ToastProvider (useToast) and CartProvider, both
mounted once in layout.tsx.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
e676397bae
commit
d624ad8a32
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { CartIcon as CartGlyph } from "./icons";
|
||||
import { useCart } from "./providers/CartProvider";
|
||||
import { CUSTOMER_PORTAL_URL } from "@/lib/config";
|
||||
import { formatPrice, toFaDigits } from "@/lib/format";
|
||||
import type { AuthUser } from "@/lib/types";
|
||||
|
||||
// Header/bottom-nav cart control: badge with the live item count, a popup
|
||||
// previewing the cart, and a "ادامه" button that hands off to the customer
|
||||
// portal to actually complete checkout (address + payment aren't built on
|
||||
// this storefront — see AGENTS.md "Shopping cart").
|
||||
export default function CartButton({
|
||||
user,
|
||||
className = "",
|
||||
labelClassName = "",
|
||||
showLabel = false,
|
||||
placement = "down",
|
||||
wrapperClassName = "relative",
|
||||
}: {
|
||||
user: AuthUser | null;
|
||||
className?: string;
|
||||
labelClassName?: string;
|
||||
showLabel?: boolean;
|
||||
/** "up" for the mobile bottom nav, where the button sits at the screen edge. */
|
||||
placement?: "down" | "up";
|
||||
/** The bottom nav needs `flex-1` on this wrapper (not just the button) to
|
||||
* match its three sibling nav items' width — the header doesn't. */
|
||||
wrapperClassName?: string;
|
||||
}) {
|
||||
const { cart, count, loading } = useCart();
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (!ref.current?.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onClick);
|
||||
return () => document.removeEventListener("mousedown", onClick);
|
||||
}, []);
|
||||
|
||||
const items = cart?.items ?? [];
|
||||
|
||||
return (
|
||||
<div ref={ref} className={wrapperClassName}>
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
aria-label="سبد خرید"
|
||||
className={className}
|
||||
>
|
||||
<span className="relative inline-flex">
|
||||
<CartGlyph className="size-5" />
|
||||
{count > 0 && (
|
||||
<span className="absolute -left-2 -top-2 flex size-4 items-center justify-center rounded-full bg-white text-[10px] font-bold text-brand">
|
||||
{toFaDigits(count > 9 ? "9+" : count)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{showLabel && <span className={labelClassName}>سبد خرید</span>}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
// "up" (mobile bottom nav) uses `fixed` against the viewport, not
|
||||
// `absolute` against this button's own flex-1 column (~1/4 of a
|
||||
// 4-item row) — an inset anchored to that narrow column would
|
||||
// squeeze the whole cart preview into it instead of spanning
|
||||
// near-full width above the bar.
|
||||
className={`z-50 overflow-hidden rounded-xl border border-line bg-white shadow-xl ${
|
||||
placement === "up"
|
||||
? "fixed inset-x-4 bottom-[76px]"
|
||||
: "absolute left-0 top-full mt-2 w-80"
|
||||
}`}
|
||||
>
|
||||
<div className="border-b border-line px-4 py-3">
|
||||
<h3 className="text-[14px] font-bold text-ink">سبد خرید</h3>
|
||||
</div>
|
||||
|
||||
{!user ? (
|
||||
<div className="flex flex-col items-center gap-3 p-6 text-center">
|
||||
<p className="text-[13px] text-body">برای مشاهده سبد خرید ابتدا وارد شوید.</p>
|
||||
<Link
|
||||
href={CUSTOMER_PORTAL_URL}
|
||||
onClick={() => setOpen(false)}
|
||||
className="rounded-lg bg-brand px-5 py-2 text-[13px] font-bold text-white transition hover:bg-brand-dark"
|
||||
>
|
||||
ورود | ثبتنام
|
||||
</Link>
|
||||
</div>
|
||||
) : loading ? (
|
||||
<p className="p-6 text-center text-[13px] text-body">در حال بارگذاری…</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="p-6 text-center text-[13px] text-body">سبد خرید شما خالی است.</p>
|
||||
) : (
|
||||
<>
|
||||
<ul className="max-h-80 divide-y divide-line overflow-auto">
|
||||
{items.map((item) => {
|
||||
const title = item.productNameFa || item.productTitle || "کالا";
|
||||
const price = item.discountedPrice ?? item.price ?? null;
|
||||
return (
|
||||
<li key={item.id} className="flex items-center gap-3 px-4 py-3">
|
||||
<span className="relative size-12 shrink-0 overflow-hidden rounded-lg bg-surface">
|
||||
{item.image && (
|
||||
<Image src={item.image} alt={title} fill sizes="48px" className="object-contain" />
|
||||
)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="clamp-2 block text-[12.5px] leading-5 text-ink-soft">{title}</span>
|
||||
<span className="mt-0.5 block text-[11px] text-body">
|
||||
{toFaDigits(item.quantity)} عدد
|
||||
</span>
|
||||
</span>
|
||||
{price !== null && (
|
||||
<span className="shrink-0 text-[12px] font-bold text-ink">{formatPrice(price)}</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<div className="border-t border-line p-4">
|
||||
{cart?.total != null && (
|
||||
<div className="mb-3 flex items-center justify-between text-[13px]">
|
||||
<span className="text-body">جمع کل</span>
|
||||
<span className="font-bold text-ink">
|
||||
{formatPrice(cart.total)}
|
||||
<span className="mr-1 font-normal text-body">تومان</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Link
|
||||
href={`${CUSTOMER_PORTAL_URL}/cart`}
|
||||
onClick={() => setOpen(false)}
|
||||
className="flex h-11 w-full items-center justify-center rounded-lg bg-brand text-[13px] font-bold text-white transition hover:bg-brand-dark"
|
||||
>
|
||||
ادامه
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,22 +2,28 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { submitContactForm } from "@/lib/api";
|
||||
import { useToast } from "./providers/ToastProvider";
|
||||
import { CheckIcon } from "./icons";
|
||||
|
||||
type Status = "idle" | "submitting" | "sent" | "error";
|
||||
|
||||
// Posts straight to POST /tenants/moalem.shop/contact-submissions — a real,
|
||||
// documented endpoint, no bearer required.
|
||||
// documented endpoint, no bearer required. On success: a toast, inline
|
||||
// success text, and the form resets and stays put (not swapped for a
|
||||
// separate "thank you" panel) so another message can be sent right away.
|
||||
export default function ContactForm() {
|
||||
const [status, setStatus] = useState<Status>("idle");
|
||||
const { showToast } = useToast();
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
const form = new FormData(e.currentTarget);
|
||||
const name = String(form.get("name") || "").trim();
|
||||
const title = String(form.get("title") || "").trim();
|
||||
const text = String(form.get("text") || "").trim();
|
||||
const email = String(form.get("email") || "").trim();
|
||||
const cellNumber = String(form.get("cellNumber") || "").trim();
|
||||
const form = e.currentTarget;
|
||||
const data = new FormData(form);
|
||||
const name = String(data.get("name") || "").trim();
|
||||
const title = String(data.get("title") || "").trim();
|
||||
const text = String(data.get("text") || "").trim();
|
||||
const email = String(data.get("email") || "").trim();
|
||||
const cellNumber = String(data.get("cellNumber") || "").trim();
|
||||
if (!name || !title || !text) return;
|
||||
|
||||
setStatus("submitting");
|
||||
@@ -29,33 +35,19 @@ export default function ContactForm() {
|
||||
email: email || undefined,
|
||||
cellNumber: cellNumber || undefined,
|
||||
});
|
||||
form.reset();
|
||||
setStatus("sent");
|
||||
e.currentTarget.reset();
|
||||
showToast("پیام شما با موفقیت ارسال شد");
|
||||
} catch {
|
||||
setStatus("error");
|
||||
}
|
||||
}
|
||||
|
||||
if (status === "sent") {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 rounded-2xl border border-line bg-surface p-10 text-center">
|
||||
<p className="text-[15px] font-bold text-ink">پیام شما با موفقیت ارسال شد.</p>
|
||||
<p className="text-[13px] text-body">همکاران ما در اسرع وقت با شما تماس میگیرند.</p>
|
||||
<button
|
||||
onClick={() => setStatus("idle")}
|
||||
className="mt-2 rounded-lg border border-line px-4 py-2 text-[13px] text-ink-soft transition hover:border-brand hover:text-brand"
|
||||
>
|
||||
ارسال پیام دیگر
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const input =
|
||||
"h-12 w-full rounded-xl border border-line bg-white px-4 text-sm outline-none transition focus:border-brand";
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<form onSubmit={handleSubmit} onChange={() => status === "sent" && setStatus("idle")} className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<input name="name" required placeholder="نام و نام خانوادگی" className={input} />
|
||||
<input name="cellNumber" placeholder="شماره موبایل" inputMode="tel" className={input} />
|
||||
@@ -75,6 +67,12 @@ export default function ContactForm() {
|
||||
{status === "error" && (
|
||||
<p className="text-[12.5px] text-brand">ارسال پیام ناموفق بود. لطفاً دوباره تلاش کنید.</p>
|
||||
)}
|
||||
{status === "sent" && (
|
||||
<p className="flex items-center gap-1.5 text-[12.5px] font-bold text-brand">
|
||||
<CheckIcon className="size-4" />
|
||||
پیام شما ارسال شد. همکاران ما در اسرع وقت با شما تماس میگیرند.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
@@ -5,9 +5,9 @@ import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import SearchBox from "./SearchBox";
|
||||
import AccountMenu from "./AccountMenu";
|
||||
import { CartIcon, ChevronDown, GridIcon, PhoneIcon } from "./icons";
|
||||
import CartButton from "./CartButton";
|
||||
import { ChevronDown, GridIcon, PhoneIcon } from "./icons";
|
||||
import { MAIN_NAV, PHONE, PHONE_HREF, SITE_NAME } from "@/data/site";
|
||||
import { CUSTOMER_PORTAL_URL } from "@/lib/config";
|
||||
import { categoryHref } from "@/lib/slug";
|
||||
import type { AuthUser, CategoryNode } from "@/lib/types";
|
||||
|
||||
@@ -51,14 +51,10 @@ export default function Header({
|
||||
|
||||
<div className="mr-auto hidden items-center gap-2 lg:flex">
|
||||
<AccountMenu user={user} />
|
||||
<Link
|
||||
href={`${CUSTOMER_PORTAL_URL}/cart`}
|
||||
<CartButton
|
||||
user={user}
|
||||
className="flex h-11 items-center gap-2 rounded-xl bg-brand px-3 text-[13px] text-white transition hover:bg-brand-dark"
|
||||
aria-label="سبد خرید"
|
||||
>
|
||||
<CartIcon className="size-5" />
|
||||
<span className="font-bold">۰</span>
|
||||
</Link>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { CartIcon, ChevronDown, GridIcon, UserIcon } from "./icons";
|
||||
import { ChevronDown, GridIcon, UserIcon } from "./icons";
|
||||
import CartButton from "./CartButton";
|
||||
import { BUSINESS_PORTAL_URL, CUSTOMER_PORTAL_URL } from "@/lib/config";
|
||||
import { categoryHref } from "@/lib/slug";
|
||||
import { displayName, isAdmin } from "@/lib/authUser";
|
||||
@@ -130,10 +131,13 @@ export default function MobileBottomNav({
|
||||
خانه
|
||||
</Link>
|
||||
|
||||
<Link href={`${CUSTOMER_PORTAL_URL}/cart`} className={`${item} text-ink`}>
|
||||
<CartIcon className="size-6" />
|
||||
سبد خرید
|
||||
</Link>
|
||||
<CartButton
|
||||
user={user}
|
||||
placement="up"
|
||||
wrapperClassName="relative flex-1"
|
||||
className={`${item} w-full text-ink`}
|
||||
showLabel
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={() => setSheetOpen((v) => !v)}
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { CartIcon, CheckIcon, RotateIcon, ShieldIcon, TruckIcon } from "./icons";
|
||||
import { formatPrice } from "@/lib/format";
|
||||
import { AUTH_TOKEN_COOKIE, CUSTOMER_PORTAL_URL } from "@/lib/config";
|
||||
import { useCart } from "./providers/CartProvider";
|
||||
import { useToast } from "./providers/ToastProvider";
|
||||
import { formatPrice, toFaDigits } from "@/lib/format";
|
||||
import { CUSTOMER_PORTAL_URL } from "@/lib/config";
|
||||
import { DEMO_PRICING, demoPrice } from "@/lib/demoPricing";
|
||||
import type { ApiProduct, AuthUser, StoreItem } from "@/lib/types";
|
||||
|
||||
@@ -18,15 +20,22 @@ export default function ProductBuyPanel({
|
||||
product,
|
||||
items = [],
|
||||
user,
|
||||
initialVariantId,
|
||||
}: {
|
||||
product: ApiProduct;
|
||||
items?: StoreItem[];
|
||||
user: AuthUser | null;
|
||||
/** Preselect a specific variant — used when entering via its own
|
||||
* /store-items/{id} URL rather than the product page. */
|
||||
initialVariantId?: string;
|
||||
}) {
|
||||
const [selectedId, setSelectedId] = useState(items[0]?.id);
|
||||
const [selectedId, setSelectedId] = useState(initialVariantId ?? items[0]?.id);
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [added, setAdded] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { addItem } = useCart();
|
||||
const { showToast } = useToast();
|
||||
const selected = items.find((i) => i.id === selectedId) ?? items[0];
|
||||
|
||||
const title = product.nameFa || product.title;
|
||||
@@ -55,28 +64,16 @@ export default function ProductBuyPanel({
|
||||
// is nothing a bearer token could add to a cart, logged in or not.
|
||||
const purchasable = items.length > 0 && Boolean(selected);
|
||||
const inStock = selected ? selected.stockQuantity > 0 : product.store.inStock;
|
||||
const maxQuantity = selected ? Math.max(1, selected.stockQuantity) : 99;
|
||||
|
||||
async function handleAddToCart() {
|
||||
if (!selected || !user) return;
|
||||
setAdding(true);
|
||||
setError(null);
|
||||
try {
|
||||
const token = document.cookie
|
||||
.split("; ")
|
||||
.find((c) => c.startsWith(`${AUTH_TOKEN_COOKIE}=`))
|
||||
?.split("=")[1];
|
||||
if (!token) throw new Error("no-token");
|
||||
|
||||
const res = await fetch(
|
||||
`https://api.meshkee.com/api/v1/businesses/${product.businessId}/cart/items`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ storeItemVariantId: selected.storeItemId, quantity: 1 }),
|
||||
}
|
||||
);
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
await addItem(selected.storeItemId, quantity);
|
||||
setAdded(true);
|
||||
showToast(`${quantity > 1 ? `${toFaDigits(quantity)} عدد ` : ""}به سبد خرید اضافه شد`);
|
||||
setTimeout(() => setAdded(false), 1800);
|
||||
} catch {
|
||||
setError("افزودن به سبد خرید ناموفق بود. لطفاً دوباره تلاش کنید.");
|
||||
@@ -109,7 +106,10 @@ export default function ProductBuyPanel({
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedId(item.id)}
|
||||
onClick={() => {
|
||||
setSelectedId(item.id);
|
||||
setQuantity(1);
|
||||
}}
|
||||
disabled={item.stockQuantity <= 0}
|
||||
title={item.label}
|
||||
className={`flex items-center justify-center transition disabled:cursor-not-allowed disabled:opacity-40 ${
|
||||
@@ -194,26 +194,50 @@ export default function ProductBuyPanel({
|
||||
برای خرید وارد شوید
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddToCart}
|
||||
disabled={!inStock || adding}
|
||||
className={`flex h-12 w-full items-center justify-center gap-2 rounded-lg text-sm font-bold transition disabled:cursor-not-allowed disabled:opacity-40 ${
|
||||
added ? "bg-brand-dark text-white" : "bg-brand text-white hover:bg-brand-dark"
|
||||
}`}
|
||||
>
|
||||
{added ? (
|
||||
<>
|
||||
<CheckIcon className="size-4.5" />
|
||||
افزوده شد
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CartIcon className="size-4.5" />
|
||||
{adding ? "در حال افزودن…" : "افزودن به سبد خرید"}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<div className="flex items-stretch gap-2">
|
||||
<div className="flex h-12 items-center rounded-lg border border-line">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuantity((q) => Math.max(1, q - 1))}
|
||||
disabled={quantity <= 1}
|
||||
aria-label="کاهش تعداد"
|
||||
className="flex h-full w-9 items-center justify-center text-lg text-ink-soft transition hover:text-brand disabled:opacity-30"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span className="w-8 text-center text-sm font-bold text-ink">{toFaDigits(quantity)}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuantity((q) => Math.min(maxQuantity, q + 1))}
|
||||
disabled={quantity >= maxQuantity}
|
||||
aria-label="افزایش تعداد"
|
||||
className="flex h-full w-9 items-center justify-center text-lg text-ink-soft transition hover:text-brand disabled:opacity-30"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddToCart}
|
||||
disabled={!inStock || adding}
|
||||
className={`flex h-12 flex-1 items-center justify-center gap-2 rounded-lg text-sm font-bold transition disabled:cursor-not-allowed disabled:opacity-40 ${
|
||||
added ? "bg-brand-dark text-white" : "bg-brand text-white hover:bg-brand-dark"
|
||||
}`}
|
||||
>
|
||||
{added ? (
|
||||
<>
|
||||
<CheckIcon className="size-4.5" />
|
||||
افزوده شد
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CartIcon className="size-4.5" />
|
||||
{adding ? "در حال افزودن…" : "افزودن به سبد خرید"}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{error && <p className="mt-2 text-center text-[12px] text-brand">{error}</p>}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { addCartItem, getCart, removeCartItem, updateCartItem } from "@/lib/api";
|
||||
import { AUTH_TOKEN_COOKIE } from "@/lib/config";
|
||||
import type { AuthUser, Cart } from "@/lib/types";
|
||||
|
||||
function readToken(): string | null {
|
||||
if (typeof document === "undefined") return null;
|
||||
return (
|
||||
document.cookie
|
||||
.split("; ")
|
||||
.find((c) => c.startsWith(`${AUTH_TOKEN_COOKIE}=`))
|
||||
?.split("=")[1] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
interface CartContextValue {
|
||||
cart: Cart | null;
|
||||
count: number;
|
||||
loading: boolean;
|
||||
refresh: () => Promise<void>;
|
||||
addItem: (storeItemVariantId: string, quantity?: number) => Promise<void>;
|
||||
updateItem: (itemId: string, quantity: number) => Promise<void>;
|
||||
removeItem: (itemId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const CartContext = createContext<CartContextValue | null>(null);
|
||||
|
||||
// Cart is not guest-accessible at the API level (every verb needs a bearer
|
||||
// token — see api.ts), so this only ever fetches once a session exists.
|
||||
// Anonymous visitors always see an empty cart, which is correct, not a bug.
|
||||
export function CartProvider({
|
||||
businessId,
|
||||
user,
|
||||
children,
|
||||
}: {
|
||||
businessId: string;
|
||||
user: AuthUser | null;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [cart, setCart] = useState<Cart | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const token = readToken();
|
||||
if (!token) {
|
||||
setCart(null);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const { cart } = await getCart(businessId, token);
|
||||
setCart(cart);
|
||||
} catch {
|
||||
setCart(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [businessId]);
|
||||
|
||||
useEffect(() => {
|
||||
// refresh() calls setLoading synchronously as its first statement —
|
||||
// deferred to a microtask so the effect body itself never calls
|
||||
// setState synchronously.
|
||||
if (user) queueMicrotask(() => void refresh());
|
||||
}, [user, refresh]);
|
||||
|
||||
const addItem = useCallback(
|
||||
async (storeItemVariantId: string, quantity = 1) => {
|
||||
const token = readToken();
|
||||
if (!token) throw new Error("not-authenticated");
|
||||
const { cart } = await addCartItem(businessId, token, storeItemVariantId, quantity);
|
||||
setCart(cart);
|
||||
},
|
||||
[businessId]
|
||||
);
|
||||
|
||||
const updateItem = useCallback(
|
||||
async (itemId: string, quantity: number) => {
|
||||
const token = readToken();
|
||||
if (!token) return;
|
||||
const { cart } = await updateCartItem(businessId, token, itemId, quantity);
|
||||
setCart(cart);
|
||||
},
|
||||
[businessId]
|
||||
);
|
||||
|
||||
const removeItem = useCallback(
|
||||
async (itemId: string) => {
|
||||
const token = readToken();
|
||||
if (!token) return;
|
||||
const { cart } = await removeCartItem(businessId, token, itemId);
|
||||
setCart(cart);
|
||||
},
|
||||
[businessId]
|
||||
);
|
||||
|
||||
const count = cart?.itemCount ?? cart?.items?.reduce((sum, item) => sum + item.quantity, 0) ?? 0;
|
||||
|
||||
return (
|
||||
<CartContext.Provider value={{ cart, count, loading, refresh, addItem, updateItem, removeItem }}>
|
||||
{children}
|
||||
</CartContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useCart(): CartContextValue {
|
||||
const ctx = useContext(CartContext);
|
||||
if (!ctx) throw new Error("useCart must be used within CartProvider");
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
|
||||
import { CheckIcon } from "../icons";
|
||||
|
||||
interface ToastItem {
|
||||
id: number;
|
||||
message: string;
|
||||
type: "success" | "error";
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
showToast: (message: string, type?: "success" | "error") => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||
|
||||
// Minimal global toast — used by the contact form and cart actions. Sits
|
||||
// above the mobile bottom nav (bottom-20) and drops down on lg where that
|
||||
// bar doesn't exist.
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
||||
|
||||
const showToast = useCallback((message: string, type: "success" | "error" = "success") => {
|
||||
const id = Date.now() + Math.random();
|
||||
setToasts((prev) => [...prev, { id, message, type }]);
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((toast) => toast.id !== id));
|
||||
}, 3500);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ showToast }}>
|
||||
{children}
|
||||
<div
|
||||
className="pointer-events-none fixed inset-x-0 bottom-20 z-[100] flex flex-col items-center gap-2 px-4 lg:bottom-6"
|
||||
aria-live="polite"
|
||||
>
|
||||
{toasts.map((toast) => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`pointer-events-auto flex max-w-sm items-center gap-2 rounded-xl px-4 py-3 text-[13px] font-bold text-white shadow-lg ${
|
||||
toast.type === "error" ? "bg-ink" : "bg-brand"
|
||||
}`}
|
||||
>
|
||||
{toast.type === "success" && <CheckIcon className="size-4 shrink-0" />}
|
||||
{toast.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast(): ToastContextValue {
|
||||
const ctx = useContext(ToastContext);
|
||||
if (!ctx) throw new Error("useToast must be used within ToastProvider");
|
||||
return ctx;
|
||||
}
|
||||
Reference in New Issue
Block a user