mirror of
https://git.meshkee.com/Meshkee/dashboards.git
synced 2026-08-11 22:30:58 +04:30
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user