This commit is contained in:
amirhosein.ashourloo
2026-08-20 11:17:04 +03:30
parent 82e73c47cf
commit 08a729b3fc
7 changed files with 206 additions and 50 deletions
+63 -20
View File
@@ -6,42 +6,85 @@ import TopSellingCarousel from "@/components/TopSellingCarousel";
import FullBanner from "@/components/FullBanner";
import AboutUs from "@/components/AboutUs";
import BlogCarousel from "@/components/BlogCarousel";
import { getBlogs, getCategories, getProducts, getStaticImageSlots, findSlot, safe } from "@/lib/api";
import {
getBlogs,
getCategories,
getProducts,
getStaticImageSlots,
getStoreSpecials,
findSlot,
safe,
} from "@/lib/api";
import { getMegaMenuColumns } from "@/lib/categoryTree";
import type {
ApiProduct,
ProductDiscount,
SpecialCarouselItem,
StoreSpecial,
StoreSpecialItem,
} from "@/lib/types";
// The best (highest %) discount across an item's variants, if any.
function bestDiscount(item: StoreSpecialItem): ProductDiscount | undefined {
let best: ProductDiscount | undefined;
for (const v of item.variants) {
if (v.price != null && v.discountedPrice != null && v.discountedPrice < v.price) {
const percent = Math.round(((v.price - v.discountedPrice) / v.price) * 100);
if (!best || percent > best.percent) {
best = { price: v.discountedPrice, oldPrice: v.price, percent };
}
}
}
return best;
}
// Resolves a curated store-special group's items to full product records
// (needed for slug/price/stock) via an id lookup, preserving the
// dashboard-defined sort order, plus any real discount found on the item.
function resolveSpecialItems(
special: StoreSpecial | undefined,
productById: Map<string, ApiProduct>
): SpecialCarouselItem[] {
if (!special) return [];
return special.items
.filter((item) => item.isActive)
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((item): SpecialCarouselItem | null => {
const product = productById.get(item.productId);
if (!product) return null;
return { product, discount: bestDiscount(item) };
})
.filter((entry): entry is SpecialCarouselItem => entry !== null);
}
export default async function Home() {
const [categoriesFlat, special, topSelling, blogs, slots] = await Promise.all([
const [categoriesFlat, allProducts, specials, blogs, slots] = await Promise.all([
safe(getCategories(), []),
safe(getProducts({ tag: "special-discount", pageSize: 8 }), { items: [], total: 0, page: 1, pageSize: 8 }),
safe(getProducts({ tag: "top-selling", pageSize: 12 }), { items: [], total: 0, page: 1, pageSize: 12 }),
// Large enough to cover the current catalog in one call so
// store-special items (which only carry a productId) can be resolved
// to a full product record (slug, price, stock) without N lookups.
safe(getProducts({ pageSize: 1000 }), { items: [], total: 0, page: 1, pageSize: 1000 }),
safe(getStoreSpecials(), []),
safe(getBlogs({ pageSize: 4 }), { items: [], total: 0, page: 1, pageSize: 4 }),
safe(getStaticImageSlots("home"), []),
]);
const categoryColumns = getMegaMenuColumns(categoriesFlat);
const productById = new Map(allProducts.items.map((p) => [p.id, p]));
// The merchandising tags may not be assigned to any product yet — fall
// back to the latest published products so the carousels stay populated.
const [specialProducts, topSellingProducts] = await Promise.all([
special.items.length > 0
? Promise.resolve(special.items)
: safe(getProducts({ pageSize: 8 }), { items: [], total: 0, page: 1, pageSize: 8 }).then(
(r) => r.items
),
topSelling.items.length > 0
? Promise.resolve(topSelling.items)
: safe(getProducts({ pageSize: 12 }), { items: [], total: 0, page: 1, pageSize: 12 }).then(
(r) => r.items
),
]);
const specialDiscount = specials.find((s) => s.key === "special-discount" && s.isActive);
const topSelling = specials.find((s) => s.key === "top-selling" && s.isActive);
const specialItems = resolveSpecialItems(specialDiscount, productById);
const topSellingItems = resolveSpecialItems(topSelling, productById);
return (
<main>
<HeroSlider slot={findSlot(slots, "slider")} />
<SpecialSaleCarousel products={specialProducts} />
<SpecialSaleCarousel items={specialItems} />
<Categories categoryColumns={categoryColumns} />
<TwoBanners slot={findSlot(slots, "two-banner-row")} />
<TopSellingCarousel products={topSellingProducts} />
<TopSellingCarousel items={topSellingItems} />
<FullBanner slot={findSlot(slots, "one-banner-row")} />
<AboutUs slot={findSlot(slots, "aboutus-row")} />
<BlogCarousel posts={blogs.items} />
+10 -10
View File
@@ -4,12 +4,12 @@ import Link from "next/link";
import { Swiper, SwiperSlide } from "swiper/react";
import { Autoplay, Navigation } from "swiper/modules";
import "swiper/css";
import type { ApiProduct } from "@/lib/types";
import type { SpecialCarouselItem } from "@/lib/types";
import ProductCard from "./ui/ProductCard";
import { ChevronLeftIcon, ChevronRightIcon } from "./icons";
export default function SpecialSaleCarousel({ products }: { products: ApiProduct[] }) {
if (products.length === 0) return null;
export default function SpecialSaleCarousel({ items }: { items: SpecialCarouselItem[] }) {
if (items.length === 0) return null;
return (
<section id="special-sale" className="boxed py-10 md:py-14">
@@ -40,13 +40,13 @@ export default function SpecialSaleCarousel({ products }: { products: ApiProduct
prevEl: ".sale-prev",
nextEl: ".sale-next",
}}
loop={products.length > 5}
loop={items.length > 5}
spaceBetween={14}
slidesPerView={2.15}
slidesPerView={1.8}
breakpoints={{
480: { slidesPerView: 2.4 },
640: { slidesPerView: 3.2 },
1024: { slidesPerView: 5, spaceBetween: 20 },
480: { slidesPerView: 2.2 },
640: { slidesPerView: 3 },
1024: { slidesPerView: 4.5, spaceBetween: 20 },
}}
>
<SwiperSlide>
@@ -56,9 +56,9 @@ export default function SpecialSaleCarousel({ products }: { products: ApiProduct
className="w-full h-full aspect-[4/5] object-cover rounded-lg"
/>
</SwiperSlide>
{products.map((product) => (
{items.map(({ product, discount }) => (
<SwiperSlide key={product.id}>
<ProductCard product={product} />
<ProductCard product={product} discount={discount} />
</SwiperSlide>
))}
</Swiper>
+9 -9
View File
@@ -4,13 +4,13 @@ import { Swiper, SwiperSlide } from "swiper/react";
import { Navigation } from "swiper/modules";
import "swiper/css";
import "swiper/css/navigation";
import type { ApiProduct } from "@/lib/types";
import type { SpecialCarouselItem } from "@/lib/types";
import ProductCard from "./ui/ProductCard";
import SectionHeading from "./ui/SectionHeading";
import { ChevronLeftIcon, ChevronRightIcon } from "./icons";
export default function TopSellingCarousel({ products }: { products: ApiProduct[] }) {
if (products.length === 0) return null;
export default function TopSellingCarousel({ items }: { items: SpecialCarouselItem[] }) {
if (items.length === 0) return null;
return (
<section className="boxed py-10 md:py-14">
@@ -42,16 +42,16 @@ export default function TopSellingCarousel({ products }: { products: ApiProduct[
nextEl: ".top-selling-next",
}}
spaceBetween={16}
slidesPerView={2.2}
slidesPerView={1.8}
breakpoints={{
640: { slidesPerView: 3.2 },
1024: { slidesPerView: 4 },
1280: { slidesPerView: 5 },
640: { slidesPerView: 2.8 },
1024: { slidesPerView: 3.5 },
1280: { slidesPerView: 4.5 },
}}
>
{products.map((product) => (
{items.map(({ product, discount }) => (
<SwiperSlide key={product.id}>
<ProductCard product={product} />
<ProductCard product={product} discount={discount} />
</SwiperSlide>
))}
</Swiper>
+8
View File
@@ -155,6 +155,14 @@ export function CheckIcon({ className }: IconProps) {
);
}
export function PlusIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} {...base}>
<path d="M12 5v14M5 12h14" />
</svg>
);
}
export function CommentIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} {...base}>
+59 -11
View File
@@ -1,12 +1,31 @@
import Link from "next/link";
import type { ApiProduct } from "@/lib/types";
import { formatToman } from "@/lib/format";
"use client";
export default function ProductCard({ product }: { product: ApiProduct }) {
import { useState } from "react";
import Link from "next/link";
import type { ApiProduct, ProductDiscount } from "@/lib/types";
import { formatToman } from "@/lib/format";
import { CheckIcon, PlusIcon } from "../icons";
export default function ProductCard({
product,
discount,
}: {
product: ApiProduct;
discount?: ProductDiscount;
}) {
const [added, setAdded] = useState(false);
const title = product.nameFa || product.title;
const image =
product.thumbnailUrl || product.image || product.thumbnail || "/images/products/product-1.svg";
const store = product.store;
const price = discount ? discount.price : store?.minPrice;
function handleQuickAdd(e: React.MouseEvent) {
e.preventDefault();
e.stopPropagation();
setAdded(true);
setTimeout(() => setAdded(false), 1500);
}
return (
<Link
@@ -26,15 +45,44 @@ export default function ProductCard({ product }: { product: ApiProduct }) {
</div>
<div className="flex flex-1 flex-col p-3.5">
<h3 className="text-[13px] md:text-sm text-ink line-clamp-2 min-h-[2.5em]">{title}</h3>
<div className="mt-2 flex items-center gap-2 flex-wrap">
<span className="font-bold text-sm md:text-[15px] text-brand">
{store?.minPrice != null ? formatToman(store.minPrice) : "تماس بگیرید"}
</span>
{store?.maxPrice != null && store.maxPrice !== store.minPrice && (
<span className="text-xs text-body/60">تا {formatToman(store.maxPrice)}</span>
{/* Fixed height regardless of 1 or 2 lines, so card heights stay
consistent across a row of mixed-length titles. */}
<h3 className="h-10 md:h-11 leading-tight text-[13px] md:text-sm text-ink text-right line-clamp-2">
{title}
</h3>
{/* Always reserves its height, even with no discount, so a
discounted card and a plain card end up the same height. Right
aligned to line up with the price row below; discount badge
sits to the left of the old price. */}
<div className="mt-2 h-6 flex items-center justify-start gap-2">
{discount && (
<>
<span className="text-xs text-body/60 line-through truncate">
{discount.oldPrice.toLocaleString("en-US")}
</span>
<span className="shrink-0 rounded-md bg-gold/20 text-gold-dark text-xs font-bold px-1.5 py-0.5">
%{discount.percent}
</span>
</>
)}
</div>
<div className="mt-2 flex items-center justify-between gap-2">
<span className="font-bold text-sm md:text-[15px] text-brand truncate">
{price != null ? formatToman(price) : "تماس بگیرید"}
</span>
<button
type="button"
onClick={handleQuickAdd}
aria-label="افزودن سریع به سبد خرید"
className={`shrink-0 flex items-center justify-center size-8 rounded-full transition-colors ${
added ? "bg-brand-dark text-white" : "bg-brand text-white hover:bg-brand-dark"
}`}
>
{added ? <CheckIcon className="size-4" /> : <PlusIcon className="size-4" />}
</button>
</div>
</div>
</Link>
);
+10
View File
@@ -8,6 +8,7 @@ import type {
ProductVariation,
StaticImageSlot,
StoreItem,
StoreSpecial,
TechnicalInfoValue,
} from "./types";
@@ -107,6 +108,15 @@ export function getStaticImageSlots(pageKey?: string): Promise<StaticImageSlot[]
}).then((r) => r.items);
}
// Curated merchandising groups (e.g. "special-discount", "top-selling")
// managed from the business dashboard — the actual source for the
// homepage carousels, not a generic product tag filter.
export function getStoreSpecials(): Promise<StoreSpecial[]> {
return apiGet<{ items: StoreSpecial[] }>(`/tenants/${SITE_DOMAIN}/store-specials`).then(
(r) => r.items
);
}
export function findSlot(
slots: StaticImageSlot[],
key: string
+47
View File
@@ -53,6 +53,12 @@ export interface ProductStoreSummary {
inStock: boolean;
}
export interface ProductDiscount {
price: number;
oldPrice: number;
percent: number;
}
export interface ApiProduct {
id: string;
businessId: string;
@@ -79,6 +85,13 @@ export interface ApiProduct {
store: ProductStoreSummary;
}
// A product resolved from a curated store-special group, with an optional
// real discount computed from that group's variant pricing.
export interface SpecialCarouselItem {
product: ApiProduct;
discount?: ProductDiscount;
}
export interface PagedResult<T> {
items: T[];
total: number;
@@ -149,6 +162,40 @@ export interface StoreItem {
updatedAt: string;
}
export interface StoreSpecialVariant {
id: string;
label: string;
selections: StoreItemSelection[];
price: number | null;
discountedPrice: number | null;
stockQuantity: number;
rewardPoints: number | null;
isFestival: boolean;
sortOrder: number;
}
export interface StoreSpecialItem {
id: string;
productId: string;
productTitle: string;
productNameFa: string;
productImage: string | null;
isActive: boolean;
sortOrder: number;
variants: StoreSpecialVariant[];
}
export interface StoreSpecial {
id: string;
key: string;
title: string;
sortOrder: number;
isActive: boolean;
createdAt: string;
updatedAt: string;
items: StoreSpecialItem[];
}
// Technical-info and comment shapes aren't documented and this tenant has
// no data yet — every field is optional and consumers must read defensively.
export interface TechnicalInfoValue {