Route partnership CTA to the store account and polish search modal.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
fa248d2f6a
commit
f44058bd6e
@@ -172,7 +172,6 @@ export default async function CategoryPage({ params, searchParams }: PageProps)
|
||||
products,
|
||||
query.page,
|
||||
);
|
||||
const hasExtraFilters = Boolean(query.q || query.min || query.max);
|
||||
|
||||
return (
|
||||
<main className="home-page min-h-dvh bg-white text-ink">
|
||||
@@ -202,18 +201,7 @@ export default async function CategoryPage({ params, searchParams }: PageProps)
|
||||
/>
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
{total === 0 ? (
|
||||
<ProductEmptyState
|
||||
title={
|
||||
hasExtraFilters
|
||||
? "محصولی با این فیلترها پیدا نشد"
|
||||
: `محصولی در «${resolved.nameFa}» نیست`
|
||||
}
|
||||
description={
|
||||
hasExtraFilters
|
||||
? "فیلترها را تغییر دهید یا همه را پاک کنید تا محصولات بیشتری ببینید."
|
||||
: "بهزودی محصولات این دسته اضافه میشوند."
|
||||
}
|
||||
/>
|
||||
<ProductEmptyState />
|
||||
) : (
|
||||
<>
|
||||
<ProductGrid products={pageItems} />
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { STORE_URL } from "@/lib/public-api";
|
||||
|
||||
/** Partnership form lives in the customer dashboard (auth already shared). */
|
||||
export default function PartnersPage() {
|
||||
redirect(`${STORE_URL}/account/partners`);
|
||||
}
|
||||
@@ -92,9 +92,6 @@ export default async function ProductsPage({ searchParams }: PageProps) {
|
||||
const categoryLabel = query.cat
|
||||
? findCategoryName(apiCategories, query.cat)
|
||||
: null;
|
||||
const hasFilters = Boolean(
|
||||
query.q || query.cat || query.min || query.max || query.sale,
|
||||
);
|
||||
|
||||
const heroTitle = query.sale
|
||||
? "فروش ویژه"
|
||||
@@ -152,18 +149,7 @@ export default async function ProductsPage({ searchParams }: PageProps) {
|
||||
/>
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
{total === 0 ? (
|
||||
<ProductEmptyState
|
||||
title={
|
||||
hasFilters
|
||||
? "محصولی با این فیلترها پیدا نشد"
|
||||
: "محصولی موجود نیست"
|
||||
}
|
||||
description={
|
||||
hasFilters
|
||||
? "فیلترها را تغییر دهید یا همه را پاک کنید تا محصولات بیشتری ببینید."
|
||||
: "بهزودی محصولات جدید اضافه میشوند."
|
||||
}
|
||||
/>
|
||||
<ProductEmptyState />
|
||||
) : (
|
||||
<>
|
||||
<ProductGrid products={pageItems} />
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { FormEvent, useEffect, useRef, useState } from "react";
|
||||
import { categoryPath } from "@/lib/category-path";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { productPath } from "@/lib/product-path";
|
||||
import { API_URL, STORE_SLUG } from "@/lib/public-api";
|
||||
|
||||
@@ -15,29 +15,35 @@ type SearchHit = {
|
||||
price?: number;
|
||||
};
|
||||
|
||||
type CategoryHit = {
|
||||
id: number;
|
||||
nameFa: string;
|
||||
nameEn: string;
|
||||
};
|
||||
|
||||
export default function HeaderSearch() {
|
||||
const titleId = useId();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [q, setQ] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [products, setProducts] = useState<SearchHit[]>([]);
|
||||
const [categories, setCategories] = useState<CategoryHit[]>([]);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
inputRef.current?.focus();
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
inputRef.current?.focus();
|
||||
});
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
};
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
document.body.style.overflow = prevOverflow;
|
||||
window.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -45,7 +51,7 @@ export default function HeaderSearch() {
|
||||
const query = q.trim();
|
||||
if (query.length < 2) {
|
||||
setProducts([]);
|
||||
setCategories([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const t = window.setTimeout(async () => {
|
||||
@@ -54,19 +60,16 @@ export default function HeaderSearch() {
|
||||
const params = new URLSearchParams({
|
||||
storeSlug: STORE_SLUG,
|
||||
q: query,
|
||||
limit: "8",
|
||||
limit: "12",
|
||||
});
|
||||
const res = await fetch(`${API_URL}/public/search?${params}`);
|
||||
if (!res.ok) throw new Error("search failed");
|
||||
const data = (await res.json()) as {
|
||||
products?: SearchHit[];
|
||||
categories?: CategoryHit[];
|
||||
};
|
||||
setProducts(data.products ?? []);
|
||||
setCategories(data.categories ?? []);
|
||||
} catch {
|
||||
setProducts([]);
|
||||
setCategories([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -74,13 +77,125 @@ export default function HeaderSearch() {
|
||||
return () => window.clearTimeout(t);
|
||||
}, [q, open]);
|
||||
|
||||
function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
const query = q.trim();
|
||||
if (!query) return;
|
||||
setOpen(false);
|
||||
window.location.href = `/products?q=${encodeURIComponent(query)}`;
|
||||
}
|
||||
const modal =
|
||||
open && mounted
|
||||
? createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[200] flex items-center justify-center p-4 sm:p-6"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-0 bg-ink/25 backdrop-blur-2xl"
|
||||
aria-label="بستن جستجو"
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
|
||||
<div className="relative z-10 flex max-h-[min(92vh,880px)] w-full max-w-[920px] flex-col overflow-hidden rounded-[12px] border border-white/50 bg-white/45 shadow-[0_8px_32px_rgba(0,0,0,0.12),inset_0_1px_0_rgba(255,255,255,0.65)] backdrop-blur-2xl before:pointer-events-none before:absolute before:inset-x-0 before:top-0 before:h-1/2 before:bg-gradient-to-b before:from-white/50 before:to-transparent">
|
||||
<div className="relative z-10 flex shrink-0 items-center justify-between border-b border-white/40 px-5 py-4 sm:px-7 sm:py-5">
|
||||
<h2
|
||||
id={titleId}
|
||||
className="font-doran text-xl font-normal text-ink sm:text-2xl"
|
||||
>
|
||||
جستجو
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="بستن"
|
||||
onClick={() => setOpen(false)}
|
||||
className="flex size-10 items-center justify-center rounded-full bg-white/30 transition-opacity hover:opacity-60"
|
||||
>
|
||||
<Image
|
||||
src="/images/home/icons/close.svg"
|
||||
alt=""
|
||||
width={22}
|
||||
height={22}
|
||||
unoptimized
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 shrink-0 px-5 py-5 sm:px-7 sm:py-6" dir="rtl">
|
||||
<label className="relative block w-full">
|
||||
<span className="sr-only">جستجو</span>
|
||||
<span className="pointer-events-none absolute start-3 top-1/2 z-10 -translate-y-1/2">
|
||||
<Image
|
||||
src="/images/home/icons/search.svg"
|
||||
alt=""
|
||||
width={16}
|
||||
height={16}
|
||||
unoptimized
|
||||
className="opacity-70"
|
||||
/>
|
||||
</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="نام محصول"
|
||||
autoComplete="off"
|
||||
className="h-11 w-full rounded-[5px] border border-white/60 bg-white/55 pe-3 ps-9 text-right text-sm font-light text-ink shadow-[inset_0_1px_0_rgba(255,255,255,0.8)] placeholder:text-[#818181] outline-none backdrop-blur-sm focus:border-ink/40 focus:bg-white/70"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="relative z-10 min-h-0 flex-1 overflow-y-auto border-t border-white/40 px-5 py-5 sm:px-7 sm:py-6"
|
||||
dir="rtl"
|
||||
>
|
||||
{q.trim().length < 2 ? (
|
||||
<p className="text-sm text-muted">
|
||||
برای نمایش نتایج، حداقل ۲ حرف وارد کنید.
|
||||
</p>
|
||||
) : loading ? (
|
||||
<p className="text-sm text-muted">در حال جستجو...</p>
|
||||
) : !products.length ? (
|
||||
<p className="text-sm text-muted">نتیجهای یافت نشد.</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{products.map((p) => (
|
||||
<li key={p.id}>
|
||||
<Link
|
||||
href={productPath(p)}
|
||||
onClick={() => setOpen(false)}
|
||||
className="flex items-center gap-3 border border-transparent px-2 py-2.5 hover:border-line hover:bg-[#f7f7f5]"
|
||||
>
|
||||
<span className="relative h-14 w-11 shrink-0 overflow-hidden bg-[#f3f3f1]">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={
|
||||
p.imageUrl ||
|
||||
"/images/home/products/coat-blue.jpg"
|
||||
}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm text-ink">
|
||||
{p.nameFa}
|
||||
</span>
|
||||
<span
|
||||
dir="ltr"
|
||||
className="block truncate font-montserrat text-[10px] text-muted"
|
||||
>
|
||||
{p.nameEn}
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -88,6 +203,7 @@ export default function HeaderSearch() {
|
||||
type="button"
|
||||
aria-label="جستجو"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="dialog"
|
||||
className="transition-opacity hover:opacity-70"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
@@ -99,129 +215,7 @@ export default function HeaderSearch() {
|
||||
unoptimized
|
||||
/>
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<div className="fixed inset-0 z-[70]">
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-0 bg-ink/40"
|
||||
aria-label="بستن جستجو"
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="absolute inset-x-0 top-0 bg-white shadow-[0_12px_40px_rgba(0,0,0,0.12)]"
|
||||
>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="mx-auto flex max-w-[900px] items-center gap-3 px-4 py-4 sm:px-6"
|
||||
dir="rtl"
|
||||
>
|
||||
<Image
|
||||
src="/images/home/icons/search.svg"
|
||||
alt=""
|
||||
width={18}
|
||||
height={18}
|
||||
unoptimized
|
||||
/>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="جستجوی محصول یا دستهبندی..."
|
||||
className="min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="text-sm text-muted hover:text-ink"
|
||||
>
|
||||
بستن
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mx-auto max-w-[900px] border-t border-line px-4 py-4 sm:px-6">
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted">در حال جستجو...</p>
|
||||
) : q.trim().length < 2 ? (
|
||||
<p className="text-sm text-muted">
|
||||
حداقل ۲ حرف وارد کنید.
|
||||
</p>
|
||||
) : !products.length && !categories.length ? (
|
||||
<p className="text-sm text-muted">نتیجهای یافت نشد.</p>
|
||||
) : (
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
{categories.length ? (
|
||||
<div>
|
||||
<p className="mb-2 text-xs text-muted">دستهبندیها</p>
|
||||
<ul className="space-y-1">
|
||||
{categories.map((c) => (
|
||||
<li key={c.id}>
|
||||
<Link
|
||||
href={categoryPath(c)}
|
||||
onClick={() => setOpen(false)}
|
||||
className="block border border-transparent px-2 py-2 text-sm hover:border-line hover:bg-[#f7f7f5]"
|
||||
>
|
||||
{c.nameFa}
|
||||
{c.nameEn ? (
|
||||
<span
|
||||
dir="ltr"
|
||||
className="ms-2 font-montserrat text-[10px] text-muted"
|
||||
>
|
||||
{c.nameEn}
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
{products.length ? (
|
||||
<div>
|
||||
<p className="mb-2 text-xs text-muted">محصولات</p>
|
||||
<ul className="space-y-1">
|
||||
{products.map((p) => (
|
||||
<li key={p.id}>
|
||||
<Link
|
||||
href={productPath(p)}
|
||||
onClick={() => setOpen(false)}
|
||||
className="flex items-center gap-3 border border-transparent px-2 py-2 hover:border-line hover:bg-[#f7f7f5]"
|
||||
>
|
||||
<span className="relative h-12 w-10 shrink-0 overflow-hidden bg-[#f3f3f1]">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={
|
||||
p.imageUrl ||
|
||||
"/images/home/products/coat-blue.jpg"
|
||||
}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm text-ink">
|
||||
{p.nameFa}
|
||||
</span>
|
||||
<span
|
||||
dir="ltr"
|
||||
className="block truncate font-montserrat text-[10px] text-muted"
|
||||
>
|
||||
{p.nameEn}
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{modal}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,17 +17,16 @@ export default function HoverFillButton({
|
||||
variant = "light",
|
||||
}: HoverFillButtonProps) {
|
||||
const isDark = variant === "dark";
|
||||
const isExternal = /^https?:\/\//i.test(href);
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
dir="rtl"
|
||||
className={`group relative inline-flex h-[52px] items-center justify-between gap-3 overflow-hidden rounded-[5px] border px-4 backdrop-blur-sm sm:h-[56px] ${
|
||||
isDark
|
||||
? "border-white bg-transparent text-white"
|
||||
: "border-ink bg-white/30 text-ink"
|
||||
} ${className}`}
|
||||
>
|
||||
const classNames = `group relative inline-flex h-[52px] items-center justify-between gap-3 overflow-hidden rounded-[5px] border px-4 backdrop-blur-sm sm:h-[56px] ${
|
||||
isDark
|
||||
? "border-white bg-transparent text-white"
|
||||
: "border-ink bg-white/30 text-ink"
|
||||
} ${className}`;
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<span
|
||||
aria-hidden
|
||||
className={`pointer-events-none absolute inset-0 origin-bottom scale-y-0 transition-transform duration-[400ms] ease-out group-hover:scale-y-100 ${
|
||||
@@ -53,6 +52,20 @@ export default function HoverFillButton({
|
||||
unoptimized
|
||||
className="relative z-10 transition-[filter] duration-[400ms] ease-out group-hover:invert"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
if (isExternal) {
|
||||
return (
|
||||
<a href={href} dir="rtl" className={classNames}>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={href} dir="rtl" className={classNames}>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import HoverFillButton from "./HoverFillButton";
|
||||
import { STORE_URL } from "@/lib/public-api";
|
||||
|
||||
export default function PartnerCta() {
|
||||
return (
|
||||
@@ -14,7 +15,7 @@ export default function PartnerCta() {
|
||||
</div>
|
||||
|
||||
<HoverFillButton
|
||||
href="/partners"
|
||||
href={`${STORE_URL}/account/partners`}
|
||||
variant="dark"
|
||||
className="!h-10 w-fit max-w-none shrink-0 gap-2 self-start px-3 sm:!h-11 sm:self-center [&_span]:!text-sm sm:[&_span]:!text-base [&_img]:!h-4 [&_img]:!w-4"
|
||||
>
|
||||
|
||||
@@ -1,44 +1,39 @@
|
||||
type ProductEmptyStateProps = {
|
||||
title?: string;
|
||||
description?: string;
|
||||
titleFa?: string;
|
||||
titleEn?: string;
|
||||
};
|
||||
|
||||
export default function ProductEmptyState({
|
||||
title = "محصولی پیدا نشد",
|
||||
description = "با فیلترهای انتخابشده نتیجهای وجود ندارد. فیلترها را تغییر دهید یا همه را پاک کنید.",
|
||||
titleFa = "متاسفیم، بر اساس جستجوی شما، محصولی وجود ندارد",
|
||||
titleEn = "We're sorry. There is no result based on your criteria",
|
||||
}: ProductEmptyStateProps) {
|
||||
return (
|
||||
<div
|
||||
dir="rtl"
|
||||
className="mx-auto flex w-full max-w-[1920px] flex-col items-center justify-center px-4 py-20 text-center sm:px-6 sm:py-28 lg:px-[120px] lg:py-32"
|
||||
className="mx-auto mb-16 w-full max-w-[1920px] px-4 sm:mb-20 sm:px-6 lg:mb-24 lg:px-[120px]"
|
||||
role="status"
|
||||
>
|
||||
<div className="flex size-24 items-center justify-center rounded-full border border-[#818181]/35 bg-[#f7f7f7] sm:size-28">
|
||||
<svg
|
||||
width="40"
|
||||
height="40"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden
|
||||
className="text-[#818181]"
|
||||
<div
|
||||
className="flex flex-col items-center justify-center rounded-[5px] border border-[#ececec] px-5 py-10 text-center sm:px-8 sm:py-12"
|
||||
style={{
|
||||
backgroundColor: "#fafafa",
|
||||
backgroundImage:
|
||||
"radial-gradient(circle, rgba(10,10,10,0.06) 0.8px, transparent 1px)",
|
||||
backgroundSize: "14px 14px",
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
dir="rtl"
|
||||
className="max-w-2xl font-doran text-base font-medium leading-8 text-ink sm:text-lg sm:leading-9"
|
||||
>
|
||||
<path
|
||||
d="M4 7h16l-1.2 12.2A2 2 0 0 1 16.81 21H7.19a2 2 0 0 1-1.99-1.8L4 7Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M9 10v7M15 10v7M8 7l1.2-2.4A1 1 0 0 1 10.1 4h3.8a1 1 0 0 1 .9.6L16 7"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
{titleFa}
|
||||
</h2>
|
||||
<p
|
||||
dir="ltr"
|
||||
className="mt-2.5 max-w-xl font-montserrat text-[11px] font-light uppercase tracking-[0.14em] text-[#9a9a9a] sm:text-xs sm:tracking-[0.16em]"
|
||||
>
|
||||
{titleEn}
|
||||
</p>
|
||||
</div>
|
||||
<h2 className="mt-6 text-xl font-normal text-ink sm:text-2xl">{title}</h2>
|
||||
<p className="mt-3 max-w-md text-sm font-light leading-7 text-[#6a6a6a] sm:text-base">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user