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,
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} />
+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
? 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} />
+141 -147
View File
@@ -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}
</>
);
}
+23 -10
View File
@@ -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>
);
}
+2 -1
View File
@@ -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"
>
+25 -30
View File
@@ -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>
);
}