Fix
This commit is contained in:
+63
-20
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user