Merge pull request 'perf: logger + store page modification' (#4) from feature/WNPT-734 into develop
Reviewed-on: #4
This commit is contained in:
commit
89d2a090e1
13 changed files with 289 additions and 188 deletions
|
|
@ -2,6 +2,7 @@ import Link from "next/link";
|
|||
import { getCart } from "@/lib/api/cart";
|
||||
import { requireSession } from "@/lib/auth/session";
|
||||
import { CartItemRow } from "@/components/customer/cart-item-row";
|
||||
import { ClearCartButton } from "@/components/customer/clear-cart-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export default async function CartPage() {
|
||||
|
|
@ -22,7 +23,10 @@ export default async function CartPage() {
|
|||
|
||||
return (
|
||||
<main className="mx-auto flex w-full max-w-2xl flex-1 flex-col gap-6 px-4 py-6">
|
||||
<h1 className="text-lg font-bold">سبد خرید</h1>
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-lg font-bold">سبد خرید</h1>
|
||||
<ClearCartButton />
|
||||
</div>
|
||||
|
||||
{cart.groups.map((group) => (
|
||||
<section key={group.store.uuid} className="flex flex-col gap-2 rounded-2xl border border-border p-4">
|
||||
|
|
|
|||
|
|
@ -25,45 +25,70 @@ export default async function StoreDetailPage({
|
|||
});
|
||||
if (!store) notFound();
|
||||
|
||||
const [products, session] = await Promise.all([getProducts({ store: uuid, search, limit: 100 }), getValidSession()]);
|
||||
const [products, session] = await Promise.all([
|
||||
getProducts({ store: uuid, search, limit: 100 }),
|
||||
getValidSession(),
|
||||
]);
|
||||
const cart = session ? await getCart() : null;
|
||||
const storeGroup = cart?.groups.find((g) => g.store.uuid === uuid) ?? null;
|
||||
|
||||
const cartItemByProduct = new Map<string, CartItem>();
|
||||
storeGroup?.items.forEach((item) => cartItemByProduct.set(item.product.uuid, item));
|
||||
storeGroup?.items.forEach((item) =>
|
||||
cartItemByProduct.set(item.product.uuid, item),
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="flex min-h-full flex-1 flex-col bg-page-bg">
|
||||
<div className="relative h-[257px] w-full shrink-0 overflow-hidden rounded-b-3xl bg-muted">
|
||||
<div className="relative h-64.25 w-full shrink-0 overflow-hidden rounded-b-2xl bg-muted">
|
||||
{store.cover_image_url && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={store.cover_image_url} alt="" className="h-full w-full object-cover" />
|
||||
<img
|
||||
src={store.cover_image_url}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-black/0 to-black/70" />
|
||||
<HeroBackButton href="/" />
|
||||
</div>
|
||||
<div className="w-full h-auto flex flex-col items-center">
|
||||
<StoreInfoCard store={store} />
|
||||
</div>
|
||||
|
||||
<StoreInfoCard store={store} />
|
||||
|
||||
<section className="px-4 pt-2">
|
||||
<h2 className="text-[18px] font-extrabold leading-none text-primary-action">توضیحات</h2>
|
||||
<p className="mt-3 text-right text-[15px] leading-[1.6] text-gray-700">{store.description}</p>
|
||||
<section className="p-6">
|
||||
<h2 className="text-[18px] font-extrabold leading-none text-primary-action">
|
||||
توضیحات
|
||||
</h2>
|
||||
<p className="mt-3 text-right text-[15px] leading-[1.6] text-gray-700">
|
||||
{store.description}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div className="pt-4">
|
||||
<div className="pb-6">
|
||||
<StoreSearchBar storeUuid={uuid} defaultValue={search} />
|
||||
</div>
|
||||
|
||||
<section className="grid grid-cols-2 gap-3 px-8 pt-2 pb-[120px]">
|
||||
<section className="grid grid-cols-2 gap-3 px-8 pt-2 pb-30">
|
||||
{products.map((product) => (
|
||||
<ProductGridCard key={product.uuid} product={product} cartItem={cartItemByProduct.get(product.uuid) ?? null} />
|
||||
<ProductGridCard
|
||||
key={product.uuid}
|
||||
product={product}
|
||||
cartItem={cartItemByProduct.get(product.uuid) ?? null}
|
||||
/>
|
||||
))}
|
||||
{products.length === 0 && (
|
||||
<p className="col-span-2 py-8 text-center text-body-medium text-gray-600">محصولی یافت نشد.</p>
|
||||
<p className="col-span-2 py-8 text-center text-body-medium text-gray-600">
|
||||
محصولی یافت نشد.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{storeGroup && <StickyCartBar itemCount={storeGroup.items.length} total={storeGroup.total} />}
|
||||
{storeGroup && (
|
||||
<StickyCartBar
|
||||
itemCount={storeGroup.items.length}
|
||||
total={storeGroup.total}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ export function RoleCard({
|
|||
className={cn(
|
||||
"flex h-12 w-full items-center justify-center rounded-full text-body-large font-semibold transition-colors",
|
||||
ctaVariant === "primary"
|
||||
? "bg-primary-action text-white hover:bg-primary-action/90"
|
||||
? "border-0 bg-primary-action text-white hover:bg-primary-action/90"
|
||||
: "border border-primary-action bg-white text-primary-action hover:bg-primary-action/5",
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,54 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import { addToCart } from "@/lib/api/cart";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
|
||||
export function AddToCartButton({
|
||||
productUuid,
|
||||
quantity = 1,
|
||||
disabled,
|
||||
outOfStock,
|
||||
className,
|
||||
}: {
|
||||
productUuid: string;
|
||||
quantity?: number;
|
||||
disabled?: boolean;
|
||||
outOfStock?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [feedback, setFeedback] = useState<"added" | "error" | null>(null);
|
||||
|
||||
function handleClick() {
|
||||
startTransition(async () => {
|
||||
const result = await addToCart(productUuid, quantity);
|
||||
if (result.ok) {
|
||||
setFeedback("added");
|
||||
router.refresh();
|
||||
setTimeout(() => setFeedback(null), 1500);
|
||||
} else if (result.status === 401) {
|
||||
router.push(`/login?next=${encodeURIComponent(pathname)}`);
|
||||
} else {
|
||||
setFeedback("error");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={feedback === "added" ? "secondary" : "primary"}
|
||||
disabled={disabled || isPending}
|
||||
onClick={handleClick}
|
||||
className={cn("w-full", className)}
|
||||
>
|
||||
{outOfStock ? "ناموجود" : isPending ? "..." : feedback === "added" ? "افزوده شد ✓" : feedback === "error" ? "خطا، دوباره تلاش کنید" : "افزودن به سبد"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,59 +1,74 @@
|
|||
"use client";
|
||||
|
||||
import { useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import { updateCartItemQuantity, removeCartItem } from "@/lib/api/cart";
|
||||
import type { CartItem } from "@/types/api";
|
||||
|
||||
export function CartItemRow({ item }: { item: CartItem }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!error) return;
|
||||
const timeout = setTimeout(() => setError(null), 3000);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [error]);
|
||||
|
||||
function changeQuantity(nextQuantity: number) {
|
||||
startTransition(async () => {
|
||||
if (nextQuantity <= 0) {
|
||||
await removeCartItem(item.uuid);
|
||||
const result =
|
||||
nextQuantity <= 0 ? await removeCartItem(item.uuid) : await updateCartItemQuantity(item.uuid, nextQuantity);
|
||||
|
||||
if (result.ok) {
|
||||
router.refresh();
|
||||
} else if (result.status === 401) {
|
||||
router.push(`/login?next=${encodeURIComponent(pathname)}`);
|
||||
} else {
|
||||
await updateCartItemQuantity(item.uuid, nextQuantity);
|
||||
setError(result.message);
|
||||
}
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-3 py-3 ${isPending ? "opacity-50" : ""}`}>
|
||||
<div className="h-14 w-14 shrink-0 overflow-hidden rounded-xl bg-muted">
|
||||
{item.product.image_url && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={item.product.image_url} alt="" className="h-full w-full object-cover" />
|
||||
)}
|
||||
<div className="flex flex-col gap-1.5 py-3">
|
||||
<div className={`flex items-center gap-3 ${isPending ? "opacity-50" : ""}`}>
|
||||
<div className="h-14 w-14 shrink-0 overflow-hidden rounded-xl bg-muted">
|
||||
{item.product.image_url && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={item.product.image_url} alt="" className="h-full w-full object-cover" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<span className="text-sm font-medium">{item.product.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{item.product.price.toLocaleString("fa-IR")} تومان</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 rounded-full border border-border px-2 py-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
onClick={() => changeQuantity(item.quantity - 1)}
|
||||
className="flex h-6 w-6 items-center justify-center text-sm"
|
||||
aria-label="کاهش تعداد"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span className="w-5 text-center text-sm">{item.quantity.toLocaleString("fa-IR")}</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
onClick={() => changeQuantity(item.quantity + 1)}
|
||||
className="flex h-6 w-6 items-center justify-center text-sm"
|
||||
aria-label="افزایش تعداد"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<span className="w-20 shrink-0 text-end text-sm font-semibold">{item.line_total.toLocaleString("fa-IR")}</span>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<span className="text-sm font-medium">{item.product.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{item.product.price.toLocaleString("fa-IR")} تومان</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 rounded-full border border-border px-2 py-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
onClick={() => changeQuantity(item.quantity - 1)}
|
||||
className="flex h-6 w-6 items-center justify-center text-sm"
|
||||
aria-label="کاهش تعداد"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span className="w-5 text-center text-sm">{item.quantity}</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
onClick={() => changeQuantity(item.quantity + 1)}
|
||||
className="flex h-6 w-6 items-center justify-center text-sm"
|
||||
aria-label="افزایش تعداد"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<span className="w-20 shrink-0 text-end text-sm font-semibold">{item.line_total.toLocaleString("fa-IR")}</span>
|
||||
{error && <p className="text-end text-xs text-error">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
48
src/components/customer/clear-cart-button.tsx
Normal file
48
src/components/customer/clear-cart-button.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { clearCart } from "@/lib/api/cart";
|
||||
|
||||
export function ClearCartButton() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!error) return;
|
||||
const timeout = setTimeout(() => setError(null), 3000);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [error]);
|
||||
|
||||
function handleClear() {
|
||||
if (!window.confirm("سبد خرید شما بهطور کامل خالی میشود. ادامه میدهید؟")) return;
|
||||
startTransition(async () => {
|
||||
const result = await clearCart();
|
||||
if (result.ok) {
|
||||
router.refresh();
|
||||
} else if (result.status === 401) {
|
||||
router.push(`/login?next=${encodeURIComponent(pathname)}`);
|
||||
} else {
|
||||
setError(result.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
disabled={isPending}
|
||||
className="flex items-center gap-1.5 text-xs font-medium text-error disabled:opacity-40"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
خالی کردن سبد
|
||||
</button>
|
||||
{error && <p className="text-xs text-error">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,11 +4,20 @@ import { useEffect, useState, useTransition } from "react";
|
|||
import Link from "next/link";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import { Minus, Plus } from "lucide-react";
|
||||
import { addToCart, updateCartItemQuantity, removeCartItem } from "@/lib/api/cart";
|
||||
import {
|
||||
addToCart,
|
||||
updateCartItemQuantity,
|
||||
removeCartItem,
|
||||
} from "@/lib/api/cart";
|
||||
import type { CartItem, ProductListItem } from "@/types/api";
|
||||
|
||||
/** designspec.md §2.5 — product-card, 157×254, with the two-state add/stepper control. */
|
||||
export function ProductGridCard({ product, cartItem }: { product: ProductListItem; cartItem: CartItem | null }) {
|
||||
export function ProductGridCard({
|
||||
product,
|
||||
cartItem,
|
||||
}: {
|
||||
product: ProductListItem;
|
||||
cartItem: CartItem | null;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
|
@ -43,12 +52,13 @@ export function ProductGridCard({ product, cartItem }: { product: ProductListIte
|
|||
startTransition(async () => {
|
||||
if (nextQuantity <= 0) {
|
||||
const result = await removeCartItem(localItem.uuid);
|
||||
// Only clear locally once the backend actually confirms removal — otherwise
|
||||
// the card would show "add" again while the item still exists server-side.
|
||||
if (result.ok) setLocalItem(null);
|
||||
else setError(result.message);
|
||||
} else {
|
||||
const result = await updateCartItemQuantity(localItem.uuid, nextQuantity);
|
||||
const result = await updateCartItemQuantity(
|
||||
localItem.uuid,
|
||||
nextQuantity,
|
||||
);
|
||||
if (result.ok) setLocalItem(result.data);
|
||||
else setError(result.message);
|
||||
}
|
||||
|
|
@ -57,67 +67,83 @@ export function ProductGridCard({ product, cartItem }: { product: ProductListIte
|
|||
}
|
||||
|
||||
return (
|
||||
<article className="relative flex h-[254px] w-[157px] flex-col overflow-hidden rounded-xl border border-gray-300 bg-white shadow-[0_2px_10px_-6px_rgba(0,0,0,0.039),0_14px_28px_-12px_rgba(0,0,0,0.071)]">
|
||||
<article className="relative flex h-63.5 w-39.25 flex-col overflow-hidden rounded-xl border border-gray-300 bg-white shadow-[0_2px_10px_-6px_rgba(0,0,0,0.039),0_14px_28px_-12px_rgba(0,0,0,0.071)]">
|
||||
{error && (
|
||||
<div className="absolute inset-x-1 top-1 z-10 rounded-lg bg-error px-2 py-1 text-center text-[11px] leading-tight text-white">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<Link href={`/products/${product.uuid}`} className="block shrink-0">
|
||||
<div className="relative h-[130px] w-full overflow-hidden rounded-t-xl bg-muted">
|
||||
<div className="relative h-32.5 w-full overflow-hidden rounded-t-xl bg-muted">
|
||||
{product.image_url && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={product.image_url} alt={product.name} className="h-full w-full object-cover" />
|
||||
<img
|
||||
src={product.image_url}
|
||||
alt={product.name}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
)}
|
||||
<div className="absolute inset-x-0 bottom-0 h-1/2 bg-gradient-to-t from-black/40 to-transparent" />
|
||||
<div className="absolute inset-x-0 bottom-0 h-1/2 bg-linear-to-t from-black/40 to-transparent" />
|
||||
</div>
|
||||
</Link>
|
||||
<div className="flex flex-1 flex-col justify-between p-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Link href={`/products/${product.uuid}`}>
|
||||
<h3 className="truncate text-right text-[14px] font-bold leading-none text-foreground">{product.name}</h3>
|
||||
</Link>
|
||||
<p className="text-right text-body-medium font-extrabold text-primary-action">
|
||||
{product.price.toLocaleString("fa-IR")} تومان
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-1.5 p-3">
|
||||
<Link href={`/products/${product.uuid}`}>
|
||||
<h3 className="truncate text-right text-[14px] font-bold leading-none text-foreground">
|
||||
{product.name}
|
||||
</h3>
|
||||
</Link>
|
||||
<p className="text-right text-body-medium font-extrabold text-primary-action">
|
||||
{product.price.toLocaleString("fa-IR")} تومان
|
||||
</p>
|
||||
{disabled && (
|
||||
<span className="mt-auto text-center text-label text-gray-500">
|
||||
ناموجود
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{disabled ? (
|
||||
<span className="text-center text-label text-gray-500">ناموجود</span>
|
||||
) : quantity === 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAdd}
|
||||
disabled={isPending}
|
||||
aria-label="افزودن به سبد"
|
||||
className="flex h-8 w-8 items-center justify-center self-start rounded-full bg-primary-action text-white disabled:opacity-40"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex h-10 w-[110px] items-center justify-between gap-4 rounded-3xl border border-gray-300 bg-white p-1">
|
||||
{!disabled && (
|
||||
<div
|
||||
className={`motion-reduce:transition-none absolute bottom-3 left-3 overflow-hidden rounded-full border transition-[width,background-color,border-color] duration-300 ease-out ${
|
||||
quantity === 0
|
||||
? "h-10 w-10 border-transparent bg-primary-action"
|
||||
: "h-10 w-28 border-gray-300 bg-white"
|
||||
}`}
|
||||
dir="ltr"
|
||||
>
|
||||
<div className="relative flex h-full w-full items-center justify-between px-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleChange(quantity + 1)}
|
||||
onClick={
|
||||
quantity === 0 ? handleAdd : () => handleChange(quantity + 1)
|
||||
}
|
||||
disabled={isPending}
|
||||
aria-label="افزایش تعداد"
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary-action text-white disabled:opacity-40"
|
||||
aria-label={quantity === 0 ? "افزودن به سبد" : "افزایش تعداد"}
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border-0 bg-primary-action p-0 text-white disabled:opacity-40"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
<span className="text-[18px] font-bold leading-none text-foreground">{quantity.toLocaleString("fa-IR")}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleChange(quantity - 1)}
|
||||
disabled={isPending}
|
||||
aria-label="کاهش تعداد"
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-blue-bg text-primary-action disabled:opacity-40"
|
||||
>
|
||||
<Minus className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{quantity > 0 && (
|
||||
<>
|
||||
<span className="flex h-full w-8 shrink-0 items-center justify-center text-center text-[18px] font-bold leading-none text-foreground">
|
||||
{quantity.toLocaleString("fa-IR")}
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleChange(quantity - 1)}
|
||||
disabled={isPending}
|
||||
aria-label="کاهش تعداد"
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border-0 bg-blue-bg p-0 text-primary-action disabled:opacity-40"
|
||||
>
|
||||
<Minus className="h-4 w-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ import type { StoreDetail } from "@/types/api";
|
|||
function todaysWorkingHours(store: StoreDetail) {
|
||||
const jsDay = new Date().getDay();
|
||||
const iranianWeekday = (jsDay + 1) % 7;
|
||||
return store.working_hours.find((wh) => wh.weekday === iranianWeekday) ?? null;
|
||||
return (
|
||||
store.working_hours.find((wh) => wh.weekday === iranianWeekday) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(value: string | null) {
|
||||
|
|
@ -20,35 +22,52 @@ export function StoreInfoCard({ store }: { store: StoreDetail }) {
|
|||
const mapsUrl = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(`${store.name} ${store.address}`)}`;
|
||||
|
||||
return (
|
||||
<div className="relative z-10 -mt-6 rounded-t-3xl bg-white px-4 pt-5 pb-4">
|
||||
<h1 className="text-title-large font-extrabold leading-none text-primary-action">{store.name}</h1>
|
||||
<p className="mt-1 text-[14px] leading-none text-gray-700">{store.category.name}</p>
|
||||
<div className="w-[90%] relative z-10 -mt-24 rounded-3xl bg-white px-4 pt-5 pb-4 shadow-[0px_4px_20px_0px_rgba(0,0,0,0.102)]">
|
||||
<h1 className="text-title-large font-extrabold leading-none text-primary-action">
|
||||
{store.name}
|
||||
</h1>
|
||||
<p className="mt-1 text-[14px] leading-none text-gray-700">
|
||||
{store.category.name}
|
||||
</p>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
{today && (
|
||||
<span className="rounded-full bg-teal-light px-2 py-1 text-[11px] font-semibold leading-none text-primary-action">
|
||||
{today.is_closed ? "تعطیل" : `${formatTime(today.opens_at)} تا ${formatTime(today.closes_at)}`}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[13px] leading-none text-gray-600">|</span>
|
||||
<span
|
||||
className={`rounded-full border px-2.5 py-1.5 text-[12px] font-semibold leading-none ${
|
||||
store.is_open ? "border-emerald-border bg-emerald-bg text-emerald-text-strong" : "border-gray-300 bg-gray-100 text-gray-600"
|
||||
store.is_open
|
||||
? "border-emerald-border bg-emerald-bg text-emerald-text-strong"
|
||||
: "border-gray-300 bg-gray-100 text-gray-600"
|
||||
}`}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${store.is_open ? "bg-emerald-500" : "bg-gray-500"}`} />
|
||||
<span
|
||||
className={`h-1.5 w-1.5 rounded-full ${store.is_open ? "bg-emerald-500" : "bg-gray-500"}`}
|
||||
/>
|
||||
{store.is_open ? "فعال" : "بسته"}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-[13px] leading-none text-gray-600">|</span>
|
||||
{today && (
|
||||
<span className="h-6 flex items-center rounded-full bg-teal-light px-2 py-1 text-[11px] font-semibold leading-none text-primary-action">
|
||||
{today.is_closed
|
||||
? "تعطیل"
|
||||
: `${formatTime(today.opens_at)} تا ${formatTime(today.closes_at)}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center gap-2 rounded-[10px] border border-teal-border p-2">
|
||||
<div className="mt-4 flex items-center gap-2 p-2">
|
||||
<span className="flex h-8 w-10 shrink-0 items-center justify-center rounded-[10px] border border-teal-border bg-teal-light text-primary-action">
|
||||
<MapPin className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="flex-1 truncate text-right text-[14px] leading-normal text-foreground">{store.address}</span>
|
||||
<a href={mapsUrl} target="_blank" rel="noreferrer" className="shrink-0 text-[13px] font-medium text-teal">
|
||||
<span className="flex-1 truncate text-right text-[14px] leading-normal text-foreground">
|
||||
{store.address}
|
||||
</span>
|
||||
<a
|
||||
href={mapsUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="shrink-0 text-[13px] font-medium text-teal"
|
||||
>
|
||||
مسیریابی
|
||||
</a>
|
||||
</div>
|
||||
|
|
@ -56,7 +75,7 @@ export function StoreInfoCard({ store }: { store: StoreDetail }) {
|
|||
{store.phone_number && (
|
||||
<a
|
||||
href={`tel:${store.phone_number}`}
|
||||
className="mt-2 flex items-center gap-2 rounded-[10px] border border-teal-border p-2"
|
||||
className="mt-2 flex items-center gap-2 p-2"
|
||||
>
|
||||
<span className="flex h-8 w-10 shrink-0 items-center justify-center rounded-[10px] border border-teal-border bg-teal-light text-primary-action">
|
||||
<Phone className="h-4 w-4" />
|
||||
|
|
|
|||
|
|
@ -8,10 +8,14 @@ const buttonVariants = cva(
|
|||
{
|
||||
variants: {
|
||||
variant: {
|
||||
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
// Tailwind's preflight doesn't zero out <button>'s default border/padding (only
|
||||
// font/color/background/border-radius/opacity) — every variant that doesn't set
|
||||
// its own border must reset it explicitly, or the browser's default UA button
|
||||
// border/bevel survives underneath and throws off icon/label centering.
|
||||
primary: "border-0 bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
secondary: "border-0 bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
outline: "border border-border bg-transparent hover:bg-muted",
|
||||
ghost: "bg-transparent hover:bg-muted",
|
||||
ghost: "border-0 bg-transparent hover:bg-muted",
|
||||
},
|
||||
size: {
|
||||
default: "h-12 px-4 text-body-large",
|
||||
|
|
|
|||
|
|
@ -7,9 +7,9 @@ export function HeroBackButton({ href }: { href: string }) {
|
|||
<Link
|
||||
href={href}
|
||||
aria-label="بازگشت"
|
||||
className="absolute top-4 right-4 flex h-9 w-9 items-center justify-center rounded-full border border-white bg-white/80 backdrop-blur-md"
|
||||
className="absolute top-4 right-4 flex h-9 w-9 items-center justify-center rounded-full border border-white bg-white/80 backdrop-blur-lg"
|
||||
>
|
||||
<ArrowRight className="h-[19.8px] w-[19.8px] text-white" />
|
||||
<ArrowRight className="h-[19.8px] w-[19.8px] text-black" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,17 +46,19 @@ function indent(text: string): string {
|
|||
|
||||
/**
|
||||
* Dev-only request/response logging — the terminal-equivalent of Chrome's Network
|
||||
* tab for server-side calls, since they run on the Next.js server and the browser
|
||||
* never sees them (no CORS on the Winofy API). Never runs in production.
|
||||
* tab for every server-side call this app makes (Winofy API, Gooyal accounts),
|
||||
* since those run on the Next.js server and the browser never sees them. Shared
|
||||
* across every backend this app talks to, labeled by `service` so interleaved
|
||||
* calls stay easy to tell apart. Never runs in production.
|
||||
*/
|
||||
export function logWinofyRequest(method: string, url: string, body: unknown) {
|
||||
export function logApiRequest(service: string, method: string, url: string, body: unknown) {
|
||||
if (!DEBUG) return;
|
||||
console.log(`\n${CYAN}→ [winofy]${RESET} ${BOLD}${method}${RESET} ${url}`);
|
||||
console.log(`\n${CYAN}→ [${service}]${RESET} ${BOLD}${method}${RESET} ${url}`);
|
||||
if (body !== undefined) console.log(`${DIM} body:${RESET}\n${indent(prettyJson(body))}`);
|
||||
}
|
||||
|
||||
export function logWinofyResponse(method: string, url: string, status: number, data: unknown) {
|
||||
export function logApiResponse(service: string, method: string, url: string, status: number, data: unknown) {
|
||||
if (!DEBUG) return;
|
||||
console.log(`${statusColor(status)}← [winofy] ${status}${RESET} ${BOLD}${method}${RESET} ${url}`);
|
||||
console.log(`${statusColor(status)}← [${service}] ${status}${RESET} ${BOLD}${method}${RESET} ${url}`);
|
||||
if (data !== null && data !== undefined) console.log(`${DIM} data:${RESET}\n${indent(prettyJson(data))}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import "server-only";
|
||||
import { getValidSession } from "@/lib/auth/session";
|
||||
import { ApiError, type WinofyErrorDetails } from "./errors";
|
||||
import { logWinofyRequest, logWinofyResponse } from "./dev-logger";
|
||||
import { logApiRequest, logApiResponse } from "./dev-logger";
|
||||
|
||||
const BASE_URL = process.env.WINOFY_API_BASE_URL!;
|
||||
|
||||
|
|
@ -41,7 +41,7 @@ export async function winofyFetch<T>(path: string, options: WinofyFetchOptions =
|
|||
if (session) finalHeaders.set("Authorization", `Bearer ${session.accessToken}`);
|
||||
}
|
||||
|
||||
logWinofyRequest(method, url.toString(), body);
|
||||
logApiRequest("winofy", method, url.toString(), body);
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
...rest,
|
||||
|
|
@ -51,12 +51,12 @@ export async function winofyFetch<T>(path: string, options: WinofyFetchOptions =
|
|||
});
|
||||
|
||||
if (res.status === 204) {
|
||||
logWinofyResponse(method, url.toString(), res.status, null);
|
||||
logApiResponse("winofy", method, url.toString(), res.status, null);
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
const data = await res.json().catch(() => null);
|
||||
logWinofyResponse(method, url.toString(), res.status, data);
|
||||
logApiResponse("winofy", method, url.toString(), res.status, data);
|
||||
|
||||
if (!res.ok) {
|
||||
const envelope = data as { status_message?: string; details?: WinofyErrorDetails } | null;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import "server-only";
|
||||
import { logApiRequest, logApiResponse } from "@/lib/api/dev-logger";
|
||||
|
||||
const ACCOUNTS_BASE_URL = process.env.GOOYAL_ACCOUNTS_BASE_URL!;
|
||||
const CLIENT_ID = process.env.GOOYAL_CLIENT_ID!;
|
||||
|
|
@ -34,11 +35,16 @@ export interface GooyalTokenResponse {
|
|||
}
|
||||
|
||||
export async function refreshAccessToken(refreshToken: string): Promise<GooyalTokenResponse> {
|
||||
const url = `${ACCOUNTS_BASE_URL}/oauth2/token/`;
|
||||
const body = new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
const res = await fetch(`${ACCOUNTS_BASE_URL}/oauth2/token/`, {
|
||||
|
||||
// Never print real tokens to the terminal — log the request shape, not the secret values.
|
||||
logApiRequest("gooyal", "POST", url, { grant_type: "refresh_token", refresh_token: "<redacted>" });
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: basicAuthHeader(),
|
||||
|
|
@ -48,6 +54,12 @@ export async function refreshAccessToken(refreshToken: string): Promise<GooyalTo
|
|||
cache: "no-store",
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
logApiResponse("gooyal", "POST", url, res.status, {
|
||||
...data,
|
||||
access_token: data.access_token ? "<redacted>" : undefined,
|
||||
refresh_token: data.refresh_token ? "<redacted>" : undefined,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new GooyalAuthError(res.status, data.error ?? "refresh_failed", data.error_description);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue