Add shipping cost management and checkout shipping fees.
Business store shipping groups/static rates UI, and customer checkout that quotes and applies shipping on delivery and payment. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
95390933a7
commit
c59ca4444f
@@ -140,6 +140,113 @@
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.shippingBlock {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(148, 163, 184, 0.05);
|
||||
}
|
||||
|
||||
.shippingSectionTitle {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.addressText {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.shippingGroupList {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.shippingGroupCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 10px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.22);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.shippingGroupHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.shippingGroupTitle {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.shippingGroupAmount {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.shippingMethod {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.shippingItemNames {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.totalsBlock {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.25);
|
||||
}
|
||||
|
||||
.totalsRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.totalsValue {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.totalsRowGrand {
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.18);
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.empty {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
|
||||
@@ -31,6 +31,54 @@ function totalQuantity(order: Order) {
|
||||
return order.items.reduce((sum, item) => sum + item.quantity, 0)
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null
|
||||
}
|
||||
|
||||
function parseOrderShipping(shippingAddress: Record<string, unknown>) {
|
||||
const root = asRecord(shippingAddress) ?? {}
|
||||
const deliveryMode =
|
||||
root.deliveryMode === 'pickup' || root.deliveryMode === 'delivery'
|
||||
? root.deliveryMode
|
||||
: root.province === 'Pickup'
|
||||
? 'pickup'
|
||||
: 'delivery'
|
||||
const shipping = asRecord(root.shipping)
|
||||
const groupsRaw = Array.isArray(shipping?.groups) ? shipping.groups : []
|
||||
const groups = groupsRaw
|
||||
.map((entry) => {
|
||||
const row = asRecord(entry)
|
||||
if (!row) return null
|
||||
const groupId = typeof row.groupId === 'string' ? row.groupId : ''
|
||||
if (!groupId) return null
|
||||
return {
|
||||
groupId,
|
||||
title: typeof row.title === 'string' ? row.title : '',
|
||||
methodLabel: typeof row.methodLabel === 'string' ? row.methodLabel : null,
|
||||
amount: typeof row.amount === 'number' && Number.isFinite(row.amount) ? row.amount : 0,
|
||||
itemNames: Array.isArray(row.itemNames)
|
||||
? row.itemNames.filter((name): name is string => typeof name === 'string')
|
||||
: [],
|
||||
}
|
||||
})
|
||||
.filter((group): group is NonNullable<typeof group> => Boolean(group))
|
||||
|
||||
const addressParts = [
|
||||
typeof root.province === 'string' ? root.province : '',
|
||||
typeof root.city === 'string' ? root.city : '',
|
||||
typeof root.address === 'string' ? root.address : '',
|
||||
typeof root.postalCode === 'string' ? root.postalCode : '',
|
||||
].filter((part) => part && part !== 'Pickup' && part !== 'Store' && part !== 'In-store pickup')
|
||||
|
||||
return {
|
||||
deliveryMode,
|
||||
addressLine: addressParts.join(' · '),
|
||||
groups,
|
||||
}
|
||||
}
|
||||
|
||||
export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps) {
|
||||
const t = useT()
|
||||
const [mounted, setMounted] = useState(open)
|
||||
@@ -106,6 +154,47 @@ export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
const shippingInfo = parseOrderShipping(order.shippingAddress)
|
||||
return (
|
||||
<div className={styles.shippingBlock}>
|
||||
<div className={styles.shippingSectionTitle}>{t('orderItems.delivery')}</div>
|
||||
{shippingInfo.deliveryMode === 'pickup' ? (
|
||||
<p className={styles.addressText}>{t('orderItems.pickup')}</p>
|
||||
) : shippingInfo.addressLine ? (
|
||||
<p className={styles.addressText}>{shippingInfo.addressLine}</p>
|
||||
) : null}
|
||||
|
||||
{shippingInfo.groups.length > 0 ? (
|
||||
<ul className={styles.shippingGroupList}>
|
||||
{shippingInfo.groups.map((group) => (
|
||||
<li key={group.groupId} className={styles.shippingGroupCard}>
|
||||
<div className={styles.shippingGroupHeader}>
|
||||
<span className={styles.shippingGroupTitle}>{group.title}</span>
|
||||
<span className={styles.shippingGroupAmount}>
|
||||
{group.amount > 0
|
||||
? formatIrtPrice(group.amount)
|
||||
: t('orderItems.freeShipping')}
|
||||
</span>
|
||||
</div>
|
||||
{group.methodLabel ? (
|
||||
<div className={styles.shippingMethod}>{group.methodLabel}</div>
|
||||
) : null}
|
||||
{group.itemNames.length > 0 ? (
|
||||
<ul className={styles.shippingItemNames}>
|
||||
{group.itemNames.map((name) => (
|
||||
<li key={`${group.groupId}-${name}`}>{name}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{order.items.length === 0 ? (
|
||||
<EmptyState layout="inline" message={t('orderItems.empty')} />
|
||||
) : (
|
||||
@@ -145,15 +234,35 @@ export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps)
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.summary}>
|
||||
<span className={styles.summaryLabel}>
|
||||
{t('orderItems.summary', {
|
||||
count: itemCount,
|
||||
itemsLabel:
|
||||
itemCount === 1 ? t('orderItems.item') : t('orderItems.itemsPlural'),
|
||||
})}
|
||||
</span>
|
||||
<span className={styles.summaryValue}>{formatIrtPrice(order.total)}</span>
|
||||
<div className={styles.totalsBlock}>
|
||||
<div className={styles.totalsRow}>
|
||||
<span>{t('orderItems.subtotal')}</span>
|
||||
<span className={styles.totalsValue}>{formatIrtPrice(order.subtotal)}</span>
|
||||
</div>
|
||||
<div className={styles.totalsRow}>
|
||||
<span>{t('orderItems.shipping')}</span>
|
||||
<span className={styles.totalsValue}>
|
||||
{order.shippingTotal > 0
|
||||
? formatIrtPrice(order.shippingTotal)
|
||||
: t('orderItems.freeShipping')}
|
||||
</span>
|
||||
</div>
|
||||
{order.discountTotal > 0 ? (
|
||||
<div className={styles.totalsRow}>
|
||||
<span>{t('orderItems.discount')}</span>
|
||||
<span className={styles.totalsValue}>{formatIrtPrice(order.discountTotal)}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={`${styles.totalsRow} ${styles.totalsRowGrand}`}>
|
||||
<span>
|
||||
{t('orderItems.summary', {
|
||||
count: itemCount,
|
||||
itemsLabel:
|
||||
itemCount === 1 ? t('orderItems.item') : t('orderItems.itemsPlural'),
|
||||
})}
|
||||
</span>
|
||||
<span className={styles.summaryValue}>{formatIrtPrice(order.total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={modalStyles.actions}>
|
||||
|
||||
@@ -8,7 +8,7 @@ import styles from './CheckoutAddAddressModal.module.css'
|
||||
interface CheckoutAddAddressModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
onSaved: (address?: { id: string }) => void
|
||||
}
|
||||
|
||||
const ANIMATION_MS = 220
|
||||
@@ -52,8 +52,8 @@ export function CheckoutAddAddressModal({ open, onClose, onSaved }: CheckoutAddA
|
||||
|
||||
if (!mounted) return null
|
||||
|
||||
function handleSaved() {
|
||||
onSaved()
|
||||
function handleSaved(address?: { id: string }) {
|
||||
onSaved(address)
|
||||
onClose()
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ function FieldLabel({
|
||||
}
|
||||
|
||||
interface CheckoutAddAddressPanelProps {
|
||||
onSaved: () => void
|
||||
onSaved: (address?: { id: string }) => void
|
||||
onCancel: () => void
|
||||
inModal?: boolean
|
||||
}
|
||||
@@ -97,7 +97,7 @@ export function CheckoutAddAddressPanel({
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
await createAddress({
|
||||
const result = await createAddress({
|
||||
label: label.trim(),
|
||||
province: province.nameFa || province.nameEn,
|
||||
city: city.trim(),
|
||||
@@ -106,7 +106,7 @@ export function CheckoutAddAddressPanel({
|
||||
landline: landline.trim() || undefined,
|
||||
})
|
||||
showToast('آدرس ذخیره شد.', 'success')
|
||||
onSaved()
|
||||
onSaved(result.address)
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'ذخیره آدرس ممکن نشد.')
|
||||
} finally {
|
||||
|
||||
@@ -102,18 +102,20 @@
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 60%;
|
||||
max-width: none;
|
||||
width: min(1040px, 96%);
|
||||
min-width: 320px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.container {
|
||||
width: 90%;
|
||||
}
|
||||
.shell {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
@@ -123,6 +125,18 @@
|
||||
padding: 28px 28px 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.container {
|
||||
width: 94%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.shell {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.header {
|
||||
padding: 12px 16px;
|
||||
|
||||
@@ -54,9 +54,7 @@ export function CheckoutLayout() {
|
||||
|
||||
<main className={styles.main}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.card}>
|
||||
<Outlet />
|
||||
</div>
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* Horizontal top stepper */
|
||||
.stepper {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
@@ -94,3 +95,91 @@
|
||||
margin: 0 4px 13px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tall side panel — current step only */
|
||||
.rail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
width: 112px;
|
||||
flex-shrink: 0;
|
||||
min-height: 320px;
|
||||
align-self: stretch;
|
||||
padding: 18px 12px;
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--glass-shadow);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.railCurrent {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
text-align: center;
|
||||
min-height: 160px;
|
||||
}
|
||||
|
||||
.railIconWrap {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 14px;
|
||||
color: var(--primary);
|
||||
background: rgba(var(--primary-rgb) / 0.14);
|
||||
border: 1px solid rgba(var(--primary-rgb) / 0.28);
|
||||
}
|
||||
|
||||
.railTitle {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.railIndex {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.rail {
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.railCurrent {
|
||||
flex: 0 0 auto;
|
||||
min-height: 0;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.railIconWrap {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.railTitle {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.railIndex {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { Check } from 'lucide-react'
|
||||
import { CreditCard, LogIn, ShoppingCart, Truck, type LucideIcon } from 'lucide-react'
|
||||
import styles from './CheckoutStepper.module.css'
|
||||
|
||||
export type CheckoutStepId = 'cart' | 'login' | 'delivery' | 'payment'
|
||||
|
||||
const STEPS: { id: CheckoutStepId; label: string }[] = [
|
||||
{ id: 'cart', label: 'سبد خرید' },
|
||||
{ id: 'login', label: 'ورود' },
|
||||
{ id: 'delivery', label: 'ارسال' },
|
||||
{ id: 'payment', label: 'پرداخت' },
|
||||
const STEPS: { id: CheckoutStepId; label: string; Icon: LucideIcon }[] = [
|
||||
{ id: 'cart', label: 'سبد خرید', Icon: ShoppingCart },
|
||||
{ id: 'login', label: 'ورود', Icon: LogIn },
|
||||
{ id: 'delivery', label: 'ارسال', Icon: Truck },
|
||||
{ id: 'payment', label: 'پرداخت', Icon: CreditCard },
|
||||
]
|
||||
|
||||
interface CheckoutStepperProps {
|
||||
@@ -42,7 +42,7 @@ export function CheckoutStepper({ current, isAuthenticated }: CheckoutStepperPro
|
||||
>
|
||||
<span className={styles.stepLabel}>{step.label}</span>
|
||||
<span className={styles.stepDot} aria-hidden>
|
||||
{isDone ? <Check size={14} /> : index + 1}
|
||||
{isDone ? '✓' : index + 1}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -60,3 +60,35 @@ export function CheckoutStepper({ current, isAuthenticated }: CheckoutStepperPro
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
interface CheckoutStepSidePanelProps {
|
||||
current: CheckoutStepId
|
||||
isAuthenticated: boolean
|
||||
}
|
||||
|
||||
export function CheckoutStepSidePanel({
|
||||
current,
|
||||
isAuthenticated,
|
||||
}: CheckoutStepSidePanelProps) {
|
||||
const visibleSteps = isAuthenticated
|
||||
? STEPS.filter((step) => step.id !== 'login')
|
||||
: STEPS
|
||||
|
||||
const currentStep = visibleSteps.find((step) => step.id === current) ?? visibleSteps[0]
|
||||
const currentIdx = visibleSteps.findIndex((step) => step.id === current)
|
||||
const CurrentIcon = currentStep.Icon
|
||||
|
||||
return (
|
||||
<aside className={styles.rail} aria-label={`مرحله جاری: ${currentStep.label}`}>
|
||||
<div className={styles.railCurrent}>
|
||||
<span className={styles.railIconWrap} aria-hidden>
|
||||
<CurrentIcon size={28} strokeWidth={1.75} />
|
||||
</span>
|
||||
<span className={styles.railTitle}>{currentStep.label}</span>
|
||||
<span className={styles.railIndex}>
|
||||
گام {currentIdx + 1} از {visibleSteps.length}
|
||||
</span>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,12 +8,28 @@ import {
|
||||
} from 'react'
|
||||
import type { Order } from '../services/orderService'
|
||||
import { getTenantDomain } from '../lib/config'
|
||||
import {
|
||||
SHIPPING_METHOD_LABELS_FA,
|
||||
type ShippingMethodId,
|
||||
} from '../services/shippingCostService'
|
||||
|
||||
export type DeliveryMode = 'delivery' | 'pickup'
|
||||
|
||||
export interface CheckoutShippingQuote {
|
||||
key: string
|
||||
title: string
|
||||
methodId: string | null
|
||||
methodLabel: string | null
|
||||
amount: number
|
||||
itemNames: string[]
|
||||
}
|
||||
|
||||
interface CheckoutState {
|
||||
deliveryMode: DeliveryMode
|
||||
selectedAddressId: string | null
|
||||
/** Selected courier method per shipping cost group id (or "static"). */
|
||||
shippingMethodByGroupId: Record<string, string>
|
||||
shippingQuotes: CheckoutShippingQuote[]
|
||||
discountCode: string
|
||||
placedOrder: Order | null
|
||||
}
|
||||
@@ -21,14 +37,20 @@ interface CheckoutState {
|
||||
interface CheckoutContextValue extends CheckoutState {
|
||||
setDeliveryMode: (mode: DeliveryMode) => void
|
||||
setSelectedAddressId: (id: string | null) => void
|
||||
setShippingMethodForGroup: (groupId: string, methodId: string) => void
|
||||
setShippingMethodByGroupId: (value: Record<string, string>) => void
|
||||
setShippingQuotes: (quotes: CheckoutShippingQuote[]) => void
|
||||
setDiscountCode: (code: string) => void
|
||||
setPlacedOrder: (order: Order | null) => void
|
||||
resetCheckout: () => void
|
||||
shippingTotal: number
|
||||
}
|
||||
|
||||
const defaultState: CheckoutState = {
|
||||
deliveryMode: 'delivery',
|
||||
selectedAddressId: null,
|
||||
shippingMethodByGroupId: {},
|
||||
shippingQuotes: [],
|
||||
discountCode: '',
|
||||
placedOrder: null,
|
||||
}
|
||||
@@ -37,19 +59,63 @@ function storageKey() {
|
||||
return `meshkee_checkout:${getTenantDomain()}`
|
||||
}
|
||||
|
||||
function readPersisted(): Pick<CheckoutState, 'deliveryMode' | 'selectedAddressId' | 'discountCode'> {
|
||||
function parseShippingQuotes(value: unknown): CheckoutShippingQuote[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
const quotes: CheckoutShippingQuote[] = []
|
||||
for (const entry of value) {
|
||||
if (!entry || typeof entry !== 'object') continue
|
||||
const row = entry as Record<string, unknown>
|
||||
if (typeof row.key !== 'string' || !row.key) continue
|
||||
quotes.push({
|
||||
key: row.key,
|
||||
title: typeof row.title === 'string' ? row.title : '',
|
||||
methodId: typeof row.methodId === 'string' ? row.methodId : null,
|
||||
methodLabel: typeof row.methodLabel === 'string' ? row.methodLabel : null,
|
||||
amount: typeof row.amount === 'number' && Number.isFinite(row.amount) ? row.amount : 0,
|
||||
itemNames: Array.isArray(row.itemNames)
|
||||
? row.itemNames.filter((name): name is string => typeof name === 'string')
|
||||
: [],
|
||||
})
|
||||
}
|
||||
return quotes
|
||||
}
|
||||
|
||||
function readPersisted(): Pick<
|
||||
CheckoutState,
|
||||
| 'deliveryMode'
|
||||
| 'selectedAddressId'
|
||||
| 'discountCode'
|
||||
| 'shippingMethodByGroupId'
|
||||
| 'shippingQuotes'
|
||||
> {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(storageKey())
|
||||
if (!raw) return {
|
||||
deliveryMode: defaultState.deliveryMode,
|
||||
selectedAddressId: defaultState.selectedAddressId,
|
||||
discountCode: defaultState.discountCode,
|
||||
if (!raw) {
|
||||
return {
|
||||
deliveryMode: defaultState.deliveryMode,
|
||||
selectedAddressId: defaultState.selectedAddressId,
|
||||
discountCode: defaultState.discountCode,
|
||||
shippingMethodByGroupId: defaultState.shippingMethodByGroupId,
|
||||
shippingQuotes: defaultState.shippingQuotes,
|
||||
}
|
||||
}
|
||||
const parsed = JSON.parse(raw) as {
|
||||
deliveryMode?: unknown
|
||||
selectedAddressId?: unknown
|
||||
discountCode?: unknown
|
||||
shippingMethodByGroupId?: unknown
|
||||
shippingQuotes?: unknown
|
||||
}
|
||||
const methods =
|
||||
parsed.shippingMethodByGroupId &&
|
||||
typeof parsed.shippingMethodByGroupId === 'object' &&
|
||||
!Array.isArray(parsed.shippingMethodByGroupId)
|
||||
? Object.fromEntries(
|
||||
Object.entries(parsed.shippingMethodByGroupId as Record<string, unknown>).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === 'string',
|
||||
),
|
||||
)
|
||||
: {}
|
||||
return {
|
||||
deliveryMode:
|
||||
parsed.deliveryMode === 'pickup' || parsed.deliveryMode === 'delivery'
|
||||
@@ -59,14 +125,17 @@ function readPersisted(): Pick<CheckoutState, 'deliveryMode' | 'selectedAddressI
|
||||
typeof parsed.selectedAddressId === 'string' && parsed.selectedAddressId
|
||||
? parsed.selectedAddressId
|
||||
: null,
|
||||
discountCode:
|
||||
typeof parsed.discountCode === 'string' ? parsed.discountCode : '',
|
||||
discountCode: typeof parsed.discountCode === 'string' ? parsed.discountCode : '',
|
||||
shippingMethodByGroupId: methods,
|
||||
shippingQuotes: parseShippingQuotes(parsed.shippingQuotes),
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
deliveryMode: defaultState.deliveryMode,
|
||||
selectedAddressId: defaultState.selectedAddressId,
|
||||
discountCode: defaultState.discountCode,
|
||||
shippingMethodByGroupId: defaultState.shippingMethodByGroupId,
|
||||
shippingQuotes: defaultState.shippingQuotes,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,6 +148,8 @@ function persistPartial(state: CheckoutState) {
|
||||
deliveryMode: state.deliveryMode,
|
||||
selectedAddressId: state.selectedAddressId,
|
||||
discountCode: state.discountCode,
|
||||
shippingMethodByGroupId: state.shippingMethodByGroupId,
|
||||
shippingQuotes: state.shippingQuotes,
|
||||
}),
|
||||
)
|
||||
} catch {
|
||||
@@ -96,7 +167,11 @@ export function CheckoutProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const setDeliveryMode = useCallback((deliveryMode: DeliveryMode) => {
|
||||
setState((prev) => {
|
||||
const next = { ...prev, deliveryMode }
|
||||
const next = {
|
||||
...prev,
|
||||
deliveryMode,
|
||||
shippingQuotes: deliveryMode === 'pickup' ? [] : prev.shippingQuotes,
|
||||
}
|
||||
persistPartial(next)
|
||||
return next
|
||||
})
|
||||
@@ -115,6 +190,48 @@ export function CheckoutProvider({ children }: { children: ReactNode }) {
|
||||
})
|
||||
}, [])
|
||||
|
||||
const setShippingMethodForGroup = useCallback((groupId: string, methodId: string) => {
|
||||
const methodLabel =
|
||||
methodId in SHIPPING_METHOD_LABELS_FA
|
||||
? SHIPPING_METHOD_LABELS_FA[methodId as ShippingMethodId]
|
||||
: methodId
|
||||
setState((prev) => {
|
||||
const next = {
|
||||
...prev,
|
||||
shippingMethodByGroupId: {
|
||||
...prev.shippingMethodByGroupId,
|
||||
[groupId]: methodId,
|
||||
},
|
||||
shippingQuotes: prev.shippingQuotes.map((quote) =>
|
||||
quote.key === groupId
|
||||
? { ...quote, methodId, methodLabel }
|
||||
: quote,
|
||||
),
|
||||
}
|
||||
persistPartial(next)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const setShippingMethodByGroupId = useCallback(
|
||||
(shippingMethodByGroupId: Record<string, string>) => {
|
||||
setState((prev) => {
|
||||
const next = { ...prev, shippingMethodByGroupId }
|
||||
persistPartial(next)
|
||||
return next
|
||||
})
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const setShippingQuotes = useCallback((shippingQuotes: CheckoutShippingQuote[]) => {
|
||||
setState((prev) => {
|
||||
const next = { ...prev, shippingQuotes }
|
||||
persistPartial(next)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const setDiscountCode = useCallback((discountCode: string) => {
|
||||
setState((prev) => {
|
||||
const next = { ...prev, discountCode }
|
||||
@@ -136,16 +253,39 @@ export function CheckoutProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const shippingTotal = useMemo(
|
||||
() =>
|
||||
state.deliveryMode === 'pickup'
|
||||
? 0
|
||||
: state.shippingQuotes.reduce((sum, quote) => sum + quote.amount, 0),
|
||||
[state.deliveryMode, state.shippingQuotes],
|
||||
)
|
||||
|
||||
const value = useMemo<CheckoutContextValue>(
|
||||
() => ({
|
||||
...state,
|
||||
shippingTotal,
|
||||
setDeliveryMode,
|
||||
setSelectedAddressId,
|
||||
setShippingMethodForGroup,
|
||||
setShippingMethodByGroupId,
|
||||
setShippingQuotes,
|
||||
setDiscountCode,
|
||||
setPlacedOrder,
|
||||
resetCheckout,
|
||||
}),
|
||||
[state, setDeliveryMode, setSelectedAddressId, setDiscountCode, setPlacedOrder, resetCheckout],
|
||||
[
|
||||
state,
|
||||
shippingTotal,
|
||||
setDeliveryMode,
|
||||
setSelectedAddressId,
|
||||
setShippingMethodForGroup,
|
||||
setShippingMethodByGroupId,
|
||||
setShippingQuotes,
|
||||
setDiscountCode,
|
||||
setPlacedOrder,
|
||||
resetCheckout,
|
||||
],
|
||||
)
|
||||
|
||||
return <CheckoutContext.Provider value={value}>{children}</CheckoutContext.Provider>
|
||||
|
||||
@@ -154,6 +154,12 @@ const en = {
|
||||
'orderItems.item': 'item',
|
||||
'orderItems.itemsPlural': 'items',
|
||||
'orderItems.close': 'Close',
|
||||
'orderItems.delivery': 'Delivery',
|
||||
'orderItems.pickup': 'In-store pickup',
|
||||
'orderItems.subtotal': 'Subtotal',
|
||||
'orderItems.shipping': 'Shipping',
|
||||
'orderItems.discount': 'Discount',
|
||||
'orderItems.freeShipping': 'Free shipping',
|
||||
|
||||
'favorites.title': 'My Favorites',
|
||||
'favorites.subtitle': 'Products you have saved for later.',
|
||||
@@ -576,6 +582,12 @@ const fa: Record<MessageKey, string> = {
|
||||
'orderItems.item': 'قلم',
|
||||
'orderItems.itemsPlural': 'قلم',
|
||||
'orderItems.close': 'بستن',
|
||||
'orderItems.delivery': 'ارسال',
|
||||
'orderItems.pickup': 'تحویل حضوری',
|
||||
'orderItems.subtotal': 'جمع جزء',
|
||||
'orderItems.shipping': 'هزینه ارسال',
|
||||
'orderItems.discount': 'تخفیف',
|
||||
'orderItems.freeShipping': 'ارسال رایگان',
|
||||
|
||||
'favorites.title': 'علاقهمندیها',
|
||||
'favorites.subtitle': 'محصولاتی که برای بعد ذخیره کردهاید.',
|
||||
|
||||
@@ -17,6 +17,7 @@ import { LanguageSelect, PasswordStrengthMeter, isPasswordStrong, useLocale, use
|
||||
import { useT } from '../i18n/useT'
|
||||
import { safeRedirectPath } from '../lib/redirect'
|
||||
import { BackgroundOrbs } from '../components/BackgroundOrbs'
|
||||
import { syncGuestCartToServer } from '../services/syncGuestCart'
|
||||
import styles from './LoginPage.module.css'
|
||||
|
||||
type AuthView = 'login' | 'signup' | 'forgot' | 'otp'
|
||||
@@ -49,6 +50,17 @@ export function LoginPage() {
|
||||
const navigate = useNavigate()
|
||||
const [searchParams] = useSearchParams()
|
||||
const redirectTo = safeRedirectPath(searchParams.get('redirect'))
|
||||
|
||||
async function finishAuthAndNavigate() {
|
||||
if (redirectTo.startsWith('/checkout')) {
|
||||
try {
|
||||
await syncGuestCartToServer()
|
||||
} catch {
|
||||
// Cart sync is best-effort; checkout will retry
|
||||
}
|
||||
}
|
||||
navigate(redirectTo)
|
||||
}
|
||||
const { login, loginWithOtp, establishSession } = useAuth()
|
||||
const { businessName, businessNameEn, logoUrl } = useTenantBranding()
|
||||
const tenantDomain = getTenantDomain()
|
||||
@@ -195,7 +207,7 @@ export function LoginPage() {
|
||||
|
||||
try {
|
||||
await login(cellNumber, password)
|
||||
navigate(redirectTo)
|
||||
await finishAuthAndNavigate()
|
||||
} catch (err) {
|
||||
handleApiError(err, t('login.error.signIn'))
|
||||
} finally {
|
||||
@@ -337,7 +349,7 @@ export function LoginPage() {
|
||||
if (data.existingAccount) {
|
||||
showToast(t('signup.toast.linkedExisting'), 'info')
|
||||
}
|
||||
navigate(redirectTo)
|
||||
await finishAuthAndNavigate()
|
||||
} catch (err) {
|
||||
handleApiError(err, t('signup.error.create'))
|
||||
} finally {
|
||||
@@ -438,7 +450,7 @@ export function LoginPage() {
|
||||
if (result.passwordUpdated) {
|
||||
showToast(t('signup.toast.passwordUpdated'), 'info')
|
||||
}
|
||||
navigate(redirectTo)
|
||||
await finishAuthAndNavigate()
|
||||
} catch (err) {
|
||||
handleApiError(err, t('otp.error.signIn'))
|
||||
} finally {
|
||||
|
||||
@@ -189,7 +189,7 @@ export function CheckoutCartStep() {
|
||||
|
||||
async function handleContinue() {
|
||||
if (!isAuthenticated) {
|
||||
navigate(`/checkout/login?redirect=${encodeURIComponent('/checkout/delivery')}`, {
|
||||
navigate(`/login?redirect=${encodeURIComponent('/checkout/delivery')}`, {
|
||||
state: { from: '/checkout/delivery' },
|
||||
})
|
||||
return
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Navigate, useNavigate } from 'react-router-dom'
|
||||
import { MapPin, Store } from 'lucide-react'
|
||||
import { useAuth } from '../../context/AuthContext'
|
||||
import { useCheckout } from '../../context/CheckoutContext'
|
||||
import { ApiError, isAbortError } from '../../lib/api'
|
||||
import { listAddresses, type UserAddress } from '../../services/addressService'
|
||||
import { getCart, type CartItem } from '../../services/cartService'
|
||||
import { listIranProvinces, type CityOption } from '../../services/citiesService'
|
||||
import {
|
||||
findGroupForCategoryIds,
|
||||
getPublicShippingConfig,
|
||||
resolveProvinceAmount,
|
||||
SHIPPING_METHOD_LABELS_FA,
|
||||
type PublicShippingConfig,
|
||||
type ShippingMethodId,
|
||||
} from '../../services/shippingCostService'
|
||||
import { formatIrtPrice } from '../../utils/irtPrice'
|
||||
import {
|
||||
CheckoutAddAddressRow,
|
||||
} from '../../components/checkout/CheckoutAddAddressPanel'
|
||||
@@ -13,12 +24,54 @@ import { CheckoutActionBar } from '../../components/checkout/CheckoutActionBar'
|
||||
import { CheckoutBackButton } from '../../components/checkout/CheckoutBackButton'
|
||||
import styles from './CheckoutSteps.module.css'
|
||||
|
||||
const STATIC_GROUP_KEY = 'static'
|
||||
const STORE_SHIPPING_TITLE = 'ارسال توسط مجموعه'
|
||||
const UNGROUPED_KEY = 'ungrouped'
|
||||
|
||||
interface ShippingGroupQuote {
|
||||
key: string
|
||||
title: string
|
||||
methods: ShippingMethodId[]
|
||||
amount: number
|
||||
items: CartItem[]
|
||||
}
|
||||
|
||||
function resolveProvinceId(
|
||||
provinces: CityOption[],
|
||||
provinceName: string | null | undefined,
|
||||
) {
|
||||
const needle = provinceName?.trim()
|
||||
if (!needle) return null
|
||||
const match = provinces.find(
|
||||
(item) =>
|
||||
item.nameFa === needle ||
|
||||
item.nameEn === needle ||
|
||||
item.slug === needle,
|
||||
)
|
||||
return match?.id ?? null
|
||||
}
|
||||
|
||||
function quoteTitleForGroup(isDefault: boolean, nameFa: string, nameEn: string) {
|
||||
return isDefault ? STORE_SHIPPING_TITLE : nameFa || nameEn
|
||||
}
|
||||
|
||||
export function CheckoutDeliveryStep() {
|
||||
const navigate = useNavigate()
|
||||
const { user, isLoading: authLoading } = useAuth()
|
||||
const { deliveryMode, selectedAddressId, setDeliveryMode, setSelectedAddressId } =
|
||||
useCheckout()
|
||||
const {
|
||||
deliveryMode,
|
||||
selectedAddressId,
|
||||
shippingMethodByGroupId,
|
||||
setDeliveryMode,
|
||||
setSelectedAddressId,
|
||||
setShippingMethodForGroup,
|
||||
setShippingMethodByGroupId,
|
||||
setShippingQuotes,
|
||||
} = useCheckout()
|
||||
const [addresses, setAddresses] = useState<UserAddress[]>([])
|
||||
const [provinces, setProvinces] = useState<CityOption[]>([])
|
||||
const [cartItems, setCartItems] = useState<CartItem[]>([])
|
||||
const [shippingConfig, setShippingConfig] = useState<PublicShippingConfig | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
@@ -35,24 +88,32 @@ export function CheckoutDeliveryStep() {
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const data = await listAddresses(controller.signal)
|
||||
const [addressData, provinceItems, cartData, shippingData] = await Promise.all([
|
||||
listAddresses(controller.signal),
|
||||
listIranProvinces(controller.signal),
|
||||
getCart(controller.signal),
|
||||
getPublicShippingConfig(controller.signal),
|
||||
])
|
||||
if (controller.signal.aborted) return
|
||||
|
||||
setAddresses(data.items)
|
||||
setAddresses(addressData.items)
|
||||
setProvinces(provinceItems)
|
||||
setCartItems(cartData.cart.items)
|
||||
setShippingConfig(shippingData)
|
||||
|
||||
const current = selectedAddressIdRef.current
|
||||
if (data.items.length === 0) {
|
||||
if (addressData.items.length === 0) {
|
||||
if (current !== null) setSelectedAddressId(null)
|
||||
return
|
||||
}
|
||||
|
||||
const stillValid = current !== null && data.items.some((item) => item.id === current)
|
||||
const stillValid = current !== null && addressData.items.some((item) => item.id === current)
|
||||
if (!stillValid) {
|
||||
setSelectedAddressId(data.items[0].id)
|
||||
setSelectedAddressId(addressData.items[0].id)
|
||||
}
|
||||
} catch (err) {
|
||||
if (isAbortError(err)) return
|
||||
setError(err instanceof ApiError ? err.message : 'بارگذاری آدرسها ممکن نشد.')
|
||||
setError(err instanceof ApiError ? err.message : 'بارگذاری اطلاعات ارسال ممکن نشد.')
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setLoading(false)
|
||||
}
|
||||
@@ -62,19 +123,183 @@ export function CheckoutDeliveryStep() {
|
||||
return () => controller.abort()
|
||||
}, [user, setSelectedAddressId])
|
||||
|
||||
async function handleAddressSaved() {
|
||||
async function handleAddressSaved(created?: { id: string }) {
|
||||
setError('')
|
||||
try {
|
||||
const data = await listAddresses()
|
||||
setAddresses(data.items)
|
||||
const newest = data.items.at(-1)
|
||||
if (newest) setSelectedAddressId(newest.id)
|
||||
if (created?.id && data.items.some((item) => item.id === created.id)) {
|
||||
setSelectedAddressId(created.id)
|
||||
} else {
|
||||
const newest = data.items.at(-1)
|
||||
if (newest) setSelectedAddressId(newest.id)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'بارگذاری آدرسها ممکن نشد.')
|
||||
}
|
||||
}
|
||||
|
||||
const selectedAddress = useMemo(
|
||||
() => addresses.find((item) => item.id === selectedAddressId) ?? null,
|
||||
[addresses, selectedAddressId],
|
||||
)
|
||||
|
||||
const provinceId = useMemo(
|
||||
() => resolveProvinceId(provinces, selectedAddress?.province),
|
||||
[provinces, selectedAddress?.province],
|
||||
)
|
||||
|
||||
const shippingQuotes = useMemo((): ShippingGroupQuote[] => {
|
||||
if (deliveryMode !== 'delivery' || !selectedAddress) {
|
||||
return []
|
||||
}
|
||||
|
||||
if (!shippingConfig?.enabled) {
|
||||
if (cartItems.length === 0) return []
|
||||
return [
|
||||
{
|
||||
key: UNGROUPED_KEY,
|
||||
title: STORE_SHIPPING_TITLE,
|
||||
methods: [],
|
||||
amount: 0,
|
||||
items: cartItems,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
if (shippingConfig.staticShippingEnabled) {
|
||||
return [
|
||||
{
|
||||
key: STATIC_GROUP_KEY,
|
||||
title: STORE_SHIPPING_TITLE,
|
||||
methods: [],
|
||||
amount: resolveProvinceAmount(
|
||||
shippingConfig.staticShippingAmount,
|
||||
shippingConfig.staticShippingProvinceExceptions,
|
||||
provinceId,
|
||||
),
|
||||
items: cartItems,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const buckets = new Map<string, ShippingGroupQuote>()
|
||||
const ungrouped: CartItem[] = []
|
||||
const defaultGroup =
|
||||
shippingConfig.items.find((group) => group.isDefault) ?? null
|
||||
|
||||
for (const item of cartItems) {
|
||||
const matched = findGroupForCategoryIds(
|
||||
shippingConfig.items,
|
||||
item.categoryIds ?? [],
|
||||
)
|
||||
const group = matched ?? defaultGroup
|
||||
if (!group) {
|
||||
ungrouped.push(item)
|
||||
continue
|
||||
}
|
||||
|
||||
const existing = buckets.get(group.id)
|
||||
if (existing) {
|
||||
existing.items.push(item)
|
||||
} else {
|
||||
buckets.set(group.id, {
|
||||
key: group.id,
|
||||
title: quoteTitleForGroup(group.isDefault, group.nameFa, group.nameEn),
|
||||
methods: group.methods,
|
||||
amount: resolveProvinceAmount(
|
||||
group.defaultAmount,
|
||||
group.provinceExceptions,
|
||||
provinceId,
|
||||
),
|
||||
items: [item],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (ungrouped.length > 0) {
|
||||
buckets.set(UNGROUPED_KEY, {
|
||||
key: UNGROUPED_KEY,
|
||||
title: STORE_SHIPPING_TITLE,
|
||||
methods: [],
|
||||
amount: 0,
|
||||
items: ungrouped,
|
||||
})
|
||||
}
|
||||
|
||||
return [...buckets.values()]
|
||||
}, [cartItems, deliveryMode, provinceId, selectedAddress, shippingConfig])
|
||||
|
||||
useEffect(() => {
|
||||
if (shippingQuotes.length === 0) return
|
||||
|
||||
const next: Record<string, string> = { ...shippingMethodByGroupId }
|
||||
let changed = false
|
||||
|
||||
for (const quote of shippingQuotes) {
|
||||
if (!quote.methods.length) {
|
||||
if (next[quote.key]) {
|
||||
delete next[quote.key]
|
||||
changed = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (!next[quote.key] || !quote.methods.includes(next[quote.key] as ShippingMethodId)) {
|
||||
next[quote.key] = quote.methods[0]
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of Object.keys(next)) {
|
||||
if (!shippingQuotes.some((quote) => quote.key === key)) {
|
||||
delete next[key]
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) setShippingMethodByGroupId(next)
|
||||
}, [shippingQuotes, shippingMethodByGroupId, setShippingMethodByGroupId])
|
||||
|
||||
useEffect(() => {
|
||||
if (deliveryMode === 'pickup') {
|
||||
setShippingQuotes([])
|
||||
return
|
||||
}
|
||||
setShippingQuotes(
|
||||
shippingQuotes.map((quote) => {
|
||||
const methodId = shippingMethodByGroupId[quote.key] ?? quote.methods[0] ?? null
|
||||
return {
|
||||
key: quote.key,
|
||||
title: quote.title,
|
||||
methodId,
|
||||
methodLabel: methodId
|
||||
? SHIPPING_METHOD_LABELS_FA[methodId as ShippingMethodId] ?? methodId
|
||||
: null,
|
||||
amount: quote.amount,
|
||||
itemNames: quote.items.map(
|
||||
(item) => item.productNameFa || item.productTitle,
|
||||
),
|
||||
}
|
||||
}),
|
||||
)
|
||||
}, [deliveryMode, shippingQuotes, shippingMethodByGroupId, setShippingQuotes])
|
||||
|
||||
const shippingTotal = useMemo(
|
||||
() => shippingQuotes.reduce((sum, quote) => sum + quote.amount, 0),
|
||||
[shippingQuotes],
|
||||
)
|
||||
|
||||
const shippingReady =
|
||||
deliveryMode === 'pickup' ||
|
||||
(Boolean(selectedAddressId) &&
|
||||
shippingQuotes.every(
|
||||
(quote) =>
|
||||
quote.methods.length === 0 || Boolean(shippingMethodByGroupId[quote.key]),
|
||||
))
|
||||
|
||||
const canContinue =
|
||||
deliveryMode === 'pickup' || (deliveryMode === 'delivery' && selectedAddressId)
|
||||
deliveryMode === 'pickup' ||
|
||||
(deliveryMode === 'delivery' && Boolean(selectedAddressId) && shippingReady)
|
||||
|
||||
if (authLoading) {
|
||||
return <div className={styles.loading}>در حال بارگذاری...</div>
|
||||
@@ -83,7 +308,7 @@ export function CheckoutDeliveryStep() {
|
||||
if (!user) {
|
||||
return (
|
||||
<Navigate
|
||||
to={`/checkout/login?redirect=${encodeURIComponent('/checkout/delivery')}`}
|
||||
to={`/login?redirect=${encodeURIComponent('/checkout/delivery')}`}
|
||||
replace
|
||||
state={{ from: '/checkout/delivery' }}
|
||||
/>
|
||||
@@ -97,9 +322,9 @@ export function CheckoutDeliveryStep() {
|
||||
onClick={() => navigate('/checkout/cart')}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<div className={styles.stepHeadingRow}>
|
||||
<h1 className={styles.stepTitle}>نحوه دریافت</h1>
|
||||
<p className={styles.stepDesc}>چگونه میخواهید سفارش را دریافت کنید؟</p>
|
||||
<p className={styles.stepDescInline}>چگونه میخواهید سفارش را دریافت کنید؟</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.deliveryOptions}>
|
||||
@@ -198,13 +423,92 @@ export function CheckoutDeliveryStep() {
|
||||
<CheckoutAddAddressRow onClick={() => setShowAddModal(true)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedAddressId && !loading ? (
|
||||
<div className={styles.shippingSection}>
|
||||
<h2 className={styles.shippingSectionTitle}>روش و هزینه ارسال</h2>
|
||||
|
||||
{shippingQuotes.length === 0 ? (
|
||||
<p className={styles.addressEmptyHint}>
|
||||
برای این سفارش روش ارسال فعالی تعریف نشده است.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{shippingQuotes.map((quote) => (
|
||||
<div key={quote.key} className={styles.shippingGroupCard}>
|
||||
<div className={styles.shippingGroupHeader}>
|
||||
<span className={styles.shippingGroupTitle}>{quote.title}</span>
|
||||
<span
|
||||
className={[
|
||||
styles.shippingGroupAmount,
|
||||
quote.amount > 0 ? '' : styles.shippingGroupAmountFree,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
{quote.amount > 0
|
||||
? formatIrtPrice(quote.amount)
|
||||
: 'ارسال رایگان'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{quote.items.length > 0 ? (
|
||||
<ul className={styles.shippingItemList}>
|
||||
{quote.items.map((item) => (
|
||||
<li key={item.id} className={styles.shippingItemRow}>
|
||||
<span className={styles.shippingItemName}>
|
||||
{item.productNameFa || item.productTitle}
|
||||
</span>
|
||||
<span className={styles.shippingItemQty}>
|
||||
{item.quantity.toLocaleString('fa-IR')} عدد
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
{quote.methods.length > 0 ? (
|
||||
<div className={styles.shippingMethodGrid} role="group">
|
||||
{quote.methods.map((method) => {
|
||||
const active = shippingMethodByGroupId[quote.key] === method
|
||||
return (
|
||||
<button
|
||||
key={method}
|
||||
type="button"
|
||||
className={[
|
||||
styles.shippingMethodChip,
|
||||
active ? styles.shippingMethodChipActive : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
onClick={() => setShippingMethodForGroup(quote.key, method)}
|
||||
>
|
||||
{SHIPPING_METHOD_LABELS_FA[method]}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{shippingQuotes.length > 1 ? (
|
||||
<div className={styles.shippingTotalRow}>
|
||||
<span>جمع هزینه ارسال</span>
|
||||
<span className={styles.priceEn}>{formatIrtPrice(shippingTotal)}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
<CheckoutAddAddressModal
|
||||
open={showAddModal}
|
||||
onClose={() => setShowAddModal(false)}
|
||||
onSaved={() => void handleAddressSaved()}
|
||||
onSaved={(address) => void handleAddressSaved(address)}
|
||||
/>
|
||||
|
||||
<CheckoutActionBar
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { Navigate, Outlet, useLocation } from 'react-router-dom'
|
||||
import { RouteLoader } from '@meshkee/dashboard-ui'
|
||||
import { useAuth } from '../../context/AuthContext'
|
||||
import { CheckoutStepper, type CheckoutStepId } from '../../components/checkout/CheckoutStepper'
|
||||
import {
|
||||
CheckoutStepper,
|
||||
CheckoutStepSidePanel,
|
||||
type CheckoutStepId,
|
||||
} from '../../components/checkout/CheckoutStepper'
|
||||
import { loginPathWithRedirect, safeRedirectPath } from '../../lib/redirect'
|
||||
|
||||
/** Cart and checkout login are public. Delivery, payment, and result require auth. */
|
||||
const PUBLIC_STEPS = new Set<CheckoutStepId>(['cart', 'login'])
|
||||
import layoutStyles from '../../components/checkout/CheckoutLayout.module.css'
|
||||
|
||||
function stepFromPath(pathname: string): CheckoutStepId {
|
||||
if (pathname.endsWith('/login')) return 'login'
|
||||
@@ -31,17 +33,6 @@ export function CheckoutFlow() {
|
||||
return <RouteLoader />
|
||||
}
|
||||
|
||||
if (!isAuthenticated && !PUBLIC_STEPS.has(step)) {
|
||||
const returnTo = `${location.pathname}${location.search}`
|
||||
return (
|
||||
<Navigate
|
||||
to={loginPathWithRedirect(returnTo, '/checkout/login')}
|
||||
replace
|
||||
state={{ from: returnTo }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (isAuthenticated && step === 'login') {
|
||||
const params = new URLSearchParams(location.search)
|
||||
const fromQuery = safeRedirectPath(params.get('redirect'), '')
|
||||
@@ -50,17 +41,38 @@ export function CheckoutFlow() {
|
||||
return <Navigate to={destination} replace />
|
||||
}
|
||||
|
||||
// Logged-out users use the real /login page (not an inline checkout login).
|
||||
if (!isAuthenticated && step !== 'cart') {
|
||||
const params = new URLSearchParams(location.search)
|
||||
const returnTo =
|
||||
step === 'login'
|
||||
? safeRedirectPath(params.get('redirect'), '/checkout/delivery')
|
||||
: `${location.pathname}${location.search}`
|
||||
return (
|
||||
<Navigate
|
||||
to={loginPathWithRedirect(returnTo, '/login')}
|
||||
replace
|
||||
state={{ from: returnTo }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const hideStepper =
|
||||
location.pathname === '/checkout/success' ||
|
||||
location.pathname === '/checkout/failed' ||
|
||||
location.pathname === '/checkout/result'
|
||||
|
||||
return (
|
||||
<>
|
||||
{step !== 'login' && !hideStepper && (
|
||||
<CheckoutStepper current={step} isAuthenticated={isAuthenticated} />
|
||||
)}
|
||||
<Outlet />
|
||||
</>
|
||||
<div className={layoutStyles.shell}>
|
||||
{!hideStepper ? (
|
||||
<CheckoutStepSidePanel current={step} isAuthenticated={isAuthenticated} />
|
||||
) : null}
|
||||
<div className={layoutStyles.card}>
|
||||
{!hideStepper ? (
|
||||
<CheckoutStepper current={step} isAuthenticated={isAuthenticated} />
|
||||
) : null}
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -22,8 +22,15 @@ import styles from './CheckoutSteps.module.css'
|
||||
export function CheckoutPaymentStep() {
|
||||
const navigate = useNavigate()
|
||||
const { showToast } = useToast()
|
||||
const { deliveryMode, selectedAddressId, discountCode, setDiscountCode, setPlacedOrder } =
|
||||
useCheckout()
|
||||
const {
|
||||
deliveryMode,
|
||||
selectedAddressId,
|
||||
shippingQuotes,
|
||||
shippingTotal,
|
||||
discountCode,
|
||||
setDiscountCode,
|
||||
setPlacedOrder,
|
||||
} = useCheckout()
|
||||
const [subtotal, setSubtotal] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
@@ -153,21 +160,37 @@ export function CheckoutPaymentStep() {
|
||||
? `${window.location.origin}/checkout/result`
|
||||
: undefined
|
||||
|
||||
const shippingGroups =
|
||||
deliveryMode === 'delivery'
|
||||
? shippingQuotes.map((quote) => ({
|
||||
groupId: quote.key,
|
||||
title: quote.title,
|
||||
methodId: quote.methodId ?? undefined,
|
||||
methodLabel: quote.methodLabel ?? undefined,
|
||||
amount: quote.amount,
|
||||
itemNames: quote.itemNames,
|
||||
}))
|
||||
: []
|
||||
|
||||
const payload =
|
||||
deliveryMode === 'pickup'
|
||||
? {
|
||||
deliveryMode: 'pickup' as const,
|
||||
shippingAddress: {
|
||||
province: 'Pickup',
|
||||
city: 'Store',
|
||||
address: 'In-store pickup',
|
||||
postalCode: '0000000000',
|
||||
},
|
||||
shippingGroups: [],
|
||||
customerNotes: notes.join(' · ') || undefined,
|
||||
returnUrl,
|
||||
payment,
|
||||
}
|
||||
: {
|
||||
deliveryMode: 'delivery' as const,
|
||||
addressId: selectedAddressId!,
|
||||
shippingGroups,
|
||||
customerNotes: notes.join(' · ') || undefined,
|
||||
returnUrl,
|
||||
payment,
|
||||
@@ -351,13 +374,25 @@ export function CheckoutPaymentStep() {
|
||||
<span>جمع جزء</span>
|
||||
<span className={styles.priceEn}>{formatIrtPrice(subtotal)}</span>
|
||||
</div>
|
||||
<div className={styles.summaryRow}>
|
||||
<span>هزینه ارسال</span>
|
||||
<span className={styles.priceEn}>
|
||||
{deliveryMode === 'pickup'
|
||||
? '—'
|
||||
: shippingTotal <= 0
|
||||
? 'ارسال رایگان'
|
||||
: formatIrtPrice(shippingTotal)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.summaryRow}>
|
||||
<span>تخفیف</span>
|
||||
<span className={styles.priceEn}>۰ IRT</span>
|
||||
</div>
|
||||
<div className={`${styles.summaryRow} ${styles.summaryRowTotal}`}>
|
||||
<span>مبلغ قابل پرداخت</span>
|
||||
<span className={styles.priceEn}>{formatIrtPrice(subtotal)}</span>
|
||||
<span className={styles.priceEn}>
|
||||
{formatIrtPrice(subtotal + (deliveryMode === 'pickup' ? 0 : shippingTotal))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -18,6 +18,20 @@
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.stepHeadingRow {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 12px;
|
||||
}
|
||||
|
||||
.stepDescInline {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
@@ -271,18 +285,18 @@
|
||||
.summaryRowTotal {
|
||||
margin-top: 8px;
|
||||
padding: 12px 14px;
|
||||
background: #f4f4f5;
|
||||
border: 1px solid #e4e4e7;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--glass-border, rgba(148, 163, 184, 0.28));
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #18181b;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.summaryRowTotal .priceEn {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #18181b;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.deliveryOptions {
|
||||
@@ -416,6 +430,134 @@
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.shippingSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.shippingSectionTitle {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.shippingGroupCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.shippingGroupHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.shippingGroupTitle {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.shippingGroupAmount {
|
||||
font-family: 'Montserrat', var(--font-en), sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
direction: ltr;
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
|
||||
.shippingGroupAmountFree {
|
||||
font-family: inherit;
|
||||
direction: rtl;
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
|
||||
.shippingItemList {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.shippingItemRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(148, 163, 184, 0.06);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.shippingItemName {
|
||||
color: var(--text-secondary);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.shippingItemQty {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.shippingMethodGrid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.shippingMethodChip {
|
||||
min-height: 34px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
transition: border-color 0.15s, background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.shippingMethodChip:hover {
|
||||
border-color: rgba(var(--primary-rgb) / 0.45);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.shippingMethodChipActive {
|
||||
border-color: var(--primary);
|
||||
background: rgba(var(--primary-rgb) / 0.12);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.shippingTotalRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--glass-border, rgba(148, 163, 184, 0.28));
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface CartItem {
|
||||
productTitle: string
|
||||
productNameFa: string
|
||||
productImage: string | null
|
||||
categoryIds?: string[]
|
||||
sku: string
|
||||
selections: CartItemSelection[]
|
||||
label: string
|
||||
@@ -68,9 +69,20 @@ export interface CheckoutPaymentResult {
|
||||
redirect: PaymentRedirect
|
||||
}
|
||||
|
||||
export interface CheckoutShippingGroupInput {
|
||||
groupId: string
|
||||
title?: string
|
||||
methodId?: string
|
||||
methodLabel?: string
|
||||
amount?: number
|
||||
itemNames?: string[]
|
||||
}
|
||||
|
||||
export interface CheckoutInput {
|
||||
addressId?: string
|
||||
shippingAddress?: ShippingAddressInput
|
||||
deliveryMode?: 'delivery' | 'pickup'
|
||||
shippingGroups?: CheckoutShippingGroupInput[]
|
||||
customerNotes?: string
|
||||
/** Absolute URL for bank callback return (required for e_payment_gate). */
|
||||
returnUrl?: string
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { apiRequest } from '../lib/api'
|
||||
import { getTenantDomain } from '../lib/config'
|
||||
|
||||
export const SHIPPING_METHOD_IDS = [
|
||||
'post',
|
||||
'tipax',
|
||||
'mahex',
|
||||
'freight',
|
||||
'air',
|
||||
] as const
|
||||
|
||||
export type ShippingMethodId = (typeof SHIPPING_METHOD_IDS)[number]
|
||||
|
||||
export const SHIPPING_METHOD_LABELS_FA: Record<ShippingMethodId, string> = {
|
||||
post: 'پست',
|
||||
tipax: 'تیپاکس',
|
||||
mahex: 'ماهکس',
|
||||
freight: 'باربری',
|
||||
air: 'ارسال هوایی',
|
||||
}
|
||||
|
||||
export interface PublicShippingCostGroup {
|
||||
id: string
|
||||
nameFa: string
|
||||
nameEn: string
|
||||
description: string | null
|
||||
methods: ShippingMethodId[]
|
||||
defaultAmount: number
|
||||
isDefault: boolean
|
||||
categoryIds: string[]
|
||||
provinceExceptions?: Array<{ provinceId: string; amount: number }>
|
||||
}
|
||||
|
||||
export interface PublicShippingConfig {
|
||||
enabled: boolean
|
||||
staticShippingEnabled: boolean
|
||||
staticShippingAmount: number
|
||||
staticShippingProvinceExceptions: Array<{ provinceId: string; amount: number }>
|
||||
items: PublicShippingCostGroup[]
|
||||
}
|
||||
|
||||
export async function getPublicShippingConfig(signal?: AbortSignal) {
|
||||
const host = encodeURIComponent(getTenantDomain())
|
||||
return apiRequest<PublicShippingConfig>(`/tenants/${host}/shipping-cost-groups`, {
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
export function resolveProvinceAmount(
|
||||
defaultAmount: number,
|
||||
exceptions: Array<{ provinceId: string; amount: number }> | undefined,
|
||||
provinceId: string | null,
|
||||
) {
|
||||
if (!provinceId || !exceptions?.length) return defaultAmount
|
||||
const match = exceptions.find((item) => item.provinceId === provinceId)
|
||||
return match ? match.amount : defaultAmount
|
||||
}
|
||||
|
||||
export function findGroupForCategoryIds(
|
||||
groups: PublicShippingCostGroup[],
|
||||
categoryIds: string[],
|
||||
) {
|
||||
const categorySet = new Set(categoryIds)
|
||||
const byCategory = groups.filter((group) =>
|
||||
group.categoryIds.some((id) => categorySet.has(id)),
|
||||
)
|
||||
if (byCategory.length === 0) return null
|
||||
return byCategory.find((group) => !group.isDefault) ?? byCategory[0] ?? null
|
||||
}
|
||||
Reference in New Issue
Block a user