diff --git a/src/app/page.tsx b/src/app/page.tsx index 478ddf5..cdf37e6 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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 +): 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 (
- + - + diff --git a/src/components/SpecialSaleCarousel.tsx b/src/components/SpecialSaleCarousel.tsx index 8a64efb..98d13d3 100644 --- a/src/components/SpecialSaleCarousel.tsx +++ b/src/components/SpecialSaleCarousel.tsx @@ -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 (
@@ -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 }, }} > @@ -56,9 +56,9 @@ export default function SpecialSaleCarousel({ products }: { products: ApiProduct className="w-full h-full aspect-[4/5] object-cover rounded-lg" /> - {products.map((product) => ( + {items.map(({ product, discount }) => ( - + ))} diff --git a/src/components/TopSellingCarousel.tsx b/src/components/TopSellingCarousel.tsx index 8bc3554..70fe594 100644 --- a/src/components/TopSellingCarousel.tsx +++ b/src/components/TopSellingCarousel.tsx @@ -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 (
@@ -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 }) => ( - + ))} diff --git a/src/components/icons.tsx b/src/components/icons.tsx index 0e035b8..7f6a7b7 100644 --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -155,6 +155,14 @@ export function CheckIcon({ className }: IconProps) { ); } +export function PlusIcon({ className }: IconProps) { + return ( + + + + ); +} + export function CommentIcon({ className }: IconProps) { return ( diff --git a/src/components/ui/ProductCard.tsx b/src/components/ui/ProductCard.tsx index fbfad15..c462d14 100644 --- a/src/components/ui/ProductCard.tsx +++ b/src/components/ui/ProductCard.tsx @@ -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 (
-

{title}

-
- - {store?.minPrice != null ? formatToman(store.minPrice) : "تماس بگیرید"} - - {store?.maxPrice != null && store.maxPrice !== store.minPrice && ( - تا {formatToman(store.maxPrice)} + {/* Fixed height regardless of 1 or 2 lines, so card heights stay + consistent across a row of mixed-length titles. */} +

+ {title} +

+ + {/* 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. */} +
+ {discount && ( + <> + + {discount.oldPrice.toLocaleString("en-US")} + + + %{discount.percent} + + )}
+ +
+ + {price != null ? formatToman(price) : "تماس بگیرید"} + + +
); diff --git a/src/lib/api.ts b/src/lib/api.ts index 4de8c1d..1209b39 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -8,6 +8,7 @@ import type { ProductVariation, StaticImageSlot, StoreItem, + StoreSpecial, TechnicalInfoValue, } from "./types"; @@ -107,6 +108,15 @@ export function getStaticImageSlots(pageKey?: string): Promise 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 { + return apiGet<{ items: StoreSpecial[] }>(`/tenants/${SITE_DOMAIN}/store-specials`).then( + (r) => r.items + ); +} + export function findSlot( slots: StaticImageSlot[], key: string diff --git a/src/lib/types.ts b/src/lib/types.ts index 351a669..2dd367d 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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 { 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 {