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:
Alireza Hassani
2026-10-02 21:52:47 +03:30
co-authored by Cursor
parent 95390933a7
commit c59ca4444f
38 changed files with 5353 additions and 98 deletions
@@ -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>
)
}
+149 -9
View File
@@ -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>
+12
View File
@@ -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': 'محصولاتی که برای بعد ذخیره کرده‌اید.',
+15 -3
View File
@@ -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;
+12
View File
@@ -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
}