Route partnership CTA to the store account and polish search modal.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-27 21:51:44 +03:30
co-authored by Cursor
parent fa248d2f6a
commit f44058bd6e
7 changed files with 200 additions and 216 deletions
+1 -13
View File
@@ -172,7 +172,6 @@ export default async function CategoryPage({ params, searchParams }: PageProps)
products, products,
query.page, query.page,
); );
const hasExtraFilters = Boolean(query.q || query.min || query.max);
return ( return (
<main className="home-page min-h-dvh bg-white text-ink"> <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"> <div className="space-y-3 sm:space-y-4">
{total === 0 ? ( {total === 0 ? (
<ProductEmptyState <ProductEmptyState />
title={
hasExtraFilters
? "محصولی با این فیلترها پیدا نشد"
: `محصولی در «${resolved.nameFa}» نیست`
}
description={
hasExtraFilters
? "فیلترها را تغییر دهید یا همه را پاک کنید تا محصولات بیشتری ببینید."
: "به‌زودی محصولات این دسته اضافه می‌شوند."
}
/>
) : ( ) : (
<> <>
<ProductGrid products={pageItems} /> <ProductGrid products={pageItems} />
+7
View File
@@ -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`);
}
+1 -15
View File
@@ -92,9 +92,6 @@ export default async function ProductsPage({ searchParams }: PageProps) {
const categoryLabel = query.cat const categoryLabel = query.cat
? findCategoryName(apiCategories, query.cat) ? findCategoryName(apiCategories, query.cat)
: null; : null;
const hasFilters = Boolean(
query.q || query.cat || query.min || query.max || query.sale,
);
const heroTitle = 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"> <div className="space-y-3 sm:space-y-4">
{total === 0 ? ( {total === 0 ? (
<ProductEmptyState <ProductEmptyState />
title={
hasFilters
? "محصولی با این فیلترها پیدا نشد"
: "محصولی موجود نیست"
}
description={
hasFilters
? "فیلترها را تغییر دهید یا همه را پاک کنید تا محصولات بیشتری ببینید."
: "به‌زودی محصولات جدید اضافه می‌شوند."
}
/>
) : ( ) : (
<> <>
<ProductGrid products={pageItems} /> <ProductGrid products={pageItems} />
+141 -147
View File
@@ -2,8 +2,8 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { FormEvent, useEffect, useRef, useState } from "react"; import { useEffect, useId, useRef, useState } from "react";
import { categoryPath } from "@/lib/category-path"; import { createPortal } from "react-dom";
import { productPath } from "@/lib/product-path"; import { productPath } from "@/lib/product-path";
import { API_URL, STORE_SLUG } from "@/lib/public-api"; import { API_URL, STORE_SLUG } from "@/lib/public-api";
@@ -15,29 +15,35 @@ type SearchHit = {
price?: number; price?: number;
}; };
type CategoryHit = {
id: number;
nameFa: string;
nameEn: string;
};
export default function HeaderSearch() { export default function HeaderSearch() {
const titleId = useId();
const [mounted, setMounted] = useState(false);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [q, setQ] = useState(""); const [q, setQ] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [products, setProducts] = useState<SearchHit[]>([]); const [products, setProducts] = useState<SearchHit[]>([]);
const [categories, setCategories] = useState<CategoryHit[]>([]);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
inputRef.current?.focus(); const frame = window.requestAnimationFrame(() => {
inputRef.current?.focus();
});
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false); if (e.key === "Escape") setOpen(false);
}; };
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
window.addEventListener("keydown", onKey); window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey); return () => {
window.cancelAnimationFrame(frame);
document.body.style.overflow = prevOverflow;
window.removeEventListener("keydown", onKey);
};
}, [open]); }, [open]);
useEffect(() => { useEffect(() => {
@@ -45,7 +51,7 @@ export default function HeaderSearch() {
const query = q.trim(); const query = q.trim();
if (query.length < 2) { if (query.length < 2) {
setProducts([]); setProducts([]);
setCategories([]); setLoading(false);
return; return;
} }
const t = window.setTimeout(async () => { const t = window.setTimeout(async () => {
@@ -54,19 +60,16 @@ export default function HeaderSearch() {
const params = new URLSearchParams({ const params = new URLSearchParams({
storeSlug: STORE_SLUG, storeSlug: STORE_SLUG,
q: query, q: query,
limit: "8", limit: "12",
}); });
const res = await fetch(`${API_URL}/public/search?${params}`); const res = await fetch(`${API_URL}/public/search?${params}`);
if (!res.ok) throw new Error("search failed"); if (!res.ok) throw new Error("search failed");
const data = (await res.json()) as { const data = (await res.json()) as {
products?: SearchHit[]; products?: SearchHit[];
categories?: CategoryHit[];
}; };
setProducts(data.products ?? []); setProducts(data.products ?? []);
setCategories(data.categories ?? []);
} catch { } catch {
setProducts([]); setProducts([]);
setCategories([]);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -74,13 +77,125 @@ export default function HeaderSearch() {
return () => window.clearTimeout(t); return () => window.clearTimeout(t);
}, [q, open]); }, [q, open]);
function handleSubmit(e: FormEvent) { const modal =
e.preventDefault(); open && mounted
const query = q.trim(); ? createPortal(
if (!query) return; <div
setOpen(false); className="fixed inset-0 z-[200] flex items-center justify-center p-4 sm:p-6"
window.location.href = `/products?q=${encodeURIComponent(query)}`; 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 ( return (
<> <>
@@ -88,6 +203,7 @@ export default function HeaderSearch() {
type="button" type="button"
aria-label="جستجو" aria-label="جستجو"
aria-expanded={open} aria-expanded={open}
aria-haspopup="dialog"
className="transition-opacity hover:opacity-70" className="transition-opacity hover:opacity-70"
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
> >
@@ -99,129 +215,7 @@ export default function HeaderSearch() {
unoptimized unoptimized
/> />
</button> </button>
{modal}
{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}
</> </>
); );
} }
+23 -10
View File
@@ -17,17 +17,16 @@ export default function HoverFillButton({
variant = "light", variant = "light",
}: HoverFillButtonProps) { }: HoverFillButtonProps) {
const isDark = variant === "dark"; const isDark = variant === "dark";
const isExternal = /^https?:\/\//i.test(href);
return ( 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] ${
<Link isDark
href={href} ? "border-white bg-transparent text-white"
dir="rtl" : "border-ink bg-white/30 text-ink"
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] ${ } ${className}`;
isDark
? "border-white bg-transparent text-white" const content = (
: "border-ink bg-white/30 text-ink" <>
} ${className}`}
>
<span <span
aria-hidden 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 ${ 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 unoptimized
className="relative z-10 transition-[filter] duration-[400ms] ease-out group-hover:invert" 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> </Link>
); );
} }
+2 -1
View File
@@ -1,4 +1,5 @@
import HoverFillButton from "./HoverFillButton"; import HoverFillButton from "./HoverFillButton";
import { STORE_URL } from "@/lib/public-api";
export default function PartnerCta() { export default function PartnerCta() {
return ( return (
@@ -14,7 +15,7 @@ export default function PartnerCta() {
</div> </div>
<HoverFillButton <HoverFillButton
href="/partners" href={`${STORE_URL}/account/partners`}
variant="dark" 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" 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"
> >
+25 -30
View File
@@ -1,44 +1,39 @@
type ProductEmptyStateProps = { type ProductEmptyStateProps = {
title?: string; titleFa?: string;
description?: string; titleEn?: string;
}; };
export default function ProductEmptyState({ export default function ProductEmptyState({
title = "محصولی پیدا نشد", titleFa = "متاسفیم، بر اساس جستجوی شما، محصولی وجود ندارد",
description = "با فیلترهای انتخاب‌شده نتیجه‌ای وجود ندارد. فیلترها را تغییر دهید یا همه را پاک کنید.", titleEn = "We're sorry. There is no result based on your criteria",
}: ProductEmptyStateProps) { }: ProductEmptyStateProps) {
return ( return (
<div <div
dir="rtl" className="mx-auto mb-16 w-full max-w-[1920px] px-4 sm:mb-20 sm:px-6 lg:mb-24 lg:px-[120px]"
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"
role="status" role="status"
> >
<div className="flex size-24 items-center justify-center rounded-full border border-[#818181]/35 bg-[#f7f7f7] sm:size-28"> <div
<svg 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"
width="40" style={{
height="40" backgroundColor: "#fafafa",
viewBox="0 0 24 24" backgroundImage:
fill="none" "radial-gradient(circle, rgba(10,10,10,0.06) 0.8px, transparent 1px)",
aria-hidden backgroundSize: "14px 14px",
className="text-[#818181]" }}
>
<h2
dir="rtl"
className="max-w-2xl font-doran text-base font-medium leading-8 text-ink sm:text-lg sm:leading-9"
> >
<path {titleFa}
d="M4 7h16l-1.2 12.2A2 2 0 0 1 16.81 21H7.19a2 2 0 0 1-1.99-1.8L4 7Z" </h2>
stroke="currentColor" <p
strokeWidth="1.5" 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]"
<path >
d="M9 10v7M15 10v7M8 7l1.2-2.4A1 1 0 0 1 10.1 4h3.8a1 1 0 0 1 .9.6L16 7" {titleEn}
stroke="currentColor" </p>
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
</div> </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> </div>
); );
} }