Connect storefront to backend catalog, search, and home slots.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
2f857f1a90
commit
0074ab6629
@@ -0,0 +1,112 @@
|
||||
import type { Metadata } from "next";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import SiteHeader from "@/components/home/SiteHeader";
|
||||
import SiteFooter from "@/components/home/SiteFooter";
|
||||
import AboutSection from "@/components/home/AboutSection";
|
||||
import PartnerCta from "@/components/home/PartnerCta";
|
||||
import ProductListHero from "@/components/products/ProductListHero";
|
||||
import { ABOUT_PAGE } from "@/data/pages";
|
||||
import { mapHomeSlots } from "@/lib/map-home-slots";
|
||||
import { fetchPublicWebsiteSlots } from "@/lib/website-slots";
|
||||
import {
|
||||
fetchPublicCategories,
|
||||
mapCategoriesToMegaTabs,
|
||||
} from "@/lib/categories-api";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "درباره ما | مانا کالکشن",
|
||||
description: ABOUT_PAGE.lead,
|
||||
};
|
||||
|
||||
export default async function AboutPage() {
|
||||
const [slotItems, apiCategories] = await Promise.all([
|
||||
fetchPublicWebsiteSlots(),
|
||||
fetchPublicCategories(),
|
||||
]);
|
||||
const home = mapHomeSlots(slotItems);
|
||||
const megaTabs = mapCategoriesToMegaTabs(
|
||||
apiCategories,
|
||||
home.megaTabs.map((t) => ({ image: t.image })),
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="home-page min-h-dvh bg-white text-ink">
|
||||
<div className="relative">
|
||||
<SiteHeader megaTabs={megaTabs.length ? megaTabs : home.megaTabs} />
|
||||
<ProductListHero
|
||||
titleFa={ABOUT_PAGE.titleFa}
|
||||
titleEn={ABOUT_PAGE.titleEn}
|
||||
heroImage={home.heroSlides[0]?.image || ABOUT_PAGE.heroImage}
|
||||
breadcrumbs={ABOUT_PAGE.breadcrumbs}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AboutSection
|
||||
titleFa={home.about.titleFa}
|
||||
titleEn={home.about.titleEn}
|
||||
lead={home.about.lead}
|
||||
body={home.about.body}
|
||||
image={home.about.image}
|
||||
imageSecondary={home.about.imageSecondary}
|
||||
/>
|
||||
|
||||
<section className="mx-auto max-w-[1200px] px-4 py-12 sm:px-6 sm:py-16 lg:px-8">
|
||||
<div className="grid gap-10 lg:grid-cols-2 lg:gap-14 lg:items-start">
|
||||
<div>
|
||||
<h2 className="font-doran text-2xl font-light text-ink">
|
||||
{ABOUT_PAGE.storyTitleFa}
|
||||
</h2>
|
||||
<p
|
||||
dir="ltr"
|
||||
className="mt-1 font-montserrat text-[10px] uppercase tracking-[0.16em] text-muted"
|
||||
>
|
||||
{ABOUT_PAGE.storyTitleEn}
|
||||
</p>
|
||||
<p className="mt-6 text-sm leading-8 text-ink/80">
|
||||
{ABOUT_PAGE.story}
|
||||
</p>
|
||||
<Link
|
||||
href="/contact"
|
||||
className="mt-8 inline-flex border border-ink px-5 py-2.5 text-sm transition-colors hover:bg-ink hover:text-white"
|
||||
>
|
||||
تماس با ما
|
||||
</Link>
|
||||
</div>
|
||||
<ul className="space-y-6">
|
||||
{ABOUT_PAGE.values.map((value) => (
|
||||
<li
|
||||
key={value.titleEn}
|
||||
className="border border-line bg-[#f7f7f5] p-5"
|
||||
>
|
||||
<p className="text-base text-ink">{value.titleFa}</p>
|
||||
<p
|
||||
dir="ltr"
|
||||
className="mt-1 font-montserrat text-[10px] uppercase tracking-[0.16em] text-muted"
|
||||
>
|
||||
{value.titleEn}
|
||||
</p>
|
||||
<p className="mt-3 text-sm leading-7 text-ink/75">
|
||||
{value.body}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="relative mt-12 aspect-[21/9] overflow-hidden bg-[#f3f3f1]">
|
||||
<Image
|
||||
src={home.about.imageSecondary || ABOUT_PAGE.imageSecondary}
|
||||
alt=""
|
||||
fill
|
||||
sizes="100vw"
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<PartnerCta />
|
||||
<SiteFooter paymentBadges={home.paymentBadges} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import SiteHeader from "@/components/home/SiteHeader";
|
||||
import SiteFooter from "@/components/home/SiteFooter";
|
||||
import LeatherBanner from "@/components/home/LeatherBanner";
|
||||
import PartnerCta from "@/components/home/PartnerCta";
|
||||
import ProductListHero from "@/components/products/ProductListHero";
|
||||
import ProductFilters from "@/components/products/ProductFilters";
|
||||
import ProductGrid from "@/components/products/ProductGrid";
|
||||
import ProductPagination from "@/components/products/ProductPagination";
|
||||
import { getCategoryPage } from "@/data/pages";
|
||||
import { mapHomeSlots } from "@/lib/map-home-slots";
|
||||
import { fetchPublicWebsiteSlots } from "@/lib/website-slots";
|
||||
import { getStorefrontProducts } from "@/lib/products-api";
|
||||
import {
|
||||
fetchPublicCategories,
|
||||
mapCategoriesToMegaTabs,
|
||||
type ApiCategoryNode,
|
||||
} from "@/lib/categories-api";
|
||||
import { categoryPath, categorySlug } from "@/lib/category-path";
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ path: string[] }>;
|
||||
};
|
||||
|
||||
function findCategory(
|
||||
nodes: ApiCategoryNode[],
|
||||
id: string,
|
||||
): ApiCategoryNode | null {
|
||||
for (const node of nodes) {
|
||||
if (String(node.id) === id) return node;
|
||||
const child = findCategory(node.children ?? [], id);
|
||||
if (child) return child;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolveCategory(id: string) {
|
||||
const apiCategories = await fetchPublicCategories();
|
||||
const apiCat = findCategory(apiCategories, id);
|
||||
if (apiCat) {
|
||||
return {
|
||||
apiCategories,
|
||||
apiCat,
|
||||
staticCat: null as ReturnType<typeof getCategoryPage>,
|
||||
id: apiCat.id,
|
||||
nameFa: apiCat.nameFa,
|
||||
nameEn: apiCat.nameEn || apiCat.nameFa,
|
||||
imageUrl: apiCat.imageUrl,
|
||||
description: `مجموعهای از محصولات ${apiCat.nameFa} در مانا کالکشن.`,
|
||||
};
|
||||
}
|
||||
const staticCat = getCategoryPage(id);
|
||||
if (staticCat) {
|
||||
return {
|
||||
apiCategories,
|
||||
apiCat: null,
|
||||
staticCat,
|
||||
id: staticCat.id,
|
||||
nameFa: staticCat.nameFa,
|
||||
nameEn: staticCat.nameEn,
|
||||
imageUrl: null as string | null,
|
||||
description: staticCat.description,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: PageProps): Promise<Metadata> {
|
||||
const { path } = await params;
|
||||
const id = path?.[0];
|
||||
if (!id) return { title: "دسته | مانا کالکشن" };
|
||||
const resolved = await resolveCategory(id);
|
||||
if (!resolved) return { title: "دسته | مانا کالکشن" };
|
||||
return {
|
||||
title: `${resolved.nameFa} | مانا کالکشن`,
|
||||
description: resolved.description,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function CategoryPage({ params }: PageProps) {
|
||||
const { path } = await params;
|
||||
if (!path?.length || path.length > 2) notFound();
|
||||
|
||||
const [id, slug] = path;
|
||||
const resolved = await resolveCategory(id);
|
||||
if (!resolved) notFound();
|
||||
|
||||
const expectedSlug = categorySlug({
|
||||
id: resolved.id,
|
||||
nameEn: resolved.nameEn,
|
||||
nameFa: resolved.nameFa,
|
||||
});
|
||||
const canonical = categoryPath({
|
||||
id: resolved.id,
|
||||
nameEn: resolved.nameEn,
|
||||
nameFa: resolved.nameFa,
|
||||
});
|
||||
|
||||
// Legacy `/categories/:id` or wrong slug → canonical `/categories/:id/:slug`
|
||||
if (path.length === 1 || slug !== expectedSlug) {
|
||||
redirect(canonical);
|
||||
}
|
||||
|
||||
const [slotItems, products] = await Promise.all([
|
||||
fetchPublicWebsiteSlots(),
|
||||
getStorefrontProducts(
|
||||
resolved.apiCat ? { categoryId: resolved.id } : undefined,
|
||||
),
|
||||
]);
|
||||
const home = mapHomeSlots(slotItems);
|
||||
const megaTabs = mapCategoriesToMegaTabs(
|
||||
resolved.apiCategories,
|
||||
home.megaTabs.map((t) => ({ image: t.image })),
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="home-page min-h-dvh bg-white text-ink">
|
||||
<div className="relative">
|
||||
<SiteHeader megaTabs={megaTabs.length ? megaTabs : home.megaTabs} />
|
||||
<ProductListHero
|
||||
titleFa={resolved.nameFa}
|
||||
titleEn={resolved.nameEn}
|
||||
heroImage={
|
||||
home.heroSlides[0]?.image ||
|
||||
resolved.imageUrl ||
|
||||
resolved.staticCat?.heroImage
|
||||
}
|
||||
breadcrumbs={[
|
||||
{ label: "خانه", href: "/" },
|
||||
{ label: "دستهبندیها", href: "/categories" },
|
||||
{ label: resolved.nameFa },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-[1200px] px-4 pt-8 sm:px-6 lg:px-8">
|
||||
<p className="max-w-2xl text-sm leading-7 text-ink/75">
|
||||
{resolved.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-6 sm:mt-8 lg:mt-[48px]">
|
||||
<ProductFilters />
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
<ProductGrid products={products} />
|
||||
<ProductPagination />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LeatherBanner
|
||||
image={home.leather.image}
|
||||
nameFa={home.leather.nameFa}
|
||||
nameEn={home.leather.nameEn}
|
||||
linkText={home.leather.linkText}
|
||||
link={home.leather.link}
|
||||
/>
|
||||
<PartnerCta />
|
||||
<SiteFooter paymentBadges={home.paymentBadges} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { Metadata } from "next";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import SiteHeader from "@/components/home/SiteHeader";
|
||||
import SiteFooter from "@/components/home/SiteFooter";
|
||||
import PartnerCta from "@/components/home/PartnerCta";
|
||||
import ProductListHero from "@/components/products/ProductListHero";
|
||||
import { LIST_PAGE_META } from "@/data/products";
|
||||
import { mapHomeSlots } from "@/lib/map-home-slots";
|
||||
import { fetchPublicWebsiteSlots } from "@/lib/website-slots";
|
||||
import {
|
||||
fetchPublicCategories,
|
||||
mapCategoriesToMegaTabs,
|
||||
mapCategoriesToStrip,
|
||||
} from "@/lib/categories-api";
|
||||
import { categoryPath } from "@/lib/category-path";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "دستهبندیها | مانا کالکشن",
|
||||
description: "مرور دستهبندیهای مانا کالکشن",
|
||||
};
|
||||
|
||||
export default async function CategoriesIndexPage() {
|
||||
const [slotItems, apiCategories] = await Promise.all([
|
||||
fetchPublicWebsiteSlots(),
|
||||
fetchPublicCategories(),
|
||||
]);
|
||||
const home = mapHomeSlots(slotItems);
|
||||
const list = mapCategoriesToStrip(apiCategories);
|
||||
const megaTabs = mapCategoriesToMegaTabs(
|
||||
apiCategories,
|
||||
home.megaTabs.map((t) => ({ image: t.image })),
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="home-page min-h-dvh bg-white text-ink">
|
||||
<div className="relative">
|
||||
<SiteHeader megaTabs={megaTabs.length ? megaTabs : home.megaTabs} />
|
||||
<ProductListHero
|
||||
titleFa="دستهبندیها"
|
||||
titleEn="Categories"
|
||||
heroImage={home.heroSlides[0]?.image || LIST_PAGE_META.heroImage}
|
||||
breadcrumbs={[
|
||||
{ label: "خانه", href: "/" },
|
||||
{ label: "دستهبندیها" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-[1200px] px-4 py-12 sm:px-6 sm:py-16 lg:px-8">
|
||||
<ul className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-3 lg:gap-6">
|
||||
{list.map((cat) => (
|
||||
<li key={cat.id}>
|
||||
<Link
|
||||
href={cat.href || categoryPath(cat)}
|
||||
className="group block overflow-hidden rounded-[5px] border border-line bg-white transition-colors hover:border-ink"
|
||||
>
|
||||
<div className="relative aspect-[4/5] overflow-hidden bg-[#f6f6f4]">
|
||||
<Image
|
||||
src={cat.image}
|
||||
alt={cat.nameFa}
|
||||
fill
|
||||
sizes="(max-width: 768px) 50vw, 360px"
|
||||
className="object-cover transition-transform duration-500 group-hover:scale-[1.03]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end justify-between gap-3 px-4 py-3">
|
||||
<div>
|
||||
<p className="text-base text-ink">{cat.nameFa}</p>
|
||||
<p
|
||||
dir="ltr"
|
||||
className="mt-1 font-montserrat text-[10px] uppercase tracking-[0.16em] text-muted"
|
||||
>
|
||||
{cat.nameEn}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs text-muted transition-colors group-hover:text-ink">
|
||||
مشاهده
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<PartnerCta />
|
||||
<SiteFooter paymentBadges={home.paymentBadges} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { Metadata } from "next";
|
||||
import SiteHeader from "@/components/home/SiteHeader";
|
||||
import SiteFooter from "@/components/home/SiteFooter";
|
||||
import PartnerCta from "@/components/home/PartnerCta";
|
||||
import ProductListHero from "@/components/products/ProductListHero";
|
||||
import ContactForm from "@/components/contact/ContactForm";
|
||||
import { CONTACT_PAGE } from "@/data/pages";
|
||||
import { mapHomeSlots } from "@/lib/map-home-slots";
|
||||
import { fetchPublicWebsiteSlots } from "@/lib/website-slots";
|
||||
import {
|
||||
fetchPublicCategories,
|
||||
mapCategoriesToMegaTabs,
|
||||
} from "@/lib/categories-api";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "تماس با ما | مانا کالکشن",
|
||||
description: CONTACT_PAGE.lead,
|
||||
};
|
||||
|
||||
export default async function ContactPage() {
|
||||
const [slotItems, apiCategories] = await Promise.all([
|
||||
fetchPublicWebsiteSlots(),
|
||||
fetchPublicCategories(),
|
||||
]);
|
||||
const home = mapHomeSlots(slotItems);
|
||||
const megaTabs = mapCategoriesToMegaTabs(
|
||||
apiCategories,
|
||||
home.megaTabs.map((t) => ({ image: t.image })),
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="home-page min-h-dvh bg-white text-ink">
|
||||
<div className="relative">
|
||||
<SiteHeader megaTabs={megaTabs.length ? megaTabs : home.megaTabs} />
|
||||
<ProductListHero
|
||||
titleFa={CONTACT_PAGE.titleFa}
|
||||
titleEn={CONTACT_PAGE.titleEn}
|
||||
heroImage={home.heroSlides[0]?.image || CONTACT_PAGE.heroImage}
|
||||
breadcrumbs={CONTACT_PAGE.breadcrumbs}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-[1200px] px-4 py-12 sm:px-6 sm:py-16 lg:px-8">
|
||||
<div className="grid grid-cols-1 gap-10 lg:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)] lg:gap-14">
|
||||
<aside className="space-y-6 border border-line bg-[#f7f7f5] p-6 sm:p-8">
|
||||
<div>
|
||||
<h2 className="font-doran text-2xl font-light text-ink">
|
||||
اطلاعات تماس
|
||||
</h2>
|
||||
<p
|
||||
dir="ltr"
|
||||
className="mt-1 font-montserrat text-[10px] uppercase tracking-[0.16em] text-muted"
|
||||
>
|
||||
Get in touch
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<dl className="space-y-4 text-sm leading-7">
|
||||
<div>
|
||||
<dt className="text-muted">آدرس</dt>
|
||||
<dd className="text-ink">{CONTACT_PAGE.address}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted">تلفن فروشگاه</dt>
|
||||
<dd dir="ltr" className="font-montserrat text-ink">
|
||||
{CONTACT_PAGE.phoneStore}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted">تلفن همراه</dt>
|
||||
<dd dir="ltr" className="font-montserrat text-ink">
|
||||
{CONTACT_PAGE.phoneMobile}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted">ایمیل</dt>
|
||||
<dd dir="ltr" className="font-montserrat text-ink">
|
||||
{CONTACT_PAGE.email}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted">ساعات پاسخگویی</dt>
|
||||
<dd className="text-ink">{CONTACT_PAGE.hours}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</aside>
|
||||
|
||||
<div>
|
||||
<h2 className="font-doran text-2xl font-light text-ink">
|
||||
فرم تماس
|
||||
</h2>
|
||||
<p
|
||||
dir="ltr"
|
||||
className="mt-1 font-montserrat text-[10px] uppercase tracking-[0.16em] text-muted"
|
||||
>
|
||||
Send a message
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<ContactForm />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PartnerCta />
|
||||
<SiteFooter paymentBadges={home.paymentBadges} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+80
-2
@@ -1,7 +1,7 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #fbfbfa;
|
||||
--background: #ffffff;
|
||||
--foreground: #0a0a0a;
|
||||
--surface: #ffffff;
|
||||
--muted: #6b6b6b;
|
||||
@@ -16,13 +16,17 @@
|
||||
--color-muted: var(--muted);
|
||||
--color-line: var(--line);
|
||||
--color-ink: var(--ink);
|
||||
--font-sans: var(--font-doran);
|
||||
--font-sans: var(--font-iranyekan);
|
||||
--font-display: var(--font-cailyne);
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-iranyekan), Tahoma, sans-serif;
|
||||
}
|
||||
|
||||
.font-doran {
|
||||
font-family: var(--font-doran), Tahoma, sans-serif;
|
||||
}
|
||||
|
||||
@@ -35,6 +39,10 @@ body {
|
||||
font-family: var(--font-montserrat), system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.home-page {
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
@keyframes coming-soon-rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -157,6 +165,76 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes hero-ken-burns {
|
||||
from {
|
||||
transform: scale(1);
|
||||
}
|
||||
to {
|
||||
transform: scale(1.06);
|
||||
}
|
||||
}
|
||||
|
||||
.hero-ken-burns {
|
||||
animation: hero-ken-burns 10s linear forwards;
|
||||
will-change: transform;
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
.hero-ken-burns-paused {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
.seq-fade-item {
|
||||
transition:
|
||||
opacity 0.7s cubic-bezier(0.16, 1, 0.3, 1),
|
||||
transform 0.7s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.seq-fade-item {
|
||||
opacity: 1 !important;
|
||||
transform: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes hero-copy-rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(18px);
|
||||
filter: blur(6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
|
||||
.hero-copy-item {
|
||||
opacity: 0;
|
||||
animation: hero-copy-rise 0.75s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
.hero-copy-item-2 {
|
||||
animation-delay: 0.12s;
|
||||
}
|
||||
|
||||
.hero-copy-item-3 {
|
||||
animation-delay: 0.24s;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hero-ken-burns,
|
||||
.hero-copy-item {
|
||||
animation: none !important;
|
||||
opacity: 1 !important;
|
||||
filter: none !important;
|
||||
transform: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.mana-canvas {
|
||||
background:
|
||||
radial-gradient(ellipse 80% 60% at 20% 20%, rgba(0, 0, 0, 0.02), transparent 55%),
|
||||
|
||||
+46
-3
@@ -3,6 +3,48 @@ import { Montserrat } from "next/font/google";
|
||||
import localFont from "next/font/local";
|
||||
import "./globals.css";
|
||||
|
||||
const iranYekan = localFont({
|
||||
src: [
|
||||
{
|
||||
path: "../fonts/iranyekan/IRANYekanThinFaNum.ttf",
|
||||
weight: "100",
|
||||
style: "normal",
|
||||
},
|
||||
{
|
||||
path: "../fonts/iranyekan/IRANYekanLightFaNum.ttf",
|
||||
weight: "300",
|
||||
style: "normal",
|
||||
},
|
||||
{
|
||||
path: "../fonts/iranyekan/IRANYekanRegularFaNum.ttf",
|
||||
weight: "400",
|
||||
style: "normal",
|
||||
},
|
||||
{
|
||||
path: "../fonts/iranyekan/IRANYekanMediumFaNum.ttf",
|
||||
weight: "500",
|
||||
style: "normal",
|
||||
},
|
||||
{
|
||||
path: "../fonts/iranyekan/IRANYekanBoldFaNum.ttf",
|
||||
weight: "700",
|
||||
style: "normal",
|
||||
},
|
||||
{
|
||||
path: "../fonts/iranyekan/IRANYekanExtraBoldFaNum.ttf",
|
||||
weight: "800",
|
||||
style: "normal",
|
||||
},
|
||||
{
|
||||
path: "../fonts/iranyekan/IRANYekanBlackFaNum.ttf",
|
||||
weight: "900",
|
||||
style: "normal",
|
||||
},
|
||||
],
|
||||
variable: "--font-iranyekan",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const doran = localFont({
|
||||
src: [
|
||||
{
|
||||
@@ -50,8 +92,9 @@ const montserrat = Montserrat({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "بهزودی | مانا کالکشن",
|
||||
description: "فروشگاه آنلاین مانا کالکشن بهزودی در دسترس خواهد بود.",
|
||||
title: "مانا کالکشن | Mana Collection",
|
||||
description:
|
||||
"فروشگاه آنلاین مانا کالکشن — پوشاک زنانه، کالکشن پاییزه و انتخابهای خاص برای بانویی که خودش را انتخاب میکند.",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -63,7 +106,7 @@ export default function RootLayout({
|
||||
<html
|
||||
lang="fa"
|
||||
dir="rtl"
|
||||
className={`${doran.variable} ${cailyne.variable} ${montserrat.variable} h-full antialiased`}
|
||||
className={`${iranYekan.variable} ${doran.variable} ${cailyne.variable} ${montserrat.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full font-sans text-foreground">{children}</body>
|
||||
</html>
|
||||
|
||||
+102
-74
@@ -1,81 +1,109 @@
|
||||
import Image from "next/image";
|
||||
import ManaLogo from "@/components/ui/ManaLogo";
|
||||
import SiteHeader from "@/components/home/SiteHeader";
|
||||
import HeroSlider from "@/components/home/HeroSlider";
|
||||
import CategoryStrip from "@/components/home/CategoryStrip";
|
||||
import ProductRow from "@/components/home/ProductRow";
|
||||
import LeatherBanner from "@/components/home/LeatherBanner";
|
||||
import PartnerCta from "@/components/home/PartnerCta";
|
||||
import FeaturesBar from "@/components/home/FeaturesBar";
|
||||
import BlackCollection from "@/components/home/BlackCollection";
|
||||
import AboutSection from "@/components/home/AboutSection";
|
||||
import SiteFooter from "@/components/home/SiteFooter";
|
||||
import { mapHomeSlots } from "@/lib/map-home-slots";
|
||||
import {
|
||||
fetchPublicWebsiteSlots,
|
||||
itemsForSlot,
|
||||
} from "@/lib/website-slots";
|
||||
import { getStorefrontCatalog } from "@/lib/products-api";
|
||||
import {
|
||||
fetchPublicCategories,
|
||||
mapCategoriesToMegaTabs,
|
||||
mapCategoriesToStrip,
|
||||
} from "@/lib/categories-api";
|
||||
|
||||
const STORE_LOGIN =
|
||||
process.env.NEXT_PUBLIC_STORE_URL?.replace(/\/$/, "") ||
|
||||
"http://store.mana-collection.com:3001";
|
||||
export default async function HomePage() {
|
||||
const [slotItems, catalog, apiCategories] = await Promise.all([
|
||||
fetchPublicWebsiteSlots(),
|
||||
getStorefrontCatalog(),
|
||||
fetchPublicCategories(),
|
||||
]);
|
||||
const home = mapHomeSlots(slotItems);
|
||||
|
||||
const saleProducts = catalog.promo1;
|
||||
const discountProducts = catalog.promo2;
|
||||
const autumnProducts = catalog.promo3;
|
||||
|
||||
const stripCategories = mapCategoriesToStrip(apiCategories);
|
||||
const megaTabs = mapCategoriesToMegaTabs(
|
||||
apiCategories,
|
||||
home.megaTabs.map((t) => ({
|
||||
image: t.image,
|
||||
nameFa: t.nameFa,
|
||||
href: t.href,
|
||||
})),
|
||||
);
|
||||
|
||||
// Prefer Website Images → category-strip slots over catalog categories
|
||||
const hasWebsiteCategoryStrip =
|
||||
itemsForSlot(slotItems, "category-strip").length > 0;
|
||||
const categories = hasWebsiteCategoryStrip
|
||||
? home.categories
|
||||
: stripCategories.length
|
||||
? stripCategories
|
||||
: home.categories;
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<main className="coming-soon relative min-h-dvh overflow-hidden bg-[#f7f7f5] text-ink">
|
||||
{/* dir=ltr keeps content on visual left, image on visual right */}
|
||||
<div dir="ltr" className="grid min-h-dvh grid-cols-1 lg:grid-cols-2">
|
||||
{/* Content — left */}
|
||||
<section className="relative order-2 flex min-h-[52dvh] flex-col lg:order-1 lg:min-h-dvh">
|
||||
<div className="mana-canvas absolute inset-0" aria-hidden />
|
||||
|
||||
<div
|
||||
dir="rtl"
|
||||
className="relative z-10 flex flex-1 flex-col px-7 py-8 sm:px-12 sm:py-10 lg:px-16 lg:py-14"
|
||||
>
|
||||
<header className="coming-soon-seq coming-soon-seq-1 flex shrink-0 justify-center">
|
||||
<ManaLogo
|
||||
href=""
|
||||
width={400}
|
||||
priority
|
||||
showTagline
|
||||
variant="white"
|
||||
blackBg
|
||||
className="w-[400px] max-w-[min(400px,100%)]"
|
||||
/>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-1 flex-col items-center justify-center py-12 text-center sm:py-16">
|
||||
<div className="max-w-md">
|
||||
<p
|
||||
dir="ltr"
|
||||
className="coming-soon-seq coming-soon-seq-2 mb-4 font-display text-sm tracking-[0.2em] text-muted sm:text-base"
|
||||
>
|
||||
Coming soon
|
||||
</p>
|
||||
<h1 className="coming-soon-seq coming-soon-seq-3 mb-5 text-4xl leading-tight text-ink sm:text-5xl">
|
||||
بهزودی
|
||||
</h1>
|
||||
<p className="coming-soon-seq coming-soon-seq-4 text-sm leading-8 text-muted sm:text-base sm:leading-8">
|
||||
در این آدرس میزبان شما خواهیم بود.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="coming-soon-seq coming-soon-seq-5 shrink-0 pt-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<p className="text-xs leading-6 text-muted">
|
||||
حساب دارید؟ وارد شوید.
|
||||
</p>
|
||||
<a
|
||||
href={`${STORE_LOGIN}/login`}
|
||||
className="coming-soon-login inline-flex items-center justify-center border border-ink bg-ink px-7 py-2.5 text-sm text-white transition-colors hover:bg-white hover:text-ink"
|
||||
>
|
||||
ورود
|
||||
</a>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Image — right */}
|
||||
<aside className="coming-soon-seq-image relative order-1 min-h-[42dvh] overflow-hidden lg:order-2 lg:min-h-dvh">
|
||||
<Image
|
||||
src="/images/image1.jpg"
|
||||
alt="مانا کالکشن"
|
||||
fill
|
||||
priority
|
||||
sizes="(max-width: 1024px) 100vw, 50vw"
|
||||
className="coming-soon-image object-cover object-center"
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-l from-transparent via-transparent to-black/10" />
|
||||
</aside>
|
||||
<main className="home-page min-h-dvh bg-white text-ink">
|
||||
<div className="relative">
|
||||
<SiteHeader megaTabs={megaTabs.length ? megaTabs : home.megaTabs} />
|
||||
<HeroSlider slides={home.heroSlides} />
|
||||
</div>
|
||||
|
||||
<CategoryStrip categories={categories} />
|
||||
|
||||
<div className="flex flex-col gap-10 lg:gap-14">
|
||||
<ProductRow
|
||||
products={saleProducts}
|
||||
promo="sale"
|
||||
stagger
|
||||
promoSlot={home.promoSale}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<LeatherBanner
|
||||
image={home.leather.image}
|
||||
nameFa={home.leather.nameFa}
|
||||
nameEn={home.leather.nameEn}
|
||||
linkText={home.leather.linkText}
|
||||
link={home.leather.link}
|
||||
/>
|
||||
<PartnerCta />
|
||||
</div>
|
||||
|
||||
<ProductRow
|
||||
products={discountProducts}
|
||||
promo="discount"
|
||||
stagger
|
||||
promoSlot={home.promoDiscount}
|
||||
/>
|
||||
<FeaturesBar />
|
||||
<BlackCollection items={home.blackItems} />
|
||||
<ProductRow
|
||||
products={autumnProducts}
|
||||
promo="autumn"
|
||||
stagger
|
||||
promoSlot={home.promoAutumn}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AboutSection
|
||||
titleFa={home.about.titleFa}
|
||||
titleEn={home.about.titleEn}
|
||||
lead={home.about.lead}
|
||||
body={home.about.body}
|
||||
image={home.about.image}
|
||||
imageSecondary={home.about.imageSecondary}
|
||||
/>
|
||||
<SiteFooter paymentBadges={home.paymentBadges} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import SiteHeader from "@/components/home/SiteHeader";
|
||||
import SiteFooter from "@/components/home/SiteFooter";
|
||||
import ProductDetailHero from "@/components/product-detail/ProductDetailHero";
|
||||
import ProductGallery from "@/components/product-detail/ProductGallery";
|
||||
import ProductBuyBox from "@/components/product-detail/ProductBuyBox";
|
||||
import ProductTabs from "@/components/product-detail/ProductTabs";
|
||||
import SimilarProducts from "@/components/product-detail/SimilarProducts";
|
||||
import { getProductDetail } from "@/data/product-detail";
|
||||
import { mapHomeSlots } from "@/lib/map-home-slots";
|
||||
import { fetchPublicWebsiteSlots } from "@/lib/website-slots";
|
||||
import {
|
||||
fetchPublicProduct,
|
||||
getStorefrontProducts,
|
||||
mapApiProductToDetail,
|
||||
} from "@/lib/products-api";
|
||||
import {
|
||||
fetchPublicCategories,
|
||||
mapCategoriesToMegaTabs,
|
||||
} from "@/lib/categories-api";
|
||||
import { productPath, productSlug } from "@/lib/product-path";
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ path: string[] }>;
|
||||
};
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: PageProps): Promise<Metadata> {
|
||||
const { path } = await params;
|
||||
const id = path?.[0];
|
||||
if (!id) return { title: "محصول | مانا کالکشن" };
|
||||
const api = await fetchPublicProduct(id);
|
||||
if (api) {
|
||||
return {
|
||||
title: `${api.nameFa} | مانا کالکشن`,
|
||||
description: api.summary || api.description || api.nameFa,
|
||||
};
|
||||
}
|
||||
const fallback = getProductDetail(id);
|
||||
if (!fallback) return { title: "محصول | مانا کالکشن" };
|
||||
return {
|
||||
title: `${fallback.nameFa} | مانا کالکشن`,
|
||||
description: fallback.summary,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ProductDetailPage({ params }: PageProps) {
|
||||
const { path } = await params;
|
||||
if (!path?.length || path.length > 2) notFound();
|
||||
|
||||
const [id, slug] = path;
|
||||
const [slotItems, apiProduct, similar, apiCategories] = await Promise.all([
|
||||
fetchPublicWebsiteSlots(),
|
||||
fetchPublicProduct(id),
|
||||
getStorefrontProducts(),
|
||||
fetchPublicCategories(),
|
||||
]);
|
||||
|
||||
const product = apiProduct
|
||||
? mapApiProductToDetail(apiProduct)
|
||||
: getProductDetail(id);
|
||||
if (!product) notFound();
|
||||
|
||||
const expectedSlug = productSlug({
|
||||
id: product.id,
|
||||
nameEn: product.nameEn,
|
||||
nameFa: product.nameFa,
|
||||
});
|
||||
const canonical = productPath({
|
||||
id: product.id,
|
||||
nameEn: product.nameEn,
|
||||
nameFa: product.nameFa,
|
||||
});
|
||||
|
||||
// Legacy `/products/:id` or wrong slug → canonical `/products/:id/:slug`
|
||||
if (path.length === 1 || slug !== expectedSlug) {
|
||||
redirect(canonical);
|
||||
}
|
||||
|
||||
const home = mapHomeSlots(slotItems);
|
||||
const megaTabs = mapCategoriesToMegaTabs(
|
||||
apiCategories,
|
||||
home.megaTabs.map((t) => ({ image: t.image })),
|
||||
);
|
||||
const similarCards = similar
|
||||
.filter((p) => p.id !== product.id)
|
||||
.slice(0, 8);
|
||||
|
||||
return (
|
||||
<main className="home-page min-h-dvh bg-white text-ink">
|
||||
<div className="relative">
|
||||
<SiteHeader megaTabs={megaTabs.length ? megaTabs : home.megaTabs} />
|
||||
<ProductDetailHero
|
||||
heroImage={home.heroSlides[0]?.image || undefined}
|
||||
breadcrumbs={product.breadcrumbs}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-[1200px] px-4 py-8 sm:px-6 sm:py-10 lg:px-8 lg:py-12">
|
||||
<div className="grid grid-cols-1 gap-10 lg:grid-cols-2 lg:gap-14 lg:items-start">
|
||||
<ProductGallery images={product.gallery} alt={product.nameFa} />
|
||||
<ProductBuyBox product={product} />
|
||||
</div>
|
||||
|
||||
<div className="mt-12 space-y-14 lg:mt-16 lg:space-y-16">
|
||||
<ProductTabs product={product} />
|
||||
<SimilarProducts
|
||||
products={
|
||||
similarCards.length
|
||||
? similarCards
|
||||
: similar.slice(0, 8)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SiteFooter paymentBadges={home.paymentBadges} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import SiteHeader from "@/components/home/SiteHeader";
|
||||
import SiteFooter from "@/components/home/SiteFooter";
|
||||
import LeatherBanner from "@/components/home/LeatherBanner";
|
||||
import PartnerCta from "@/components/home/PartnerCta";
|
||||
import ProductListHero from "@/components/products/ProductListHero";
|
||||
import ProductFilters from "@/components/products/ProductFilters";
|
||||
import ProductGrid from "@/components/products/ProductGrid";
|
||||
import ProductPagination from "@/components/products/ProductPagination";
|
||||
import { LIST_PAGE_META } from "@/data/products";
|
||||
import { mapHomeSlots } from "@/lib/map-home-slots";
|
||||
import { fetchPublicWebsiteSlots } from "@/lib/website-slots";
|
||||
import { getStorefrontProducts } from "@/lib/products-api";
|
||||
import {
|
||||
fetchPublicCategories,
|
||||
mapCategoriesToMegaTabs,
|
||||
} from "@/lib/categories-api";
|
||||
|
||||
type PageProps = {
|
||||
searchParams: Promise<{ q?: string; cat?: string }>;
|
||||
};
|
||||
|
||||
export default async function ProductsPage({ searchParams }: PageProps) {
|
||||
const params = await searchParams;
|
||||
const [slotItems, products, apiCategories] = await Promise.all([
|
||||
fetchPublicWebsiteSlots(),
|
||||
getStorefrontProducts({
|
||||
name: params.q,
|
||||
categoryId: params.cat,
|
||||
}),
|
||||
fetchPublicCategories(),
|
||||
]);
|
||||
const home = mapHomeSlots(slotItems);
|
||||
const megaTabs = mapCategoriesToMegaTabs(
|
||||
apiCategories,
|
||||
home.megaTabs.map((t) => ({ image: t.image })),
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="home-page min-h-dvh bg-white text-ink">
|
||||
<div className="relative">
|
||||
<SiteHeader megaTabs={megaTabs.length ? megaTabs : home.megaTabs} />
|
||||
<ProductListHero
|
||||
titleFa={params.q ? `جستجو: ${params.q}` : LIST_PAGE_META.titleFa}
|
||||
titleEn={params.q ? "Search results" : LIST_PAGE_META.titleEn}
|
||||
heroImage={home.heroSlides[0]?.image || LIST_PAGE_META.heroImage}
|
||||
breadcrumbs={
|
||||
params.q
|
||||
? [
|
||||
{ label: "خانه", href: "/" },
|
||||
{ label: "محصولات", href: "/products" },
|
||||
{ label: "جستجو" },
|
||||
]
|
||||
: LIST_PAGE_META.breadcrumbs
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-6 sm:mt-8 lg:mt-[60px]">
|
||||
<ProductFilters />
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
<ProductGrid products={products} />
|
||||
<ProductPagination />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LeatherBanner
|
||||
image={home.leather.image}
|
||||
nameFa={home.leather.nameFa}
|
||||
nameEn={home.leather.nameEn}
|
||||
linkText={home.leather.linkText}
|
||||
link={home.leather.link}
|
||||
/>
|
||||
<PartnerCta />
|
||||
<SiteFooter paymentBadges={home.paymentBadges} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user