Fix
This commit is contained in:
+63
-20
@@ -6,42 +6,85 @@ import TopSellingCarousel from "@/components/TopSellingCarousel";
|
|||||||
import FullBanner from "@/components/FullBanner";
|
import FullBanner from "@/components/FullBanner";
|
||||||
import AboutUs from "@/components/AboutUs";
|
import AboutUs from "@/components/AboutUs";
|
||||||
import BlogCarousel from "@/components/BlogCarousel";
|
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 { 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() {
|
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(getCategories(), []),
|
||||||
safe(getProducts({ tag: "special-discount", pageSize: 8 }), { items: [], total: 0, page: 1, pageSize: 8 }),
|
// Large enough to cover the current catalog in one call so
|
||||||
safe(getProducts({ tag: "top-selling", pageSize: 12 }), { items: [], total: 0, page: 1, pageSize: 12 }),
|
// 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(getBlogs({ pageSize: 4 }), { items: [], total: 0, page: 1, pageSize: 4 }),
|
||||||
safe(getStaticImageSlots("home"), []),
|
safe(getStaticImageSlots("home"), []),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const categoryColumns = getMegaMenuColumns(categoriesFlat);
|
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
|
const specialDiscount = specials.find((s) => s.key === "special-discount" && s.isActive);
|
||||||
// back to the latest published products so the carousels stay populated.
|
const topSelling = specials.find((s) => s.key === "top-selling" && s.isActive);
|
||||||
const [specialProducts, topSellingProducts] = await Promise.all([
|
|
||||||
special.items.length > 0
|
const specialItems = resolveSpecialItems(specialDiscount, productById);
|
||||||
? Promise.resolve(special.items)
|
const topSellingItems = resolveSpecialItems(topSelling, productById);
|
||||||
: 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
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main>
|
<main>
|
||||||
<HeroSlider slot={findSlot(slots, "slider")} />
|
<HeroSlider slot={findSlot(slots, "slider")} />
|
||||||
<SpecialSaleCarousel products={specialProducts} />
|
<SpecialSaleCarousel items={specialItems} />
|
||||||
<Categories categoryColumns={categoryColumns} />
|
<Categories categoryColumns={categoryColumns} />
|
||||||
<TwoBanners slot={findSlot(slots, "two-banner-row")} />
|
<TwoBanners slot={findSlot(slots, "two-banner-row")} />
|
||||||
<TopSellingCarousel products={topSellingProducts} />
|
<TopSellingCarousel items={topSellingItems} />
|
||||||
<FullBanner slot={findSlot(slots, "one-banner-row")} />
|
<FullBanner slot={findSlot(slots, "one-banner-row")} />
|
||||||
<AboutUs slot={findSlot(slots, "aboutus-row")} />
|
<AboutUs slot={findSlot(slots, "aboutus-row")} />
|
||||||
<BlogCarousel posts={blogs.items} />
|
<BlogCarousel posts={blogs.items} />
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ import Link from "next/link";
|
|||||||
import { Swiper, SwiperSlide } from "swiper/react";
|
import { Swiper, SwiperSlide } from "swiper/react";
|
||||||
import { Autoplay, Navigation } from "swiper/modules";
|
import { Autoplay, Navigation } from "swiper/modules";
|
||||||
import "swiper/css";
|
import "swiper/css";
|
||||||
import type { ApiProduct } from "@/lib/types";
|
import type { SpecialCarouselItem } from "@/lib/types";
|
||||||
import ProductCard from "./ui/ProductCard";
|
import ProductCard from "./ui/ProductCard";
|
||||||
import { ChevronLeftIcon, ChevronRightIcon } from "./icons";
|
import { ChevronLeftIcon, ChevronRightIcon } from "./icons";
|
||||||
|
|
||||||
export default function SpecialSaleCarousel({ products }: { products: ApiProduct[] }) {
|
export default function SpecialSaleCarousel({ items }: { items: SpecialCarouselItem[] }) {
|
||||||
if (products.length === 0) return null;
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="special-sale" className="boxed py-10 md:py-14">
|
<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",
|
prevEl: ".sale-prev",
|
||||||
nextEl: ".sale-next",
|
nextEl: ".sale-next",
|
||||||
}}
|
}}
|
||||||
loop={products.length > 5}
|
loop={items.length > 5}
|
||||||
spaceBetween={14}
|
spaceBetween={14}
|
||||||
slidesPerView={2.15}
|
slidesPerView={1.8}
|
||||||
breakpoints={{
|
breakpoints={{
|
||||||
480: { slidesPerView: 2.4 },
|
480: { slidesPerView: 2.2 },
|
||||||
640: { slidesPerView: 3.2 },
|
640: { slidesPerView: 3 },
|
||||||
1024: { slidesPerView: 5, spaceBetween: 20 },
|
1024: { slidesPerView: 4.5, spaceBetween: 20 },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SwiperSlide>
|
<SwiperSlide>
|
||||||
@@ -56,9 +56,9 @@ export default function SpecialSaleCarousel({ products }: { products: ApiProduct
|
|||||||
className="w-full h-full aspect-[4/5] object-cover rounded-lg"
|
className="w-full h-full aspect-[4/5] object-cover rounded-lg"
|
||||||
/>
|
/>
|
||||||
</SwiperSlide>
|
</SwiperSlide>
|
||||||
{products.map((product) => (
|
{items.map(({ product, discount }) => (
|
||||||
<SwiperSlide key={product.id}>
|
<SwiperSlide key={product.id}>
|
||||||
<ProductCard product={product} />
|
<ProductCard product={product} discount={discount} />
|
||||||
</SwiperSlide>
|
</SwiperSlide>
|
||||||
))}
|
))}
|
||||||
</Swiper>
|
</Swiper>
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import { Swiper, SwiperSlide } from "swiper/react";
|
|||||||
import { Navigation } from "swiper/modules";
|
import { Navigation } from "swiper/modules";
|
||||||
import "swiper/css";
|
import "swiper/css";
|
||||||
import "swiper/css/navigation";
|
import "swiper/css/navigation";
|
||||||
import type { ApiProduct } from "@/lib/types";
|
import type { SpecialCarouselItem } from "@/lib/types";
|
||||||
import ProductCard from "./ui/ProductCard";
|
import ProductCard from "./ui/ProductCard";
|
||||||
import SectionHeading from "./ui/SectionHeading";
|
import SectionHeading from "./ui/SectionHeading";
|
||||||
import { ChevronLeftIcon, ChevronRightIcon } from "./icons";
|
import { ChevronLeftIcon, ChevronRightIcon } from "./icons";
|
||||||
|
|
||||||
export default function TopSellingCarousel({ products }: { products: ApiProduct[] }) {
|
export default function TopSellingCarousel({ items }: { items: SpecialCarouselItem[] }) {
|
||||||
if (products.length === 0) return null;
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="boxed py-10 md:py-14">
|
<section className="boxed py-10 md:py-14">
|
||||||
@@ -42,16 +42,16 @@ export default function TopSellingCarousel({ products }: { products: ApiProduct[
|
|||||||
nextEl: ".top-selling-next",
|
nextEl: ".top-selling-next",
|
||||||
}}
|
}}
|
||||||
spaceBetween={16}
|
spaceBetween={16}
|
||||||
slidesPerView={2.2}
|
slidesPerView={1.8}
|
||||||
breakpoints={{
|
breakpoints={{
|
||||||
640: { slidesPerView: 3.2 },
|
640: { slidesPerView: 2.8 },
|
||||||
1024: { slidesPerView: 4 },
|
1024: { slidesPerView: 3.5 },
|
||||||
1280: { slidesPerView: 5 },
|
1280: { slidesPerView: 4.5 },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{products.map((product) => (
|
{items.map(({ product, discount }) => (
|
||||||
<SwiperSlide key={product.id}>
|
<SwiperSlide key={product.id}>
|
||||||
<ProductCard product={product} />
|
<ProductCard product={product} discount={discount} />
|
||||||
</SwiperSlide>
|
</SwiperSlide>
|
||||||
))}
|
))}
|
||||||
</Swiper>
|
</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) {
|
export function CommentIcon({ className }: IconProps) {
|
||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 24 24" className={className} {...base}>
|
<svg viewBox="0 0 24 24" className={className} {...base}>
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import Link from "next/link";
|
"use client";
|
||||||
import type { ApiProduct } from "@/lib/types";
|
|
||||||
import { formatToman } from "@/lib/format";
|
|
||||||
|
|
||||||
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 title = product.nameFa || product.title;
|
||||||
const image =
|
const image =
|
||||||
product.thumbnailUrl || product.image || product.thumbnail || "/images/products/product-1.svg";
|
product.thumbnailUrl || product.image || product.thumbnail || "/images/products/product-1.svg";
|
||||||
const store = product.store;
|
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 (
|
return (
|
||||||
<Link
|
<Link
|
||||||
@@ -26,15 +45,44 @@ export default function ProductCard({ product }: { product: ApiProduct }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col p-3.5">
|
<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>
|
{/* Fixed height regardless of 1 or 2 lines, so card heights stay
|
||||||
<div className="mt-2 flex items-center gap-2 flex-wrap">
|
consistent across a row of mixed-length titles. */}
|
||||||
<span className="font-bold text-sm md:text-[15px] text-brand">
|
<h3 className="h-10 md:h-11 leading-tight text-[13px] md:text-sm text-ink text-right line-clamp-2">
|
||||||
{store?.minPrice != null ? formatToman(store.minPrice) : "تماس بگیرید"}
|
{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>
|
||||||
{store?.maxPrice != null && store.maxPrice !== store.minPrice && (
|
<span className="shrink-0 rounded-md bg-gold/20 text-gold-dark text-xs font-bold px-1.5 py-0.5">
|
||||||
<span className="text-xs text-body/60">تا {formatToman(store.maxPrice)}</span>
|
%{discount.percent}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
ProductVariation,
|
ProductVariation,
|
||||||
StaticImageSlot,
|
StaticImageSlot,
|
||||||
StoreItem,
|
StoreItem,
|
||||||
|
StoreSpecial,
|
||||||
TechnicalInfoValue,
|
TechnicalInfoValue,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
@@ -107,6 +108,15 @@ export function getStaticImageSlots(pageKey?: string): Promise<StaticImageSlot[]
|
|||||||
}).then((r) => r.items);
|
}).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(
|
export function findSlot(
|
||||||
slots: StaticImageSlot[],
|
slots: StaticImageSlot[],
|
||||||
key: string
|
key: string
|
||||||
|
|||||||
@@ -53,6 +53,12 @@ export interface ProductStoreSummary {
|
|||||||
inStock: boolean;
|
inStock: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProductDiscount {
|
||||||
|
price: number;
|
||||||
|
oldPrice: number;
|
||||||
|
percent: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ApiProduct {
|
export interface ApiProduct {
|
||||||
id: string;
|
id: string;
|
||||||
businessId: string;
|
businessId: string;
|
||||||
@@ -79,6 +85,13 @@ export interface ApiProduct {
|
|||||||
store: ProductStoreSummary;
|
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> {
|
export interface PagedResult<T> {
|
||||||
items: T[];
|
items: T[];
|
||||||
total: number;
|
total: number;
|
||||||
@@ -149,6 +162,40 @@ export interface StoreItem {
|
|||||||
updatedAt: string;
|
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
|
// Technical-info and comment shapes aren't documented and this tenant has
|
||||||
// no data yet — every field is optional and consumers must read defensively.
|
// no data yet — every field is optional and consumers must read defensively.
|
||||||
export interface TechnicalInfoValue {
|
export interface TechnicalInfoValue {
|
||||||
|
|||||||
Reference in New Issue
Block a user