سبد با پیشنهاد
پیشنمایش تمامصفحهسبد با نوارِ تا ارسالِ رایگان، بستهبندیِ هدیه و «شاید اینها را هم بخواهی» با افزودنِ یککلیکی.
۱. نصب کامپوننتها
این بلاک از ۹ آیتم دیگ استفاده میکند: buttoncheckboxcontrol-sizeiconslabelnumber-fieldpersianprogresstoast
پکیجمنیجر پیشفرض Node.jsنصب سریع با لینک سخت و دیسک مشترکYarn نسخهٔ ۲ به بالا (Berry)رانتایم و پکیجمنیجر Bun
npx digdesign@latest add https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/checkbox.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/label.json https://docs.digdesign.ir/r/number-field.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/progress.json https://docs.digdesign.ir/r/toast.jsonpnpm dlx digdesign@latest add https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/checkbox.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/label.json https://docs.digdesign.ir/r/number-field.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/progress.json https://docs.digdesign.ir/r/toast.jsonyarn dlx digdesign@latest add https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/checkbox.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/label.json https://docs.digdesign.ir/r/number-field.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/progress.json https://docs.digdesign.ir/r/toast.jsonbunx --bun digdesign@latest add https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/checkbox.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/label.json https://docs.digdesign.ir/r/number-field.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/progress.json https://docs.digdesign.ir/r/toast.json۲. کد صفحه
کد را در app/<route>/page.tsx بگذارید و متنها، داده و هندلرها را با محصول خودتان عوض کنید.
cart-upsell.tsx
"use client"
import * as React from "react"
import { PlusIcon, TrashIcon, TruckIcon } from "@/components/icons"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { ControlSizeProvider } from "@/components/ui/control-size"
import { Label } from "@/components/ui/label"
import { NumberField } from "@/components/ui/number-field"
import { Progress } from "@/components/ui/progress"
import { toast } from "@/components/ui/toast"
import { formatCurrency } from "@/lib/persian"
import { stockImages, type StockImageName } from "@/lib/stock-images"
type Item = { id: string; title: string; image: StockImageName; price: number; quantity: number }
const INITIAL: Item[] = [
{ id: "1", title: "گلدانِ سرامیکی دوقلو", image: "productVase", price: 1_290_000, quantity: 1 },
{ id: "2", title: "دفترِ یادداشتِ جلدسخت", image: "coverNotebook", price: 180_000, quantity: 2 },
]
const SUGGESTED: Item[] = [
{ id: "s1", title: "ست لوازمالتحریرِ رومیزی", image: "coverOfficeSupplies", price: 640_000, quantity: 1 },
{ id: "s2", title: "ماشینحسابِ رومیزی", image: "coverCalculator", price: 420_000, quantity: 1 },
]
const FREE_SHIPPING = 3_000_000
const GIFT = 60_000
/**
* سبد با پیشنهادِ تکمیلی: نوارِ «تا ارسالِ رایگان» بالای سبد، اقلام با عکس و تعداد، بستهبندی
* هدیه با چکباکس، و «شاید اینها را هم بخواهی» با افزودنِ یککلیکی که نوار را جلو میبرد.
*/
export default function CartUpsellBlock() {
const [items, setItems] = React.useState(INITIAL)
const [gift, setGift] = React.useState(false)
const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0)
const total = subtotal + (gift ? GIFT : 0)
const remaining = Math.max(0, FREE_SHIPPING - subtotal)
const suggestions = SUGGESTED.filter((item) => !items.some((entry) => entry.id === item.id))
return (
<ControlSizeProvider size="xl" asChild>
<main className="mx-auto grid w-full max-w-5xl grid-cols-1 items-start gap-8 p-4 sm:p-6 lg:grid-cols-[minmax(0,1fr)_20rem]">
<div className="flex flex-col gap-8">
<header className="flex flex-col gap-4">
<h1 className="text-2xl font-bold">سبد خرید</h1>
<div className="flex flex-col gap-2 rounded-xl bg-subtle p-4">
<span className="flex items-center gap-2 text-sm">
<TruckIcon aria-hidden className="size-4 text-brand-secondary" />
{remaining > 0 ? <>فقط {formatCurrency(remaining)} تا ارسالِ رایگان</> : "ارسالِ این سفارش رایگان است"}
</span>
<Progress value={Math.min(100, (subtotal / FREE_SHIPPING) * 100)} aria-label="پیشرفت تا ارسالِ رایگان" />
</div>
</header>
<ul className="flex flex-col divide-y divide-border">
{items.map((item) => (
<li key={item.id} className="flex gap-4 py-5 first:pt-0">
{/* eslint-disable-next-line @next/next/no-img-element -- عکس از کتابخانهٔ تصویرِ دیگ */}
<img src={stockImages[item.image].src} alt="" className="size-20 shrink-0 rounded-lg object-cover" />
<div className="flex flex-1 flex-col gap-3">
<div className="flex items-start justify-between gap-2">
<span className="font-medium">{item.title}</span>
<Button variant="ghost" size="icon" aria-label={`حذفِ ${item.title}`} onClick={(() => {
const index = items.findIndex((row) => row.id === item.id)
setItems((current) => current.filter((row) => row.id !== item.id))
toast(`«${item.title}» از سبد حذف شد.`, { action: { label: "برگردان", onClick: () => setItems((current) => (current.some((row) => row.id === item.id) ? current : [...current.slice(0, index), item, ...current.slice(index)])) } })
})}>
<TrashIcon />
</Button>
</div>
<div className="flex items-center justify-between gap-2">
<NumberField aria-label={`تعداد ${item.title}`} value={item.quantity} onValueChange={(quantity) => setItems((list) => list.map((entry) => (entry.id === item.id ? { ...entry, quantity } : entry)))} min={1} max={10} className="w-32" />
<span className="font-bold tabular-nums">{formatCurrency(item.price * item.quantity)}</span>
</div>
</div>
</li>
))}
</ul>
{suggestions.length > 0 && (
<section aria-labelledby="cu-suggest" className="flex flex-col gap-4 border-t border-border pt-8">
<h2 id="cu-suggest" className="font-bold">
شاید اینها را هم بخواهی
</h2>
<ul className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{suggestions.map((item) => (
<li key={item.id} className="flex items-center gap-3 rounded-xl border border-border p-3">
{/* eslint-disable-next-line @next/next/no-img-element -- عکس از کتابخانهٔ تصویرِ دیگ */}
<img src={stockImages[item.image].src} alt="" className="size-14 rounded-lg object-cover" />
<span className="flex flex-1 flex-col">
<span className="text-sm font-medium">{item.title}</span>
<span className="text-sm text-muted-foreground tabular-nums">{formatCurrency(item.price)}</span>
</span>
<Button
variant="outline"
size="icon"
aria-label={`افزودنِ ${item.title}`}
onClick={() => {
setItems((list) => [...list, item])
toast.success(`«${item.title}» اضافه شد.`)
}}
>
<PlusIcon />
</Button>
</li>
))}
</ul>
</section>
)}
</div>
<aside className="flex flex-col gap-5 rounded-xl bg-subtle p-6 lg:sticky lg:top-6">
<div className="flex items-center gap-3">
<Checkbox id="cu-gift" checked={gift} onCheckedChange={(checked) => setGift(checked === true)} />
<Label htmlFor="cu-gift" className="flex-1 font-normal">
بستهبندی هدیه ({formatCurrency(GIFT)})
</Label>
</div>
<div className="flex items-baseline justify-between gap-2 border-t border-border pt-5">
<span className="text-muted-foreground">جمع</span>
<span className="text-xl font-black tabular-nums">{formatCurrency(total)}</span>
</div>
<Button fullWidth disabled={items.length === 0} className="mt-3">
ادامهٔ خرید
</Button>
</aside>
</main>
</ControlSizeProvider>
)
}