Initial commit: Meshkee dashboards monorepo.

Includes business, customer, and super-admin apps with shared packages and production deploy scripts.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-07-22 13:48:53 +03:30
co-authored by Cursor
commit f566387c61
509 changed files with 62690 additions and 0 deletions
@@ -0,0 +1,65 @@
.bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 4px;
}
.websiteBtn {
font-size: 13px;
font-weight: 600;
font-family: inherit;
color: var(--text-secondary);
text-decoration: none;
transition: color 0.2s;
line-height: 1.5;
text-align: start;
}
.websiteBtn:hover {
color: var(--primary);
}
.primaryBtn {
flex-shrink: 0;
padding: 10px 22px;
font-size: 13px;
font-weight: 700;
font-family: inherit;
color: white;
text-decoration: none;
text-align: center;
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
border-radius: var(--radius-sm);
box-shadow: 0 4px 14px rgba(var(--primary-rgb) / 0.35);
transition: transform 0.2s, box-shadow 0.2s, opacity 0.2s;
white-space: nowrap;
}
.primaryBtn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 6px 18px rgba(var(--primary-rgb) / 0.4);
}
.primaryBtn:disabled {
opacity: 0.55;
cursor: not-allowed;
transform: none;
}
@media (max-width: 640px) {
.bar {
flex-direction: column-reverse;
align-items: stretch;
gap: 10px;
}
.websiteBtn {
text-align: center;
}
.primaryBtn {
width: 100%;
}
}
@@ -0,0 +1,72 @@
import { Link } from 'react-router-dom'
import { getWebsiteUrl } from '../../services/websiteService'
import styles from './CheckoutActionBar.module.css'
interface CheckoutActionBarProps {
primaryLabel: string
onPrimary: () => void
primaryDisabled?: boolean
primaryType?: 'button' | 'submit'
}
export function CheckoutActionBar({
primaryLabel,
onPrimary,
primaryDisabled = false,
primaryType = 'button',
}: CheckoutActionBarProps) {
const websiteUrl = getWebsiteUrl()
return (
<div className={styles.bar}>
<a href={websiteUrl} className={styles.websiteBtn}>
بازگشت به وبسایت و ادامهٔ خرید
</a>
<button
type={primaryType}
className={styles.primaryBtn}
disabled={primaryDisabled}
onClick={primaryType === 'button' ? onPrimary : undefined}
>
{primaryLabel}
</button>
</div>
)
}
/** Link variant for success / navigation-only rows. */
export function CheckoutWebsiteLink() {
const websiteUrl = getWebsiteUrl()
return (
<div className={styles.bar}>
<a href={websiteUrl} className={styles.websiteBtn}>
بازگشت به وبسایت و ادامهٔ خرید
</a>
</div>
)
}
/** For Link-based primary actions (success page). */
export function CheckoutActionBarLinks({
primaryTo,
primaryLabel,
onPrimaryClick,
}: {
primaryTo: string
primaryLabel: string
onPrimaryClick?: () => void
}) {
const websiteUrl = getWebsiteUrl()
return (
<div className={styles.bar}>
<a href={websiteUrl} className={styles.websiteBtn}>
بازگشت به وبسایت و ادامهٔ خرید
</a>
<Link to={primaryTo} className={styles.primaryBtn} onClick={onPrimaryClick}>
{primaryLabel}
</Link>
</div>
)
}
@@ -0,0 +1,9 @@
.modal {
max-width: 720px;
direction: rtl;
text-align: right;
}
.overlay {
z-index: 1000;
}
@@ -0,0 +1,98 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import { CheckoutAddAddressPanel } from './CheckoutAddAddressPanel'
import modalStyles from '../VariationsModal.module.css'
import styles from './CheckoutAddAddressModal.module.css'
interface CheckoutAddAddressModalProps {
open: boolean
onClose: () => void
onSaved: () => void
}
const ANIMATION_MS = 220
export function CheckoutAddAddressModal({ open, onClose, onSaved }: CheckoutAddAddressModalProps) {
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [formKey, setFormKey] = useState(0)
useEffect(() => {
if (open) {
setMounted(true)
setClosing(false)
setFormKey((key) => key + 1)
} else if (mounted) {
setClosing(true)
const timer = setTimeout(() => {
setMounted(false)
setClosing(false)
}, ANIMATION_MS)
return () => clearTimeout(timer)
}
}, [open, mounted])
useEffect(() => {
if (!mounted || closing) return
const prevOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', onKey)
return () => {
document.body.style.overflow = prevOverflow
document.removeEventListener('keydown', onKey)
}
}, [mounted, closing, onClose])
if (!mounted) return null
function handleSaved() {
onSaved()
onClose()
}
return createPortal(
<div
className={`${modalStyles.overlay} ${styles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={onClose}
>
<div
className={`${modalStyles.modal} ${styles.modal} ${closing ? modalStyles.modalOut : modalStyles.modalIn}`}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="checkout-add-address-title"
lang="fa"
dir="rtl"
>
<div className={modalStyles.header}>
<div>
<h2 id="checkout-add-address-title" className={modalStyles.title}>
افزودن آدرس جدید
</h2>
<p className={modalStyles.subtitle}>آدرس ارسال سفارش را وارد کنید.</p>
</div>
<button type="button" className={modalStyles.closeBtn} onClick={onClose} aria-label="بستن">
<X size={20} />
</button>
</div>
<div className={modalStyles.body}>
<CheckoutAddAddressPanel
key={formKey}
inModal
onSaved={handleSaved}
onCancel={onClose}
/>
</div>
</div>
</div>,
document.body,
)
}
@@ -0,0 +1,163 @@
.form {
display: flex;
flex-direction: column;
gap: 12px;
padding: 14px;
border: 2px solid rgba(var(--primary-rgb) / 0.2);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.7);
}
.formInModal {
padding: 0;
border: none;
background: transparent;
}
.error {
font-size: 13px;
color: #b91c1c;
background: rgba(239, 68, 68, 0.08);
border: 1px solid rgba(239, 68, 68, 0.25);
border-radius: var(--radius-sm);
padding: 10px 12px;
}
.fieldRow {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.fieldRowTriple {
display: grid;
grid-template-columns: 1.1fr 1fr 1fr;
gap: 10px;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
}
.field label {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
}
.optionalMark {
font-weight: 400;
color: var(--text-muted);
}
.field input,
.field select {
width: 100%;
min-height: var(--field-height);
padding: var(--field-padding-y) var(--field-padding-x);
font-size: var(--field-font-size);
font-family: var(--font-fa);
color: var(--text-primary);
background-color: rgba(255, 255, 255, 0.85);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm);
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
box-sizing: border-box;
}
.field input::placeholder {
font-family: var(--font-fa);
color: var(--text-muted);
opacity: 1;
}
.field select {
appearance: none;
-webkit-appearance: none;
padding-inline-start: var(--field-padding-x);
padding-inline-end: var(--select-padding-end);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: left var(--select-arrow-offset) center;
background-size: var(--select-arrow-size);
cursor: pointer;
}
.field input:focus,
.field select:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
}
.formActions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 4px;
}
.cancelBtn {
padding: 9px 14px;
font-size: 13px;
font-weight: 600;
font-family: inherit;
color: var(--text-secondary);
background: rgba(148, 163, 184, 0.12);
border-radius: var(--radius-sm);
transition: background 0.2s;
}
.cancelBtn:hover:not(:disabled) {
background: rgba(148, 163, 184, 0.2);
}
.saveBtn {
padding: 9px 16px;
font-size: 13px;
font-weight: 700;
font-family: inherit;
color: white;
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
border-radius: var(--radius-sm);
box-shadow: 0 4px 14px rgba(var(--primary-rgb) / 0.3);
transition: opacity 0.2s;
}
.saveBtn:disabled,
.cancelBtn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.addRow {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 14px;
border: 2px dashed rgba(148, 163, 184, 0.45);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.45);
color: var(--primary);
font-size: 13px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
}
.addRow:hover {
border-color: var(--primary);
background: rgba(var(--primary-rgb) / 0.06);
}
@media (max-width: 720px) {
.fieldRow,
.fieldRowTriple {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,244 @@
import { useEffect, useState } from 'react'
import { Plus } from 'lucide-react'
import { useToast } from '@meshkee/dashboard-ui'
import { ApiError } from '../../lib/api'
import { createAddress } from '../../services/addressService'
import {
listCitiesByProvinceSlug,
listIranProvinces,
type CityOption,
} from '../../services/citiesService'
import styles from './CheckoutAddAddressPanel.module.css'
function FieldLabel({
htmlFor,
optional,
children,
}: {
htmlFor: string
optional?: boolean
children: React.ReactNode
}) {
return (
<label htmlFor={htmlFor}>
{children}
{optional ? <span className={styles.optionalMark}> (اختیاری)</span> : null}
</label>
)
}
interface CheckoutAddAddressPanelProps {
onSaved: () => void
onCancel: () => void
inModal?: boolean
}
export function CheckoutAddAddressPanel({
onSaved,
onCancel,
inModal = false,
}: CheckoutAddAddressPanelProps) {
const { showToast } = useToast()
const [provinces, setProvinces] = useState<CityOption[]>([])
const [cities, setCities] = useState<CityOption[]>([])
const [loadingLocations, setLoadingLocations] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [label, setLabel] = useState('')
const [provinceSlug, setProvinceSlug] = useState('')
const [city, setCity] = useState('')
const [address, setAddress] = useState('')
const [postalCode, setPostalCode] = useState('')
const [landline, setLandline] = useState('')
useEffect(() => {
const controller = new AbortController()
async function loadProvinces() {
setLoadingLocations(true)
try {
const items = await listIranProvinces(controller.signal)
if (!controller.signal.aborted) setProvinces(items)
} catch {
if (!controller.signal.aborted) setError('بارگذاری استان‌ها ممکن نشد.')
} finally {
if (!controller.signal.aborted) setLoadingLocations(false)
}
}
void loadProvinces()
return () => controller.abort()
}, [])
async function handleProvinceChange(slug: string) {
setProvinceSlug(slug)
setCity('')
setCities([])
if (!slug) return
try {
const items = await listCitiesByProvinceSlug(slug)
setCities(items)
} catch {
setError('بارگذاری شهرها ممکن نشد.')
}
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
const province = provinces.find((item) => item.slug === provinceSlug)
if (!label.trim() || !province || !city.trim() || !address.trim()) {
setError('لطفاً همه فیلدهای الزامی را تکمیل کنید.')
return
}
setSaving(true)
try {
await createAddress({
label: label.trim(),
province: province.nameFa || province.nameEn,
city: city.trim(),
address: address.trim(),
postalCode: postalCode.trim() || undefined,
landline: landline.trim() || undefined,
})
showToast('آدرس ذخیره شد.', 'success')
onSaved()
} catch (err) {
setError(err instanceof ApiError ? err.message : 'ذخیره آدرس ممکن نشد.')
} finally {
setSaving(false)
}
}
return (
<form
className={[styles.form, inModal ? styles.formInModal : ''].filter(Boolean).join(' ')}
onSubmit={(e) => void handleSubmit(e)}
>
{error && (
<div className={styles.error} role="alert">
{error}
</div>
)}
<div className={styles.fieldRowTriple}>
<div className={styles.field}>
<FieldLabel htmlFor="checkout-label">عنوان آدرس</FieldLabel>
<input
id="checkout-label"
type="text"
value={label}
disabled={saving}
onChange={(e) => setLabel(e.target.value)}
placeholder="مثلاً خانه، محل کار"
/>
</div>
<div className={styles.field}>
<FieldLabel htmlFor="checkout-province">استان</FieldLabel>
<select
id="checkout-province"
value={provinceSlug}
disabled={loadingLocations || saving}
onChange={(e) => void handleProvinceChange(e.target.value)}
>
<option value="">انتخاب استان</option>
{provinces.map((province) => (
<option key={province.id} value={province.slug}>
{province.nameFa}
</option>
))}
</select>
</div>
<div className={styles.field}>
<FieldLabel htmlFor="checkout-city">شهر</FieldLabel>
<select
id="checkout-city"
value={city}
disabled={!provinceSlug || saving}
onChange={(e) => setCity(e.target.value)}
>
<option value="">انتخاب شهر</option>
{cities.map((item) => (
<option key={item.id} value={item.nameFa}>
{item.nameFa}
</option>
))}
</select>
</div>
</div>
<div className={styles.field}>
<FieldLabel htmlFor="checkout-address">آدرس</FieldLabel>
<input
id="checkout-address"
type="text"
value={address}
disabled={saving}
onChange={(e) => setAddress(e.target.value)}
placeholder="خیابان، پلاک، واحد"
/>
</div>
<div className={styles.fieldRow}>
<div className={styles.field}>
<FieldLabel htmlFor="checkout-postal" optional>
کد پستی
</FieldLabel>
<input
id="checkout-postal"
type="text"
inputMode="numeric"
value={postalCode}
disabled={saving}
onChange={(e) => setPostalCode(e.target.value)}
placeholder="کد پستی"
dir="ltr"
/>
</div>
<div className={styles.field}>
<FieldLabel htmlFor="checkout-landline" optional>
تلفن ثابت
</FieldLabel>
<input
id="checkout-landline"
type="tel"
value={landline}
disabled={saving}
onChange={(e) => setLandline(e.target.value)}
placeholder="021..."
dir="ltr"
/>
</div>
</div>
<div className={styles.formActions}>
<button type="button" className={styles.cancelBtn} onClick={onCancel} disabled={saving}>
انصراف
</button>
<button type="submit" className={styles.saveBtn} disabled={saving}>
{saving ? 'در حال ذخیره...' : 'ذخیره آدرس'}
</button>
</div>
</form>
)
}
interface CheckoutAddAddressRowProps {
onClick: () => void
}
export function CheckoutAddAddressRow({ onClick }: CheckoutAddAddressRowProps) {
return (
<button type="button" className={styles.addRow} onClick={onClick}>
<Plus size={18} />
<span>افزودن آدرس جدید</span>
</button>
)
}
@@ -0,0 +1,19 @@
.backBtn {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 500;
font-family: inherit;
color: var(--text-secondary);
margin-bottom: -8px;
transition: color 0.2s;
}
.backBtn:hover {
color: var(--primary);
}
.backBtn span {
line-height: 1.4;
}
@@ -0,0 +1,16 @@
import { ArrowRight } from 'lucide-react'
import styles from './CheckoutBackButton.module.css'
interface CheckoutBackButtonProps {
label: string
onClick: () => void
}
export function CheckoutBackButton({ label, onClick }: CheckoutBackButtonProps) {
return (
<button type="button" className={styles.backBtn} onClick={onClick}>
<ArrowRight size={16} aria-hidden />
<span>{label}</span>
</button>
)
}
@@ -0,0 +1,143 @@
.checkoutPage {
min-height: 100vh;
display: flex;
flex-direction: column;
position: relative;
direction: rtl;
font-family: var(--font-fa);
/* Soften brand color in page wash — keep primary accents, less saturated bg */
background-color: #f8f6f6;
background-image:
radial-gradient(ellipse 520px 520px at calc(100% - 40px) -60px, rgba(var(--primary-rgb) / 0.1), transparent 72%),
radial-gradient(ellipse 420px 420px at 18% calc(100% + 20px), rgba(var(--primary-rgb) / 0.07), transparent 72%),
radial-gradient(ellipse 320px 320px at -40px 42%, rgba(var(--primary-rgb) / 0.05), transparent 72%),
linear-gradient(
135deg,
color-mix(in srgb, var(--primary-light) 22%, #ffffff) 0%,
color-mix(in srgb, var(--primary-light) 10%, #ffffff) 50%,
#fafafa 100%
);
background-attachment: fixed;
background-repeat: no-repeat;
background-size: cover;
}
.header {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 16px;
padding: 16px 24px;
background: var(--glass-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-bottom: 1px solid var(--glass-border);
}
.brandLink {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
text-decoration: none;
color: inherit;
transition: opacity 0.2s;
}
.brandLink:hover {
opacity: 0.85;
}
.brandText {
display: flex;
flex-direction: column;
min-width: 0;
text-align: start;
}
.brandTitle {
font-size: 15px;
font-weight: 700;
color: var(--text-primary);
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.subtitle {
font-size: 12px;
font-weight: 400;
color: var(--text-muted);
}
.logo {
width: 40px;
height: 40px;
object-fit: contain;
flex-shrink: 0;
border-radius: 8px;
background: transparent;
}
.logoFallback {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
flex-shrink: 0;
border-radius: 8px;
background: rgba(148, 163, 184, 0.18);
color: var(--text-primary);
font-size: 16px;
font-weight: 700;
line-height: 1;
}
.main {
flex: 1;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 32px 24px 48px;
}
.container {
width: 60%;
max-width: none;
min-width: 320px;
}
@media (max-width: 900px) {
.container {
width: 90%;
}
}
.card {
background: rgba(255, 255, 255, 0.75);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid var(--glass-border);
border-radius: var(--radius);
box-shadow: 0 20px 60px rgba(31, 38, 135, 0.12);
padding: 28px 28px 24px;
}
@media (max-width: 480px) {
.header {
padding: 12px 16px;
}
.main {
padding: 20px 16px 32px;
}
.card {
padding: 22px 18px 20px;
}
}
@@ -0,0 +1,71 @@
import { useEffect, useState } from 'react'
import { Outlet } from 'react-router-dom'
import { isAbortError } from '../../lib/api'
import { getTenantDomain } from '../../lib/config'
import { CheckoutProvider } from '../../context/CheckoutContext'
import { getWebsiteBusinessInfo, getWebsiteUrl } from '../../services/websiteService'
import styles from './CheckoutLayout.module.css'
export function CheckoutLayout() {
const tenantDomain = getTenantDomain()
const websiteUrl = getWebsiteUrl(tenantDomain)
const [brandName, setBrandName] = useState('')
const [logoUrl, setLogoUrl] = useState<string | null>(null)
useEffect(() => {
const controller = new AbortController()
async function loadBranding() {
try {
const info = await getWebsiteBusinessInfo(tenantDomain, controller.signal)
if (controller.signal.aborted) return
setBrandName(info.nameFa?.trim() || info.name.trim() || tenantDomain)
setLogoUrl(info.logoUrl)
} catch (err) {
if (isAbortError(err)) return
setBrandName(tenantDomain)
setLogoUrl(null)
}
}
void loadBranding()
return () => controller.abort()
}, [tenantDomain])
const displayName = brandName || tenantDomain
const initial = displayName.trim().charAt(0) || 'S'
return (
<CheckoutProvider>
<div className={styles.checkoutPage} lang="fa" dir="rtl">
<header className={styles.header}>
<a href={websiteUrl} className={styles.brandLink}>
{logoUrl ? (
<img
src={logoUrl}
alt={displayName}
className={styles.logo}
/>
) : (
<span className={styles.logoFallback} aria-hidden>
{initial.toUpperCase()}
</span>
)}
<div className={styles.brandText}>
<span className={styles.brandTitle}>{displayName}</span>
<span className={styles.subtitle}>سبد خرید</span>
</div>
</a>
</header>
<main className={styles.main}>
<div className={styles.container}>
<div className={styles.card}>
<Outlet />
</div>
</div>
</main>
</div>
</CheckoutProvider>
)
}
@@ -0,0 +1,96 @@
.stepper {
display: flex;
align-items: flex-end;
width: 100%;
margin-bottom: 24px;
padding-bottom: 20px;
border-bottom: 1px solid rgba(148, 163, 184, 0.25);
font-family: inherit;
}
.stepGroup {
display: flex;
align-items: flex-end;
flex: 1;
min-width: 0;
}
.stepGroup:last-child {
flex: 0 0 auto;
}
.stepUnit {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.stepDot {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
background: rgba(148, 163, 184, 0.2);
color: var(--text-muted);
border: 2px solid transparent;
transition: background 0.2s, color 0.2s, border-color 0.2s;
}
.stepLabel {
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
white-space: nowrap;
transition: color 0.2s;
}
.stepActive .stepDot {
background: rgba(var(--primary-rgb) / 0.15);
color: var(--primary);
border-color: var(--primary);
}
.stepActive .stepLabel {
color: var(--primary);
}
.stepDone .stepDot {
background: var(--primary);
color: white;
border-color: var(--primary);
}
.stepDone .stepLabel {
color: var(--text-secondary);
}
.connector {
flex: 1;
height: 2px;
min-width: 24px;
margin: 0 8px 13px;
background: rgba(148, 163, 184, 0.3);
border-radius: 1px;
transition: background 0.2s;
}
.connectorDone {
background: var(--primary);
}
@media (max-width: 520px) {
.stepLabel {
font-size: 11px;
}
.connector {
min-width: 12px;
margin: 0 4px 13px;
}
}
@@ -0,0 +1,62 @@
import { Check } 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: 'پرداخت' },
]
interface CheckoutStepperProps {
current: CheckoutStepId
isAuthenticated: boolean
}
export function CheckoutStepper({ current, isAuthenticated }: CheckoutStepperProps) {
const visibleSteps = isAuthenticated
? STEPS.filter((step) => step.id !== 'login')
: STEPS
const currentVisibleIdx = visibleSteps.findIndex((step) => step.id === current)
return (
<nav className={styles.stepper} aria-label="مراحل تکمیل خرید">
{visibleSteps.map((step, index) => {
const isDone = currentVisibleIdx >= 0 && index < currentVisibleIdx
const isActive = step.id === current
const connectorDone = currentVisibleIdx >= 0 && index < currentVisibleIdx
return (
<div key={step.id} className={styles.stepGroup}>
<div
className={[
styles.stepUnit,
isActive ? styles.stepActive : '',
isDone ? styles.stepDone : '',
]
.filter(Boolean)
.join(' ')}
>
<span className={styles.stepLabel}>{step.label}</span>
<span className={styles.stepDot} aria-hidden>
{isDone ? <Check size={14} /> : index + 1}
</span>
</div>
{index < visibleSteps.length - 1 && (
<div
className={[styles.connector, connectorDone ? styles.connectorDone : '']
.filter(Boolean)
.join(' ')}
aria-hidden
/>
)}
</div>
)
})}
</nav>
)
}