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,44 @@
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.section {
|
||||
width: 100%;
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--glass-shadow);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.saveBtn {
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
background: var(--primary);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.saveBtn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
color: #b91c1c;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
AddressListEditor,
|
||||
Breadcrumbs,
|
||||
createEmptyAddressItem,
|
||||
matchCityByName,
|
||||
matchProvinceByName,
|
||||
useToast,
|
||||
type AddressListItem,
|
||||
type CityOption,
|
||||
} from '@meshkee/dashboard-ui'
|
||||
import { ApiError, isAbortError } from '../lib/api'
|
||||
import {
|
||||
createAddress,
|
||||
listAddresses,
|
||||
removeAddress,
|
||||
updateAddress,
|
||||
type UserAddress,
|
||||
type UserAddressInput,
|
||||
} from '../services/addressService'
|
||||
import {
|
||||
listCitiesByProvinceSlug,
|
||||
listIranProvinces,
|
||||
} from '../services/citiesService'
|
||||
import pageStyles from '../components/PageContent.module.css'
|
||||
import styles from './AddressesPage.module.css'
|
||||
|
||||
type AddressDraft = AddressListItem
|
||||
|
||||
function toDraft(
|
||||
item: UserAddress,
|
||||
provinces: CityOption[],
|
||||
citiesByProvince: Record<string, CityOption[]>,
|
||||
): AddressDraft {
|
||||
const province = matchProvinceByName(item.province, provinces)
|
||||
const cities = province ? (citiesByProvince[province.slug] ?? []) : []
|
||||
const city = matchCityByName(item.city, cities)
|
||||
return {
|
||||
id: item.id,
|
||||
provinceSlug: province?.slug ?? '',
|
||||
province: province?.nameEn ?? item.province,
|
||||
city: city?.nameEn ?? item.city,
|
||||
address: item.address,
|
||||
postalCode: item.postalCode ?? '',
|
||||
landline: item.landline ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
function isCompleteAddress(item: AddressDraft) {
|
||||
return item.province.trim() && item.city.trim() && item.address.trim()
|
||||
}
|
||||
|
||||
export function AddressesPage() {
|
||||
const { showToast } = useToast()
|
||||
const [provinces, setProvinces] = useState<CityOption[]>([])
|
||||
const [citiesByProvince, setCitiesByProvince] = useState<Record<string, CityOption[]>>({})
|
||||
const [addresses, setAddresses] = useState<AddressDraft[]>([createEmptyAddressItem()])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const [provinceItems, data] = await Promise.all([
|
||||
listIranProvinces(controller.signal),
|
||||
listAddresses(controller.signal),
|
||||
])
|
||||
if (controller.signal.aborted) return
|
||||
|
||||
setProvinces(provinceItems)
|
||||
|
||||
const draftItems =
|
||||
data.items.length > 0 ? data.items : []
|
||||
|
||||
const slugs = [
|
||||
...new Set(
|
||||
draftItems
|
||||
.map((item) => matchProvinceByName(item.province, provinceItems)?.slug)
|
||||
.filter(Boolean) as string[],
|
||||
),
|
||||
]
|
||||
const cityGroups = await Promise.all(
|
||||
slugs.map(async (slug) => ({
|
||||
slug,
|
||||
cities: await listCitiesByProvinceSlug(slug, controller.signal),
|
||||
})),
|
||||
)
|
||||
if (controller.signal.aborted) return
|
||||
|
||||
const citiesMap = Object.fromEntries(cityGroups.map((group) => [group.slug, group.cities]))
|
||||
setCitiesByProvince(citiesMap)
|
||||
setAddresses(
|
||||
draftItems.length > 0
|
||||
? draftItems.map((item) => toDraft(item, provinceItems, citiesMap))
|
||||
: [createEmptyAddressItem()],
|
||||
)
|
||||
} catch (err) {
|
||||
if (isAbortError(err) || controller.signal.aborted) return
|
||||
setError(err instanceof ApiError ? err.message : 'Unable to load addresses.')
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
return () => controller.abort()
|
||||
}, [])
|
||||
|
||||
function updateAddressDraft(index: number, patch: Partial<AddressDraft>) {
|
||||
setAddresses((prev) =>
|
||||
prev.map((item, i) => (i === index ? { ...item, ...patch } : item)),
|
||||
)
|
||||
}
|
||||
|
||||
async function handleProvinceChange(index: number, provinceSlug: string) {
|
||||
const province = provinces.find((item) => item.slug === provinceSlug)
|
||||
updateAddressDraft(index, {
|
||||
provinceSlug,
|
||||
province: province?.nameEn ?? '',
|
||||
city: '',
|
||||
})
|
||||
|
||||
if (provinceSlug && !citiesByProvince[provinceSlug]) {
|
||||
const cities = await listCitiesByProvinceSlug(provinceSlug)
|
||||
setCitiesByProvince((prev) => ({ ...prev, [provinceSlug]: cities }))
|
||||
}
|
||||
}
|
||||
|
||||
function addAddressRow() {
|
||||
setAddresses((prev) => [...prev, createEmptyAddressItem()])
|
||||
}
|
||||
|
||||
async function handleRemove(index: number) {
|
||||
const target = addresses[index]
|
||||
if (!target) return
|
||||
|
||||
if (!target.id) {
|
||||
setAddresses((prev) =>
|
||||
prev.length === 1 ? [createEmptyAddressItem()] : prev.filter((_, i) => i !== index),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
await removeAddress(target.id)
|
||||
setAddresses((prev) => {
|
||||
const next = prev.filter((_, i) => i !== index)
|
||||
return next.length > 0 ? next : [createEmptyAddressItem()]
|
||||
})
|
||||
showToast('Address removed.', 'success')
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'Unable to remove address.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
const payload = addresses.filter(isCompleteAddress)
|
||||
if (payload.length === 0) {
|
||||
setError('Add at least one complete address.')
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
|
||||
try {
|
||||
const saved: AddressDraft[] = []
|
||||
|
||||
for (const item of payload) {
|
||||
const input: UserAddressInput = {
|
||||
province: item.province.trim(),
|
||||
city: item.city.trim(),
|
||||
address: item.address.trim(),
|
||||
postalCode: item.postalCode.trim() || undefined,
|
||||
landline: item.landline?.trim() || undefined,
|
||||
}
|
||||
|
||||
if (item.id) {
|
||||
const result = await updateAddress(item.id, input)
|
||||
saved.push(toDraft(result.address, provinces, citiesByProvince))
|
||||
} else {
|
||||
const result = await createAddress(input)
|
||||
saved.push(toDraft(result.address, provinces, citiesByProvince))
|
||||
}
|
||||
}
|
||||
|
||||
setAddresses(saved.length > 0 ? saved : [createEmptyAddressItem()])
|
||||
showToast('Addresses saved.', 'success')
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof ApiError ? err.message : 'Unable to save addresses. Please try again.'
|
||||
setError(message)
|
||||
showToast(message, 'error')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const hasAddresses = useMemo(
|
||||
() => addresses.some((item) => isCompleteAddress(item)),
|
||||
[addresses],
|
||||
)
|
||||
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
<Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'My Addresses' }]} />
|
||||
|
||||
<div className={pageStyles.pageHeader}>
|
||||
<div>
|
||||
<h2 className={pageStyles.pageTitle}>My Addresses</h2>
|
||||
<p className={pageStyles.pageSubtitle}>
|
||||
Manage your shipping addresses for orders at this store.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className={styles.form} onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className={styles.section}>
|
||||
<AddressListEditor
|
||||
addresses={addresses}
|
||||
provinces={provinces}
|
||||
citiesByProvince={citiesByProvince}
|
||||
onAddressChange={updateAddressDraft}
|
||||
onProvinceChange={handleProvinceChange}
|
||||
onAdd={addAddressRow}
|
||||
onRemove={(index) => void handleRemove(index)}
|
||||
disabled={saving}
|
||||
loading={loading}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<button type="submit" className={styles.saveBtn} disabled={saving || loading || !hasAddresses}>
|
||||
{saving ? 'Saving...' : 'Save addresses'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
.error {
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 14px;
|
||||
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);
|
||||
}
|
||||
|
||||
.status {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.pagination button {
|
||||
height: var(--field-height);
|
||||
padding: 0 1rem;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pagination button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.grid {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.grid {
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Heart } from 'lucide-react'
|
||||
import { Breadcrumbs, useToast } from '@meshkee/dashboard-ui'
|
||||
import { FavoriteStoreItemCard } from '../components/FavoriteStoreItemCard'
|
||||
import { ApiError, isAbortError } from '../lib/api'
|
||||
import {
|
||||
listFavorites,
|
||||
removeFavorite,
|
||||
type FavoriteListing,
|
||||
type FavoritesListResponse,
|
||||
} from '../services/favoritesService'
|
||||
import pageStyles from '../components/PageContent.module.css'
|
||||
import styles from './FavoritesPage.module.css'
|
||||
|
||||
const PAGE_SIZE = 24
|
||||
|
||||
export function FavoritesPage() {
|
||||
const { showToast } = useToast()
|
||||
const [data, setData] = useState<FavoritesListResponse | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [removingId, setRemovingId] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const response = await listFavorites({ page, pageSize: PAGE_SIZE }, controller.signal)
|
||||
if (controller.signal.aborted) return
|
||||
setData(response)
|
||||
} catch (err) {
|
||||
if (isAbortError(err) || controller.signal.aborted) return
|
||||
setError(err instanceof ApiError ? err.message : 'Unable to load favorites.')
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
return () => controller.abort()
|
||||
}, [page])
|
||||
|
||||
async function handleRemove(listing: FavoriteListing) {
|
||||
setRemovingId(listing.favoriteId)
|
||||
try {
|
||||
await removeFavorite(listing.productId)
|
||||
setData((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
items: prev.items.filter((item) => item.favoriteId !== listing.favoriteId),
|
||||
total: Math.max(0, prev.total - 1),
|
||||
}
|
||||
: prev,
|
||||
)
|
||||
showToast('Removed from favorites.', 'success')
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof ApiError ? err.message : 'Unable to remove favorite.'
|
||||
showToast(message, 'error')
|
||||
} finally {
|
||||
setRemovingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
function handleAddToCart() {
|
||||
showToast('Shopping cart is coming soon.', 'success')
|
||||
}
|
||||
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
<Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'My Favorites' }]} />
|
||||
|
||||
<div className={pageStyles.pageHeader}>
|
||||
<div>
|
||||
<h2 className={pageStyles.pageTitle}>My Favorites</h2>
|
||||
<p className={pageStyles.pageSubtitle}>Products you have saved for later.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && <p className={styles.status}>Loading favorites...</p>}
|
||||
|
||||
{!loading && data?.items.length === 0 && (
|
||||
<div className={styles.empty}>
|
||||
<Heart size={32} />
|
||||
<p>No favorites yet.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!!data?.items.length && (
|
||||
<div className={styles.grid}>
|
||||
{data.items.map((listing) => (
|
||||
<FavoriteStoreItemCard
|
||||
key={listing.favoriteId}
|
||||
listing={listing}
|
||||
onRemove={handleRemove}
|
||||
onAddToCart={handleAddToCart}
|
||||
removing={removingId === listing.favoriteId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && data.total > PAGE_SIZE && (
|
||||
<div className={styles.pagination}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span>
|
||||
Page {page} of {Math.max(1, Math.ceil(data.total / data.pageSize))}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={page >= Math.ceil(data.total / data.pageSize)}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { CalendarDays, User, MapPin, ShoppingBag, Heart } from 'lucide-react'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { SectionCard } from '@meshkee/dashboard-ui'
|
||||
import styles from '../components/PageContent.module.css'
|
||||
|
||||
const sections = [
|
||||
{
|
||||
icon: User,
|
||||
title: 'My Profile',
|
||||
description: 'View and update your personal information and contact details.',
|
||||
linkText: 'View profile',
|
||||
href: '/profile',
|
||||
},
|
||||
{
|
||||
icon: MapPin,
|
||||
title: 'My Addresses',
|
||||
description: 'Manage your shipping addresses for checkout and deliveries.',
|
||||
linkText: 'View addresses',
|
||||
href: '/addresses',
|
||||
},
|
||||
{
|
||||
icon: ShoppingBag,
|
||||
title: 'My Orders',
|
||||
description: 'Track your orders, view order history and order details.',
|
||||
linkText: 'View orders',
|
||||
href: '/orders',
|
||||
},
|
||||
{
|
||||
icon: Heart,
|
||||
title: 'My Favorites',
|
||||
description: 'Browse and manage your saved favorite products.',
|
||||
linkText: 'View favorites',
|
||||
href: '/favorites',
|
||||
},
|
||||
]
|
||||
|
||||
function getFormattedDate() {
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
weekday: 'long',
|
||||
}).format(new Date())
|
||||
}
|
||||
|
||||
export function HomePage() {
|
||||
const { user } = useAuth()
|
||||
const firstName = user?.firstName || 'there'
|
||||
|
||||
return (
|
||||
<main className={styles.content}>
|
||||
<div className={styles.pageHeader}>
|
||||
<div>
|
||||
<h2 className={styles.pageTitle}>
|
||||
Welcome back, {firstName}! <span aria-hidden="true">👋</span>
|
||||
</h2>
|
||||
<p className={styles.pageSubtitle}>
|
||||
Manage your profile, addresses, orders, and favorites in one place.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.dateBadge}>
|
||||
<CalendarDays size={16} />
|
||||
<span>{getFormattedDate()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.gridHome}>
|
||||
{sections.map((section) => (
|
||||
<SectionCard key={section.title} {...section} />
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
padding: 36px 32px 32px;
|
||||
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);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: block;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
object-fit: contain;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.brandText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.domain {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.appName {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
text-align: center;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.backBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 16px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.backBtn:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.field label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.inputWrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.inputIcon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
color: var(--text-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.inputWrap input {
|
||||
width: 100%;
|
||||
min-height: var(--field-height);
|
||||
padding: var(--field-padding-y) 40px var(--field-padding-y) 38px;
|
||||
font-size: var(--field-font-size);
|
||||
line-height: 1.4;
|
||||
font-family: inherit;
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
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;
|
||||
}
|
||||
|
||||
.inputWrap input:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
|
||||
}
|
||||
|
||||
.togglePassword {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
padding: 4px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.togglePassword:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.formActions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: -8px;
|
||||
}
|
||||
|
||||
.linkBtn {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.linkBtn:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.submitBtn {
|
||||
width: 100%;
|
||||
padding: 13px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
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.35);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.submitBtn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 18px rgba(var(--primary-rgb) / 0.4);
|
||||
}
|
||||
|
||||
.secondaryBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
background: rgba(var(--primary-rgb) / 0.08);
|
||||
border: 1px solid rgba(var(--primary-rgb) / 0.25);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.secondaryBtn:hover {
|
||||
background: rgba(var(--primary-rgb) / 0.14);
|
||||
}
|
||||
|
||||
.divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 20px 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.divider::before,
|
||||
.divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: rgba(148, 163, 184, 0.3);
|
||||
}
|
||||
|
||||
.footerText {
|
||||
margin-top: 24px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.codeHint {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
padding: 10px 12px;
|
||||
background: rgba(var(--primary-rgb) / 0.06);
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.codeHint strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.resendRow {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
.countdown {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.info {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
background: rgba(var(--primary-rgb) / 0.08);
|
||||
border: 1px solid rgba(var(--primary-rgb) / 0.2);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.domainHint {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin: -12px 0 20px;
|
||||
}
|
||||
|
||||
.fieldRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.submitBtn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.card {
|
||||
padding: 28px 20px 24px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 22px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,720 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { Eye, EyeOff, Smartphone, Lock, KeyRound, ArrowLeft, User } from 'lucide-react'
|
||||
import {
|
||||
useAuth,
|
||||
CUSTOMER_ACCESS_MESSAGE,
|
||||
VERIFICATION_REQUIRED_MESSAGE,
|
||||
} from '../context/AuthContext'
|
||||
import { ApiError } from '../lib/api'
|
||||
import { toE164CellNumber } from '../lib/cellNumber'
|
||||
import { getTenantDomain } from '../lib/config'
|
||||
import {
|
||||
logout as logoutRequest,
|
||||
register,
|
||||
sendOtp,
|
||||
verifyOtp,
|
||||
} from '../services/authService'
|
||||
import meshkeeLogo from '../assets/meshkee-logo.png'
|
||||
import styles from './LoginPage.module.css'
|
||||
|
||||
type AuthView = 'login' | 'signup' | 'forgot' | 'otp'
|
||||
type SmsStep = 'phone' | 'code'
|
||||
|
||||
function safeRedirectPath(value: string | null) {
|
||||
if (!value || !value.startsWith('/') || value.startsWith('//')) {
|
||||
return '/'
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate()
|
||||
const [searchParams] = useSearchParams()
|
||||
const redirectTo = safeRedirectPath(searchParams.get('redirect'))
|
||||
const { login } = useAuth()
|
||||
const tenantDomain = getTenantDomain()
|
||||
|
||||
const [view, setView] = useState<AuthView>('login')
|
||||
const [smsStep, setSmsStep] = useState<SmsStep>('phone')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [phone, setPhone] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [firstName, setFirstName] = useState('')
|
||||
const [lastName, setLastName] = useState('')
|
||||
const [smsCode, setSmsCode] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [codeSent, setCodeSent] = useState(false)
|
||||
const [countdown, setCountdown] = useState(0)
|
||||
const [error, setError] = useState('')
|
||||
const [info, setInfo] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
function clearMessages() {
|
||||
setError('')
|
||||
setInfo('')
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
setPhone('')
|
||||
setPassword('')
|
||||
setConfirmPassword('')
|
||||
setFirstName('')
|
||||
setLastName('')
|
||||
setSmsCode('')
|
||||
setNewPassword('')
|
||||
setSmsStep('phone')
|
||||
setCodeSent(false)
|
||||
setShowPassword(false)
|
||||
clearMessages()
|
||||
}
|
||||
|
||||
function switchView(next: AuthView) {
|
||||
resetForm()
|
||||
setView(next)
|
||||
}
|
||||
|
||||
function startCountdown() {
|
||||
setCountdown(60)
|
||||
const timer = setInterval(() => {
|
||||
setCountdown((prev) => {
|
||||
if (prev <= 1) {
|
||||
clearInterval(timer)
|
||||
return 0
|
||||
}
|
||||
return prev - 1
|
||||
})
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
function handleApiError(err: unknown, fallback: string) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError(fallback)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSendCode() {
|
||||
clearMessages()
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
const cellNumber = toE164CellNumber(phone)
|
||||
const result = await sendOtp(cellNumber)
|
||||
|
||||
if (!result.enabled) {
|
||||
setInfo(result.message)
|
||||
}
|
||||
|
||||
setCodeSent(true)
|
||||
setSmsStep('code')
|
||||
startCountdown()
|
||||
} catch (err) {
|
||||
handleApiError(err, 'Unable to send verification code.')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogin(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
clearMessages()
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
const cellNumber = toE164CellNumber(phone)
|
||||
await login(cellNumber, password)
|
||||
navigate(redirectTo)
|
||||
} catch (err) {
|
||||
handleApiError(err, 'Unable to sign in. Check your connection and try again.')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSignup(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
clearMessages()
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match.')
|
||||
return
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
setError('Password must be at least 8 characters.')
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
const cellNumber = toE164CellNumber(phone)
|
||||
const data = await register({
|
||||
cellNumber,
|
||||
password,
|
||||
firstName: firstName.trim(),
|
||||
lastName: lastName.trim(),
|
||||
domain: tenantDomain,
|
||||
})
|
||||
|
||||
if (data.user.customerBusinesses.length === 0) {
|
||||
logoutRequest()
|
||||
setError(CUSTOMER_ACCESS_MESSAGE)
|
||||
return
|
||||
}
|
||||
|
||||
if (!data.user.cellVerifiedAt) {
|
||||
setInfo(
|
||||
`${VERIFICATION_REQUIRED_MESSAGE} Use one-time login with SMS to verify your number.`,
|
||||
)
|
||||
switchView('otp')
|
||||
setPhone(phone)
|
||||
return
|
||||
}
|
||||
|
||||
await login(cellNumber, password)
|
||||
navigate(redirectTo)
|
||||
} catch (err) {
|
||||
handleApiError(err, 'Unable to create account.')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResetPassword(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
clearMessages()
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
setError('Password must be at least 8 characters.')
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
const cellNumber = toE164CellNumber(phone)
|
||||
await verifyOtp(cellNumber, smsCode)
|
||||
setInfo(
|
||||
'Phone number verified. Full password reset via SMS is not available yet — please contact support or sign in if you remember your password.',
|
||||
)
|
||||
setTimeout(() => switchView('login'), 2500)
|
||||
} catch (err) {
|
||||
handleApiError(err, 'Unable to verify code.')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOtpLogin(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
clearMessages()
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
const cellNumber = toE164CellNumber(phone)
|
||||
await verifyOtp(cellNumber, smsCode)
|
||||
|
||||
if (!password) {
|
||||
setError('Enter your account password to complete sign-in after SMS verification.')
|
||||
return
|
||||
}
|
||||
|
||||
await login(cellNumber, password)
|
||||
navigate(redirectTo)
|
||||
} catch (err) {
|
||||
handleApiError(err, 'Unable to sign in with SMS verification.')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.card}>
|
||||
<div className={styles.brand}>
|
||||
<img src={meshkeeLogo} alt="Meshkee" className={styles.logo} />
|
||||
<div className={styles.brandText}>
|
||||
<span className={styles.domain}>{tenantDomain}</span>
|
||||
<span className={styles.appName}>Customer Dashboard</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className={styles.domainHint}>Store domain: {tenantDomain}</p>
|
||||
|
||||
{view === 'login' && (
|
||||
<>
|
||||
<h1 className={styles.title}>Welcome back</h1>
|
||||
<p className={styles.subtitle}>Sign in with your mobile number</p>
|
||||
|
||||
<form className={styles.form} onSubmit={handleLogin}>
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{info && <div className={styles.info}>{info}</div>}
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="login-phone">Mobile number</label>
|
||||
<div className={styles.inputWrap}>
|
||||
<Smartphone size={18} className={styles.inputIcon} />
|
||||
<input
|
||||
id="login-phone"
|
||||
type="tel"
|
||||
inputMode="tel"
|
||||
placeholder="09122222222"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="login-password">Password</label>
|
||||
<div className={styles.inputWrap}>
|
||||
<Lock size={18} className={styles.inputIcon} />
|
||||
<input
|
||||
id="login-password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="Enter your password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.togglePassword}
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.formActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.linkBtn}
|
||||
onClick={() => switchView('forgot')}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Forgot password?
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className={styles.divider}>
|
||||
<span>or</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={styles.secondaryBtn}
|
||||
onClick={() => switchView('otp')}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<KeyRound size={18} />
|
||||
One-time login with SMS
|
||||
</button>
|
||||
|
||||
<p className={styles.footerText}>
|
||||
Don't have an account?{' '}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.linkBtn}
|
||||
onClick={() => switchView('signup')}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Sign up
|
||||
</button>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{view === 'signup' && (
|
||||
<>
|
||||
<h1 className={styles.title}>Create account</h1>
|
||||
<p className={styles.subtitle}>Register as a customer of {tenantDomain}</p>
|
||||
|
||||
<form className={styles.form} onSubmit={handleSignup}>
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{info && <div className={styles.info}>{info}</div>}
|
||||
|
||||
<div className={styles.fieldRow}>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="signup-first">First name</label>
|
||||
<div className={styles.inputWrap}>
|
||||
<User size={18} className={styles.inputIcon} />
|
||||
<input
|
||||
id="signup-first"
|
||||
type="text"
|
||||
placeholder="First name"
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
required
|
||||
minLength={2}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="signup-last">Last name</label>
|
||||
<div className={styles.inputWrap}>
|
||||
<User size={18} className={styles.inputIcon} />
|
||||
<input
|
||||
id="signup-last"
|
||||
type="text"
|
||||
placeholder="Last name"
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
required
|
||||
minLength={2}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="signup-phone">Mobile number</label>
|
||||
<div className={styles.inputWrap}>
|
||||
<Smartphone size={18} className={styles.inputIcon} />
|
||||
<input
|
||||
id="signup-phone"
|
||||
type="tel"
|
||||
inputMode="tel"
|
||||
placeholder="09123456789"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="signup-password">Password</label>
|
||||
<div className={styles.inputWrap}>
|
||||
<Lock size={18} className={styles.inputIcon} />
|
||||
<input
|
||||
id="signup-password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="Choose a password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.togglePassword}
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="signup-confirm">Confirm password</label>
|
||||
<div className={styles.inputWrap}>
|
||||
<Lock size={18} className={styles.inputIcon} />
|
||||
<input
|
||||
id="signup-confirm"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="Repeat your password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Creating account...' : 'Create account'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className={styles.footerText}>
|
||||
Already have an account?{' '}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.linkBtn}
|
||||
onClick={() => switchView('login')}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{view === 'forgot' && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.backBtn}
|
||||
onClick={() => switchView('login')}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
Back to sign in
|
||||
</button>
|
||||
|
||||
<h1 className={styles.title}>Forgot password</h1>
|
||||
<p className={styles.subtitle}>
|
||||
{smsStep === 'phone'
|
||||
? 'We will send a verification code via SMS'
|
||||
: 'Enter the code and your new password'}
|
||||
</p>
|
||||
|
||||
<form className={styles.form} onSubmit={handleResetPassword}>
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{info && <div className={styles.info}>{info}</div>}
|
||||
|
||||
{smsStep === 'phone' ? (
|
||||
<>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="forgot-phone">Mobile number</label>
|
||||
<div className={styles.inputWrap}>
|
||||
<Smartphone size={18} className={styles.inputIcon} />
|
||||
<input
|
||||
id="forgot-phone"
|
||||
type="tel"
|
||||
inputMode="tel"
|
||||
placeholder="09122222222"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={styles.submitBtn}
|
||||
onClick={() => void handleSendCode()}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? 'Sending...' : 'Send SMS code'}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{codeSent && (
|
||||
<p className={styles.codeHint}>
|
||||
Verification code sent to <strong>{phone}</strong>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="forgot-code">SMS verification code</label>
|
||||
<div className={styles.inputWrap}>
|
||||
<KeyRound size={18} className={styles.inputIcon} />
|
||||
<input
|
||||
id="forgot-code"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
placeholder="123456"
|
||||
maxLength={6}
|
||||
value={smsCode}
|
||||
onChange={(e) => setSmsCode(e.target.value.replace(/\D/g, ''))}
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="forgot-new-password">New password</label>
|
||||
<div className={styles.inputWrap}>
|
||||
<Lock size={18} className={styles.inputIcon} />
|
||||
<input
|
||||
id="forgot-new-password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="Enter new password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.resendRow}>
|
||||
{countdown > 0 ? (
|
||||
<span className={styles.countdown}>Resend code in {countdown}s</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.linkBtn}
|
||||
onClick={() => void handleSendCode()}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Resend SMS code
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Verifying...' : 'Reset password'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
|
||||
{view === 'otp' && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.backBtn}
|
||||
onClick={() => switchView('login')}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
Back to sign in
|
||||
</button>
|
||||
|
||||
<h1 className={styles.title}>One-time login</h1>
|
||||
<p className={styles.subtitle}>
|
||||
{smsStep === 'phone'
|
||||
? 'Verify your mobile number with a one-time SMS code'
|
||||
: 'Enter the SMS code and your password'}
|
||||
</p>
|
||||
|
||||
<form className={styles.form} onSubmit={handleOtpLogin}>
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{smsStep === 'phone' ? (
|
||||
<>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="otp-phone">Mobile number</label>
|
||||
<div className={styles.inputWrap}>
|
||||
<Smartphone size={18} className={styles.inputIcon} />
|
||||
<input
|
||||
id="otp-phone"
|
||||
type="tel"
|
||||
inputMode="tel"
|
||||
placeholder="09122222222"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={styles.submitBtn}
|
||||
onClick={() => void handleSendCode()}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? 'Sending...' : 'Send SMS code'}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{codeSent && (
|
||||
<p className={styles.codeHint}>
|
||||
Verification code sent to <strong>{phone}</strong>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="otp-code">SMS verification code</label>
|
||||
<div className={styles.inputWrap}>
|
||||
<KeyRound size={18} className={styles.inputIcon} />
|
||||
<input
|
||||
id="otp-code"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
placeholder="123456"
|
||||
maxLength={6}
|
||||
value={smsCode}
|
||||
onChange={(e) => setSmsCode(e.target.value.replace(/\D/g, ''))}
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="otp-password">Password</label>
|
||||
<div className={styles.inputWrap}>
|
||||
<Lock size={18} className={styles.inputIcon} />
|
||||
<input
|
||||
id="otp-password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="Your account password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.resendRow}>
|
||||
{countdown > 0 ? (
|
||||
<span className={styles.countdown}>Resend code in {countdown}s</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.linkBtn}
|
||||
onClick={() => void handleSendCode()}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Resend SMS code
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
.tablePanel {
|
||||
margin-top: 12px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tableWrap {
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--glass-shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tableHeader {
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
|
||||
}
|
||||
|
||||
.tableHeaderTitle {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.meta {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.colOrderId {
|
||||
width: 18%;
|
||||
}
|
||||
|
||||
.colItems {
|
||||
width: 10%;
|
||||
}
|
||||
|
||||
.colTotal {
|
||||
width: 14%;
|
||||
}
|
||||
|
||||
.colDate {
|
||||
width: 16%;
|
||||
}
|
||||
|
||||
.colStep {
|
||||
width: 18%;
|
||||
}
|
||||
|
||||
.colSource {
|
||||
width: 14%;
|
||||
}
|
||||
|
||||
.colActions {
|
||||
width: 72px;
|
||||
}
|
||||
|
||||
.th,
|
||||
.td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.16);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.th {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.td {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.orderNumber {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.customerName {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.customerCell {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.subText {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dateCell {
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.dateTime {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.dateTimeSub {
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.sourceBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sourceOperator {
|
||||
color: var(--primary-dark, var(--primary));
|
||||
background: rgba(var(--primary-rgb) / 0.12);
|
||||
}
|
||||
|
||||
.sourceWebsite {
|
||||
color: #047857;
|
||||
background: rgba(16, 185, 129, 0.12);
|
||||
}
|
||||
|
||||
.sourceApplication {
|
||||
color: #7c3aed;
|
||||
background: rgba(139, 92, 246, 0.12);
|
||||
}
|
||||
|
||||
.stepBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
transition: filter 0.2s;
|
||||
}
|
||||
|
||||
.stepBadge:hover {
|
||||
filter: brightness(0.95);
|
||||
}
|
||||
|
||||
.ordersFiltersRow {
|
||||
grid-column: span 10;
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.filterOrderId {
|
||||
flex: 0 1 156px;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.filterCustomer {
|
||||
flex: 0 1 168px;
|
||||
min-width: 132px;
|
||||
}
|
||||
|
||||
.filterDate {
|
||||
flex: 0 1 148px;
|
||||
min-width: 132px;
|
||||
}
|
||||
|
||||
.filterCost {
|
||||
flex: 0 1 124px;
|
||||
min-width: 108px;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.ordersFiltersRow {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filterOrderId,
|
||||
.filterCustomer,
|
||||
.filterDate,
|
||||
.filterCost {
|
||||
flex: 1 1 140px;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
.thActions {
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tdActions {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.rowActions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.actionBtn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
color: var(--text-secondary);
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.actionBtn:hover:not(:disabled) {
|
||||
background: rgba(var(--primary-rgb) / 0.1);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.actionBtnDanger:hover:not(:disabled) {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.actionBtn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pagerBtns {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pageBtn {
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
color: var(--text-secondary);
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pageBtn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pageBtnActive {
|
||||
border-color: rgba(var(--primary-dark-rgb) / 0.35);
|
||||
color: var(--primary);
|
||||
background: rgba(var(--primary-dark-rgb) / 0.08);
|
||||
}
|
||||
|
||||
.errorBanner {
|
||||
padding: 16px;
|
||||
color: #b91c1c;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Breadcrumbs } from '@meshkee/dashboard-ui'
|
||||
import { OrderItemsModal } from '../components/OrderItemsModal'
|
||||
import { OrderRow } from '../components/OrderRow'
|
||||
import { ApiError, isAbortError } from '../lib/api'
|
||||
import {
|
||||
listOrders,
|
||||
type Order,
|
||||
type OrdersListResponse,
|
||||
} from '../services/orderService'
|
||||
import { DEFAULT_ORDER_PROCESS_STEPS } from '../utils/orderSteps'
|
||||
import pageStyles from '../components/PageContent.module.css'
|
||||
import styles from './OrdersPage.module.css'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
const COLUMN_COUNT = 7
|
||||
|
||||
function buildPageNumbers(page: number, totalPages: number) {
|
||||
const start = Math.max(1, page - 2)
|
||||
const end = Math.min(totalPages, page + 2)
|
||||
const numbers: number[] = []
|
||||
for (let i = start; i <= end; i++) numbers.push(i)
|
||||
return numbers
|
||||
}
|
||||
|
||||
export function OrdersPage() {
|
||||
const [data, setData] = useState<OrdersListResponse | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [viewOrder, setViewOrder] = useState<Order | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const response = await listOrders({ page, pageSize: PAGE_SIZE }, controller.signal)
|
||||
if (controller.signal.aborted) return
|
||||
setData(response)
|
||||
} catch (err) {
|
||||
if (isAbortError(err) || controller.signal.aborted) return
|
||||
setError(err instanceof ApiError ? err.message : 'Unable to load orders.')
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
return () => controller.abort()
|
||||
}, [page])
|
||||
|
||||
const totalPages = useMemo(() => {
|
||||
const total = data?.total ?? 0
|
||||
return Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||
}, [data?.total])
|
||||
|
||||
const pageNumbers = useMemo(() => buildPageNumbers(page, totalPages), [page, totalPages])
|
||||
|
||||
const showingFrom = useMemo(() => {
|
||||
if (!data || data.total === 0) return 0
|
||||
return (page - 1) * PAGE_SIZE + 1
|
||||
}, [data, page])
|
||||
|
||||
const showingTo = useMemo(() => {
|
||||
if (!data) return 0
|
||||
return Math.min(data.total, page * PAGE_SIZE)
|
||||
}, [data, page])
|
||||
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
<Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'My Orders' }]} />
|
||||
|
||||
<div className={pageStyles.pageHeader}>
|
||||
<div>
|
||||
<h2 className={pageStyles.pageTitle}>My Orders</h2>
|
||||
<p className={pageStyles.pageSubtitle}>View your order history and details.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.tablePanel}>
|
||||
<div className={styles.tableWrap}>
|
||||
<div className={styles.tableHeader}>
|
||||
<div className={styles.tableHeaderTitle}>Order list</div>
|
||||
<div className={styles.meta}>
|
||||
{data ? (
|
||||
data.total > 0 ? (
|
||||
<>
|
||||
Showing {showingFrom} - {showingTo} of {data.total}
|
||||
</>
|
||||
) : (
|
||||
'No orders'
|
||||
)
|
||||
) : (
|
||||
' '
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className={styles.errorBanner}>{error}</div>}
|
||||
|
||||
<table className={styles.table}>
|
||||
<colgroup>
|
||||
<col className={styles.colOrderId} />
|
||||
<col className={styles.colItems} />
|
||||
<col className={styles.colTotal} />
|
||||
<col className={styles.colDate} />
|
||||
<col className={styles.colStep} />
|
||||
<col className={styles.colSource} />
|
||||
<col className={styles.colActions} />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={styles.th}>Order ID</th>
|
||||
<th className={styles.th}>Items</th>
|
||||
<th className={styles.th}>Total cost</th>
|
||||
<th className={styles.th}>Date & time</th>
|
||||
<th className={styles.th}>Step</th>
|
||||
<th className={styles.th}>Registered by</th>
|
||||
<th className={`${styles.th} ${styles.thActions}`}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && (
|
||||
<tr>
|
||||
<td className={styles.td} colSpan={COLUMN_COUNT}>
|
||||
Loading orders...
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{!loading && data?.items.length === 0 && (
|
||||
<tr>
|
||||
<td className={styles.td} colSpan={COLUMN_COUNT}>
|
||||
You have no orders yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{!loading &&
|
||||
data?.items.map((order) => (
|
||||
<OrderRow
|
||||
key={order.id}
|
||||
order={order}
|
||||
processSteps={DEFAULT_ORDER_PROCESS_STEPS}
|
||||
onViewItems={setViewOrder}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{data && data.total > PAGE_SIZE && (
|
||||
<div className={styles.pagination}>
|
||||
<div>
|
||||
Page {page} / {totalPages}
|
||||
</div>
|
||||
<div className={styles.pagerBtns}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.pageBtn}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page <= 1 || loading}
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
{pageNumbers.map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
className={`${styles.pageBtn} ${n === page ? styles.pageBtnActive : ''}`}
|
||||
onClick={() => setPage(n)}
|
||||
disabled={loading}
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.pageBtn}
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page >= totalPages || loading}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<OrderItemsModal
|
||||
open={viewOrder !== null}
|
||||
order={viewOrder}
|
||||
onClose={() => setViewOrder(null)}
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 1.25rem;
|
||||
border-radius: 1rem;
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: var(--glass-shadow);
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.col3 {
|
||||
grid-column: span 6;
|
||||
}
|
||||
|
||||
.col4 {
|
||||
grid-column: span 6;
|
||||
}
|
||||
|
||||
.col12 {
|
||||
grid-column: span 12;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.field label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field textarea {
|
||||
height: var(--field-height);
|
||||
padding: 0 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--surface);
|
||||
font-size: var(--field-font-size);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.field textarea {
|
||||
height: auto;
|
||||
padding: 0.65rem 0.75rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.field input:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.saveBtn {
|
||||
height: var(--field-height);
|
||||
padding: 0 1.25rem;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
font-size: var(--field-font-size);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.saveBtn:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #b91c1c;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.col3 {
|
||||
grid-column: span 3;
|
||||
}
|
||||
|
||||
.col4 {
|
||||
grid-column: span 4;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.col3,
|
||||
.col4,
|
||||
.col12 {
|
||||
grid-column: span 12;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Breadcrumbs } from '@meshkee/dashboard-ui'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useToast } from '@meshkee/dashboard-ui'
|
||||
import { ApiError } from '../lib/api'
|
||||
import { formatCellForDisplay } from '../lib/cellNumber'
|
||||
import { updateProfile } from '../services/authService'
|
||||
import pageStyles from '../components/PageContent.module.css'
|
||||
import styles from './ProfilePage.module.css'
|
||||
|
||||
export function ProfilePage() {
|
||||
const { user, setUser } = useAuth()
|
||||
const { showToast } = useToast()
|
||||
|
||||
const [firstName, setFirstName] = useState('')
|
||||
const [lastName, setLastName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [landline, setLandline] = useState('')
|
||||
const [backupPhone, setBackupPhone] = useState('')
|
||||
const [about, setAbout] = useState('')
|
||||
const [instagram, setInstagram] = useState('')
|
||||
const [telegramId, setTelegramId] = useState('')
|
||||
const [linkedin, setLinkedin] = useState('')
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return
|
||||
|
||||
setFirstName(user.firstName ?? '')
|
||||
setLastName(user.lastName ?? '')
|
||||
setEmail(user.email ?? '')
|
||||
setLandline(user.profile.landline ?? '')
|
||||
setBackupPhone(user.profile.backupPhone ?? '')
|
||||
setAbout(user.profile.about ?? '')
|
||||
setInstagram(user.profile.instagram ?? '')
|
||||
setTelegramId(user.profile.telegramId ?? '')
|
||||
setLinkedin(user.profile.linkedin ?? '')
|
||||
}, [user])
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setIsSaving(true)
|
||||
|
||||
try {
|
||||
const result = await updateProfile({
|
||||
firstName: firstName.trim(),
|
||||
lastName: lastName.trim(),
|
||||
email: email.trim() || undefined,
|
||||
landline: landline.trim(),
|
||||
backupPhone: backupPhone.trim(),
|
||||
about: about.trim(),
|
||||
instagram: instagram.trim(),
|
||||
telegramId: telegramId.trim(),
|
||||
linkedin: linkedin.trim(),
|
||||
})
|
||||
|
||||
setUser(result.user)
|
||||
showToast('Profile updated successfully.', 'success')
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof ApiError ? err.message : 'Unable to update profile. Please try again.'
|
||||
setError(message)
|
||||
showToast(message, 'error')
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
<Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'My Profile' }]} />
|
||||
|
||||
<div className={pageStyles.pageHeader}>
|
||||
<div>
|
||||
<h2 className={pageStyles.pageTitle}>My Profile</h2>
|
||||
<p className={pageStyles.pageSubtitle}>
|
||||
Update your personal information and contact details.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className={styles.form} onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className={styles.section}>
|
||||
<h3 className={styles.sectionTitle}>Account</h3>
|
||||
<div className={styles.grid}>
|
||||
<div className={`${styles.field} ${styles.col3}`}>
|
||||
<label htmlFor="profile-cell">Mobile number</label>
|
||||
<input
|
||||
id="profile-cell"
|
||||
type="text"
|
||||
value={user ? formatCellForDisplay(user.cellNumber) : ''}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
<div className={`${styles.field} ${styles.col3}`}>
|
||||
<label htmlFor="profile-email">Email</label>
|
||||
<input
|
||||
id="profile-email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div className={`${styles.field} ${styles.col3}`}>
|
||||
<label htmlFor="profile-first">First name</label>
|
||||
<input
|
||||
id="profile-first"
|
||||
type="text"
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className={`${styles.field} ${styles.col3}`}>
|
||||
<label htmlFor="profile-last">Last name</label>
|
||||
<input
|
||||
id="profile-last"
|
||||
type="text"
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className={`${styles.field} ${styles.col3}`}>
|
||||
<label htmlFor="profile-landline">Landline</label>
|
||||
<input
|
||||
id="profile-landline"
|
||||
type="text"
|
||||
value={landline}
|
||||
onChange={(e) => setLandline(e.target.value)}
|
||||
placeholder="02112345678"
|
||||
/>
|
||||
</div>
|
||||
<div className={`${styles.field} ${styles.col3}`}>
|
||||
<label htmlFor="profile-backup-phone">Backup phone number</label>
|
||||
<input
|
||||
id="profile-backup-phone"
|
||||
type="tel"
|
||||
inputMode="tel"
|
||||
value={backupPhone}
|
||||
onChange={(e) => setBackupPhone(e.target.value)}
|
||||
placeholder="09123456789"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={styles.section}>
|
||||
<h3 className={styles.sectionTitle}>About</h3>
|
||||
<div className={styles.grid}>
|
||||
<div className={`${styles.field} ${styles.col12}`}>
|
||||
<label htmlFor="profile-about">About</label>
|
||||
<textarea
|
||||
id="profile-about"
|
||||
value={about}
|
||||
onChange={(e) => setAbout(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={styles.section}>
|
||||
<h3 className={styles.sectionTitle}>Social</h3>
|
||||
<div className={styles.grid}>
|
||||
<div className={`${styles.field} ${styles.col4}`}>
|
||||
<label htmlFor="profile-instagram">Instagram</label>
|
||||
<input
|
||||
id="profile-instagram"
|
||||
type="text"
|
||||
value={instagram}
|
||||
onChange={(e) => setInstagram(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className={`${styles.field} ${styles.col4}`}>
|
||||
<label htmlFor="profile-telegram">Telegram</label>
|
||||
<input
|
||||
id="profile-telegram"
|
||||
type="text"
|
||||
value={telegramId}
|
||||
onChange={(e) => setTelegramId(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className={`${styles.field} ${styles.col4}`}>
|
||||
<label htmlFor="profile-linkedin">LinkedIn</label>
|
||||
<input
|
||||
id="profile-linkedin"
|
||||
type="text"
|
||||
value={linkedin}
|
||||
onChange={(e) => setLinkedin(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<button type="submit" className={styles.saveBtn} disabled={isSaving}>
|
||||
{isSaving ? 'Saving...' : 'Save changes'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Minus, Package, Plus, ShoppingBag, Trash2 } from 'lucide-react'
|
||||
import { useToast } from '@meshkee/dashboard-ui'
|
||||
import { useAuth } from '../../context/AuthContext'
|
||||
import { ApiError, isAbortError } from '../../lib/api'
|
||||
import {
|
||||
loadGuestCart,
|
||||
saveGuestCart,
|
||||
type GuestCartItem,
|
||||
} from '../../lib/guestCart'
|
||||
import {
|
||||
getCart,
|
||||
removeCartItem,
|
||||
updateCartItem,
|
||||
type Cart,
|
||||
} from '../../services/cartService'
|
||||
import { syncGuestCartToServer } from '../../services/syncGuestCart'
|
||||
import { formatIrtPrice } from '../../utils/irtPrice'
|
||||
import { CheckoutActionBar } from '../../components/checkout/CheckoutActionBar'
|
||||
import styles from './CheckoutSteps.module.css'
|
||||
|
||||
type DisplayItem = {
|
||||
id: string
|
||||
title: string
|
||||
variant?: string
|
||||
image: string | null
|
||||
quantity: number
|
||||
lineTotal: number
|
||||
stockQuantity: number | null
|
||||
}
|
||||
|
||||
function guestToDisplay(items: GuestCartItem[]): DisplayItem[] {
|
||||
return items.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.name,
|
||||
image: item.image,
|
||||
quantity: item.quantity,
|
||||
lineTotal: item.price * item.quantity,
|
||||
stockQuantity: null,
|
||||
}))
|
||||
}
|
||||
|
||||
function formatVariantLabel(item: Cart['items'][number]): string | undefined {
|
||||
if (item.selections.length > 0) {
|
||||
return item.selections.map((selection) => selection.value).join(' · ')
|
||||
}
|
||||
|
||||
const title = item.productNameFa?.trim() || item.productTitle
|
||||
if (item.label && item.label !== title && item.label !== item.productTitle) {
|
||||
return item.label
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function apiToDisplay(cart: Cart): DisplayItem[] {
|
||||
return cart.items.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.productNameFa?.trim() || item.productTitle,
|
||||
variant: formatVariantLabel(item),
|
||||
image: item.productImage,
|
||||
quantity: item.quantity,
|
||||
lineTotal: item.lineTotal,
|
||||
stockQuantity: item.stockQuantity,
|
||||
}))
|
||||
}
|
||||
|
||||
export function CheckoutCartStep() {
|
||||
const navigate = useNavigate()
|
||||
const { showToast } = useToast()
|
||||
const { user } = useAuth()
|
||||
const isAuthenticated = Boolean(user)
|
||||
|
||||
const [cart, setCart] = useState<Cart | null>(null)
|
||||
const [guestItems, setGuestItems] = useState<GuestCartItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [busyItemId, setBusyItemId] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
const guest = loadGuestCart()
|
||||
setGuestItems(guest)
|
||||
|
||||
if (!isAuthenticated) {
|
||||
setCart(null)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
let data = await getCart(controller.signal)
|
||||
if (controller.signal.aborted) return
|
||||
|
||||
if (data.cart.items.length === 0 && guest.length > 0) {
|
||||
const synced = await syncGuestCartToServer(controller.signal)
|
||||
if (controller.signal.aborted) return
|
||||
data = { cart: synced }
|
||||
}
|
||||
|
||||
setCart(data.cart)
|
||||
if ((data.cart?.items?.length ?? 0) > 0) {
|
||||
setGuestItems([])
|
||||
}
|
||||
} catch (err) {
|
||||
if (isAbortError(err)) return
|
||||
setError(err instanceof ApiError ? err.message : 'بارگذاری سبد خرید ممکن نشد.')
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
return () => controller.abort()
|
||||
}, [isAuthenticated])
|
||||
|
||||
const apiHasItems = (cart?.items.length ?? 0) > 0
|
||||
const usingGuest = !isAuthenticated || !apiHasItems
|
||||
|
||||
const items = useMemo(
|
||||
() => (usingGuest ? guestToDisplay(guestItems) : cart ? apiToDisplay(cart) : []),
|
||||
[usingGuest, guestItems, cart],
|
||||
)
|
||||
|
||||
const subtotal = useMemo(
|
||||
() => items.reduce((sum, item) => sum + item.lineTotal, 0),
|
||||
[items],
|
||||
)
|
||||
|
||||
const totalQuantity = useMemo(
|
||||
() => items.reduce((sum, item) => sum + item.quantity, 0),
|
||||
[items],
|
||||
)
|
||||
|
||||
function persistGuest(next: GuestCartItem[]) {
|
||||
setGuestItems(next)
|
||||
saveGuestCart(next)
|
||||
}
|
||||
|
||||
async function handleQuantityChange(itemId: string, nextQuantity: number) {
|
||||
if (usingGuest) {
|
||||
if (nextQuantity <= 0) {
|
||||
persistGuest(guestItems.filter((item) => item.id !== itemId))
|
||||
return
|
||||
}
|
||||
persistGuest(
|
||||
guestItems.map((item) =>
|
||||
item.id === itemId ? { ...item, quantity: nextQuantity } : item,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
setBusyItemId(itemId)
|
||||
try {
|
||||
const data = await updateCartItem(itemId, nextQuantity)
|
||||
setCart(data.cart)
|
||||
} catch (err) {
|
||||
showToast(err instanceof ApiError ? err.message : 'بهروزرسانی تعداد ممکن نشد.', 'error')
|
||||
} finally {
|
||||
setBusyItemId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove(itemId: string) {
|
||||
if (usingGuest) {
|
||||
persistGuest(guestItems.filter((item) => item.id !== itemId))
|
||||
showToast('محصول از سبد حذف شد.', 'success')
|
||||
return
|
||||
}
|
||||
|
||||
setBusyItemId(itemId)
|
||||
try {
|
||||
const data = await removeCartItem(itemId)
|
||||
setCart(data.cart)
|
||||
showToast('محصول از سبد حذف شد.', 'success')
|
||||
} catch (err) {
|
||||
showToast(err instanceof ApiError ? err.message : 'حذف محصول ممکن نشد.', 'error')
|
||||
} finally {
|
||||
setBusyItemId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleContinue() {
|
||||
if (!isAuthenticated) {
|
||||
navigate(`/checkout/login?redirect=${encodeURIComponent('/checkout/delivery')}`, {
|
||||
state: { from: '/checkout/delivery' },
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (usingGuest && guestItems.length > 0) {
|
||||
setLoading(true)
|
||||
try {
|
||||
const cart = await syncGuestCartToServer()
|
||||
setCart(cart)
|
||||
setGuestItems([])
|
||||
if (cart.items.length === 0) {
|
||||
showToast('انتقال سبد خرید ممکن نشد. دوباره تلاش کنید.', 'error')
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(
|
||||
err instanceof ApiError ? err.message : 'انتقال سبد خرید ممکن نشد.',
|
||||
'error',
|
||||
)
|
||||
return
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
navigate('/checkout/delivery')
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className={styles.loading}>در حال بارگذاری سبد خرید...</div>
|
||||
}
|
||||
|
||||
if (error && items.length === 0) {
|
||||
return (
|
||||
<div className={styles.stepContent}>
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
<button type="button" className={styles.secondaryBtn} onClick={() => window.location.reload()}>
|
||||
تلاش مجدد
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.stepContent}>
|
||||
<div>
|
||||
<h1 className={styles.stepTitle}>سبد خرید شما</h1>
|
||||
<p className={styles.stepDesc}>
|
||||
{items.length === 0
|
||||
? 'سبد خرید شما خالی است.'
|
||||
: 'آماده تکمیل سفارش هستید.'}
|
||||
{!isAuthenticated && items.length > 0
|
||||
? ' برای ادامه، در مرحله بعد وارد شوید.'
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className={styles.empty}>
|
||||
<div className={styles.emptyIcon}>
|
||||
<ShoppingBag size={40} strokeWidth={1.5} />
|
||||
</div>
|
||||
<p>برای شروع خرید، از فروشگاه محصول اضافه کنید.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.cartList}>
|
||||
{items.map((item) => {
|
||||
const busy = busyItemId === item.id
|
||||
const atMax =
|
||||
item.stockQuantity !== null && item.quantity >= item.stockQuantity
|
||||
|
||||
return (
|
||||
<article key={item.id} className={styles.cartItem}>
|
||||
{item.image ? (
|
||||
<img src={item.image} alt="" className={styles.cartItemImage} />
|
||||
) : (
|
||||
<div className={`${styles.cartItemImage} ${styles.cartItemImagePlaceholder}`}>
|
||||
<Package size={22} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.cartItemInfo}>
|
||||
<div className={styles.cartItemTitle}>{item.title}</div>
|
||||
{item.variant ? (
|
||||
<div className={styles.cartItemVariant}>{item.variant}</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className={styles.cartItemFooter}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.removeBtn}
|
||||
onClick={() => void handleRemove(item.id)}
|
||||
disabled={busy}
|
||||
aria-label="حذف محصول"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
<div className={styles.qtyControls}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.qtyBtn}
|
||||
onClick={() => void handleQuantityChange(item.id, item.quantity - 1)}
|
||||
disabled={busy || item.quantity <= 1}
|
||||
aria-label="کاهش تعداد"
|
||||
>
|
||||
<Minus size={14} />
|
||||
</button>
|
||||
<span className={styles.qtyValue}>{item.quantity}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.qtyBtn}
|
||||
onClick={() => void handleQuantityChange(item.id, item.quantity + 1)}
|
||||
disabled={busy || atMax}
|
||||
aria-label="افزایش تعداد"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<span className={styles.cartItemPrice}>{formatIrtPrice(item.lineTotal)}</span>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{items.length > 0 && (
|
||||
<>
|
||||
<div className={styles.summary}>
|
||||
<div className={styles.summaryRow}>
|
||||
<span>تعداد کالا</span>
|
||||
<span>{totalQuantity.toLocaleString('fa-IR')} عدد</span>
|
||||
</div>
|
||||
<div className={`${styles.summaryRow} ${styles.summaryRowTotal}`}>
|
||||
<span>جمع کل</span>
|
||||
<span className={styles.priceEn}>{formatIrtPrice(subtotal)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CheckoutActionBar
|
||||
primaryLabel={isAuthenticated ? 'ادامه به ارسال' : 'ادامه به ورود'}
|
||||
onPrimary={handleContinue}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { useEffect, 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 {
|
||||
CheckoutAddAddressRow,
|
||||
} from '../../components/checkout/CheckoutAddAddressPanel'
|
||||
import { CheckoutAddAddressModal } from '../../components/checkout/CheckoutAddAddressModal'
|
||||
import { CheckoutActionBar } from '../../components/checkout/CheckoutActionBar'
|
||||
import { CheckoutBackButton } from '../../components/checkout/CheckoutBackButton'
|
||||
import styles from './CheckoutSteps.module.css'
|
||||
|
||||
export function CheckoutDeliveryStep() {
|
||||
const navigate = useNavigate()
|
||||
const { user, isLoading: authLoading } = useAuth()
|
||||
const { deliveryMode, selectedAddressId, setDeliveryMode, setSelectedAddressId } =
|
||||
useCheckout()
|
||||
const [addresses, setAddresses] = useState<UserAddress[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const selectedAddressIdRef = useRef(selectedAddressId)
|
||||
selectedAddressIdRef.current = selectedAddressId
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return
|
||||
|
||||
const controller = new AbortController()
|
||||
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const data = await listAddresses(controller.signal)
|
||||
if (controller.signal.aborted) return
|
||||
|
||||
setAddresses(data.items)
|
||||
|
||||
const current = selectedAddressIdRef.current
|
||||
if (data.items.length === 0) {
|
||||
if (current !== null) setSelectedAddressId(null)
|
||||
return
|
||||
}
|
||||
|
||||
const stillValid = current !== null && data.items.some((item) => item.id === current)
|
||||
if (!stillValid) {
|
||||
setSelectedAddressId(data.items[0].id)
|
||||
}
|
||||
} catch (err) {
|
||||
if (isAbortError(err)) return
|
||||
setError(err instanceof ApiError ? err.message : 'بارگذاری آدرسها ممکن نشد.')
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
return () => controller.abort()
|
||||
}, [user, setSelectedAddressId])
|
||||
|
||||
async function handleAddressSaved() {
|
||||
try {
|
||||
const data = await listAddresses()
|
||||
setAddresses(data.items)
|
||||
const newest = data.items.at(-1)
|
||||
if (newest) setSelectedAddressId(newest.id)
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'بارگذاری آدرسها ممکن نشد.')
|
||||
}
|
||||
}
|
||||
|
||||
const canContinue =
|
||||
deliveryMode === 'pickup' || (deliveryMode === 'delivery' && selectedAddressId)
|
||||
|
||||
if (authLoading) {
|
||||
return <div className={styles.loading}>در حال بارگذاری...</div>
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<Navigate
|
||||
to={`/checkout/login?redirect=${encodeURIComponent('/checkout/delivery')}`}
|
||||
replace
|
||||
state={{ from: '/checkout/delivery' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.stepContent}>
|
||||
<CheckoutBackButton
|
||||
label="بازگشت به سبد خرید"
|
||||
onClick={() => navigate('/checkout/cart')}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<h1 className={styles.stepTitle}>نحوه دریافت</h1>
|
||||
<p className={styles.stepDesc}>چگونه میخواهید سفارش را دریافت کنید؟</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.deliveryOptions}>
|
||||
<button
|
||||
type="button"
|
||||
className={[
|
||||
styles.deliveryOption,
|
||||
deliveryMode === 'delivery' ? styles.deliveryOptionActive : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
onClick={() => setDeliveryMode('delivery')}
|
||||
>
|
||||
<MapPin size={22} />
|
||||
<span className={styles.deliveryOptionLabel}>ارسال به آدرس</span>
|
||||
<span className={styles.deliveryOptionHint}>ارسال به یکی از آدرسهای ذخیرهشده</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={[
|
||||
styles.deliveryOption,
|
||||
deliveryMode === 'pickup' ? styles.deliveryOptionActive : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
onClick={() => {
|
||||
setDeliveryMode('pickup')
|
||||
setShowAddModal(false)
|
||||
}}
|
||||
>
|
||||
<Store size={22} />
|
||||
<span className={styles.deliveryOptionLabel}>تحویل حضوری</span>
|
||||
<span className={styles.deliveryOptionHint}>دریافت از فروشگاه</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{deliveryMode === 'pickup' ? (
|
||||
<p className={styles.pickupNote}>
|
||||
سفارش را از فروشگاه تحویل میگیرید. پس از آمادهسازی به شما اطلاع میدهیم.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className={styles.loading}>در حال بارگذاری آدرسها...</div>
|
||||
) : (
|
||||
<div className={styles.addressList}>
|
||||
{addresses.length === 0 && (
|
||||
<p className={styles.addressEmptyHint}>
|
||||
هنوز آدرسی ذخیره نکردهاید. یک آدرس جدید اضافه کنید یا تحویل حضوری را انتخاب
|
||||
کنید.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{addresses.map((address) => {
|
||||
const active = selectedAddressId === address.id
|
||||
return (
|
||||
<button
|
||||
key={address.id}
|
||||
type="button"
|
||||
className={[
|
||||
styles.addressCard,
|
||||
active ? styles.addressCardActive : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
onClick={() => setSelectedAddressId(address.id)}
|
||||
>
|
||||
<span className={styles.radio}>
|
||||
{active && <span className={styles.radioInner} />}
|
||||
</span>
|
||||
<span className={styles.addressCardBody}>
|
||||
<span className={styles.addressCardTitle}>
|
||||
{address.label || address.city}
|
||||
</span>
|
||||
<span className={styles.addressCardText}>
|
||||
{[
|
||||
address.label ? address.city : null,
|
||||
address.address,
|
||||
address.postalCode,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
<CheckoutAddAddressRow onClick={() => setShowAddModal(true)} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<CheckoutAddAddressModal
|
||||
open={showAddModal}
|
||||
onClose={() => setShowAddModal(false)}
|
||||
onSaved={() => void handleAddressSaved()}
|
||||
/>
|
||||
|
||||
<CheckoutActionBar
|
||||
primaryLabel="ادامه به پرداخت"
|
||||
onPrimary={() => navigate('/checkout/payment')}
|
||||
primaryDisabled={!canContinue}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import orderFailedImg from '../../assets/order-failed.png'
|
||||
import styles from './CheckoutSteps.module.css'
|
||||
|
||||
export function CheckoutFailedStep() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const message =
|
||||
(location.state as { message?: string } | null)?.message ||
|
||||
'ثبت سفارش ممکن نشد. لطفاً دوباره تلاش کنید.'
|
||||
|
||||
return (
|
||||
<div className={`${styles.stepContent} ${styles.successContent}`}>
|
||||
<div className={styles.successIcon}>
|
||||
<img
|
||||
src={orderFailedImg}
|
||||
alt="ثبت سفارش ناموفق"
|
||||
className={styles.successImage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.successHeader}>
|
||||
<h1 className={styles.stepTitle}>سفارش ثبت نشد</h1>
|
||||
<p className={styles.stepDesc}>{message}</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.successActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.successPrimaryBtn}
|
||||
onClick={() => navigate('/checkout/payment')}
|
||||
>
|
||||
تلاش مجدد
|
||||
</button>
|
||||
<Link to="/checkout/cart" className={styles.successNeutralBtn}>
|
||||
بازگشت به سبد خرید
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
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'
|
||||
|
||||
/** Cart and checkout login are public. Delivery, payment, and success require auth. */
|
||||
const PUBLIC_STEPS = new Set<CheckoutStepId>(['cart', 'login'])
|
||||
|
||||
function stepFromPath(pathname: string): CheckoutStepId {
|
||||
if (pathname.endsWith('/login')) return 'login'
|
||||
if (pathname.endsWith('/delivery')) return 'delivery'
|
||||
if (pathname.endsWith('/payment')) return 'payment'
|
||||
if (pathname.endsWith('/success') || pathname.endsWith('/failed')) return 'payment'
|
||||
return 'cart'
|
||||
}
|
||||
|
||||
function loginRedirectPath(returnTo: string) {
|
||||
return `/checkout/login?redirect=${encodeURIComponent(returnTo)}`
|
||||
}
|
||||
|
||||
export function CheckoutFlow() {
|
||||
const { user, isLoading } = useAuth()
|
||||
const location = useLocation()
|
||||
const step = stepFromPath(location.pathname)
|
||||
const isAuthenticated = Boolean(user)
|
||||
|
||||
if (isLoading) {
|
||||
return <RouteLoader />
|
||||
}
|
||||
|
||||
if (!isAuthenticated && !PUBLIC_STEPS.has(step)) {
|
||||
return (
|
||||
<Navigate
|
||||
to={loginRedirectPath(location.pathname)}
|
||||
replace
|
||||
state={{ from: location.pathname }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (isAuthenticated && step === 'login') {
|
||||
const params = new URLSearchParams(location.search)
|
||||
const fromQuery = params.get('redirect')
|
||||
const fromState = (location.state as { from?: string } | null)?.from
|
||||
const destination =
|
||||
fromQuery && fromQuery.startsWith('/') && !fromQuery.startsWith('//')
|
||||
? fromQuery
|
||||
: fromState ?? '/checkout/delivery'
|
||||
return <Navigate to={destination} replace />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{step !== 'login' &&
|
||||
location.pathname !== '/checkout/success' &&
|
||||
location.pathname !== '/checkout/failed' && (
|
||||
<CheckoutStepper current={step} isAuthenticated={isAuthenticated} />
|
||||
)}
|
||||
<Outlet />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useLocation, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { Eye, EyeOff, Lock, Smartphone } from 'lucide-react'
|
||||
import { useAuth } from '../../context/AuthContext'
|
||||
import { ApiError } from '../../lib/api'
|
||||
import { toE164CellNumber } from '../../lib/cellNumber'
|
||||
import { syncGuestCartToServer } from '../../services/syncGuestCart'
|
||||
import styles from './CheckoutSteps.module.css'
|
||||
|
||||
export function CheckoutLoginStep() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const [searchParams] = useSearchParams()
|
||||
const { login } = useAuth()
|
||||
const [phone, setPhone] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
function resolveRedirectPath() {
|
||||
const fromQuery = searchParams.get('redirect')
|
||||
if (fromQuery && fromQuery.startsWith('/') && !fromQuery.startsWith('//')) {
|
||||
return fromQuery
|
||||
}
|
||||
const fromState = (location.state as { from?: string } | null)?.from
|
||||
if (fromState && fromState.startsWith('/') && !fromState.startsWith('//')) {
|
||||
return fromState
|
||||
}
|
||||
return '/checkout/delivery'
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
const cellNumber = toE164CellNumber(phone)
|
||||
await login(cellNumber, password)
|
||||
try {
|
||||
await syncGuestCartToServer()
|
||||
} catch {
|
||||
// Cart sync is best-effort; delivery/payment will retry
|
||||
}
|
||||
navigate(resolveRedirectPath(), { replace: true })
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'ورود ممکن نشد.')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.stepContent}>
|
||||
<div>
|
||||
<h1 className={styles.stepTitle}>برای ادامه وارد شوید</h1>
|
||||
<p className={styles.stepDesc}>
|
||||
سبد خرید شما ذخیره شده است. برای تعیین ارسال و پرداخت وارد شوید.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form className={styles.loginForm} onSubmit={(e) => void handleSubmit(e)}>
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="checkout-phone">شماره موبایل</label>
|
||||
<div className={styles.inputWrap}>
|
||||
<Smartphone size={18} className={styles.inputIcon} />
|
||||
<input
|
||||
id="checkout-phone"
|
||||
type="tel"
|
||||
inputMode="tel"
|
||||
placeholder="09122222222"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
dir="ltr"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="checkout-password">رمز عبور</label>
|
||||
<div className={styles.inputWrap}>
|
||||
<Lock size={18} className={styles.inputIcon} />
|
||||
<input
|
||||
id="checkout-password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="رمز عبور خود را وارد کنید"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.togglePassword}
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
aria-label={showPassword ? 'مخفی کردن رمز' : 'نمایش رمز'}
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" className={styles.primaryBtn} disabled={isSubmitting}>
|
||||
{isSubmitting ? 'در حال ورود...' : 'ادامه به ارسال'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className={styles.footerLink}>
|
||||
حساب کاربری ندارید؟{' '}
|
||||
<Link to="/login?redirect=/checkout/cart">ثبتنام کنید</Link>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Building2, CreditCard } from 'lucide-react'
|
||||
import { useToast } from '@meshkee/dashboard-ui'
|
||||
import { useCheckout } from '../../context/CheckoutContext'
|
||||
import { ApiError, isAbortError } from '../../lib/api'
|
||||
import {
|
||||
checkoutCart,
|
||||
getCart,
|
||||
type CheckoutPaymentType,
|
||||
type OnlineGatewayType,
|
||||
} from '../../services/cartService'
|
||||
import { syncGuestCartToServer } from '../../services/syncGuestCart'
|
||||
import { formatIrtPrice } from '../../utils/irtPrice'
|
||||
import { CheckoutActionBar } from '../../components/checkout/CheckoutActionBar'
|
||||
import { CheckoutBackButton } from '../../components/checkout/CheckoutBackButton'
|
||||
import styles from './CheckoutSteps.module.css'
|
||||
|
||||
const ONLINE_GATEWAYS: { id: OnlineGatewayType; label: string }[] = [
|
||||
{ id: 'mellat_behpardakht', label: 'درگاه پرداخت بانک ملت - بهپرداخت' },
|
||||
{ id: 'saman_kish', label: 'درگاه پرداخت بانک سامان - سامان کیش' },
|
||||
]
|
||||
|
||||
export function CheckoutPaymentStep() {
|
||||
const navigate = useNavigate()
|
||||
const { showToast } = useToast()
|
||||
const { deliveryMode, selectedAddressId, discountCode, setDiscountCode, setPlacedOrder } =
|
||||
useCheckout()
|
||||
const [subtotal, setSubtotal] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [paymentType, setPaymentType] = useState<CheckoutPaymentType>('e_payment_gate')
|
||||
const [gatewayType, setGatewayType] = useState<OnlineGatewayType>('mellat_behpardakht')
|
||||
const [transferAccount, setTransferAccount] = useState('')
|
||||
const [transferRefNumber, setTransferRefNumber] = useState('')
|
||||
const [discountApplied, setDiscountApplied] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
try {
|
||||
let data = await getCart(controller.signal)
|
||||
if (controller.signal.aborted) return
|
||||
|
||||
if (data.cart.items.length === 0) {
|
||||
try {
|
||||
const synced = await syncGuestCartToServer(controller.signal)
|
||||
if (controller.signal.aborted) return
|
||||
data = { cart: synced }
|
||||
} catch {
|
||||
// fall through to empty-cart redirect
|
||||
}
|
||||
}
|
||||
|
||||
setSubtotal(data.cart.subtotal)
|
||||
if (data.cart.items.length === 0) {
|
||||
navigate('/checkout/cart', { replace: true })
|
||||
}
|
||||
} catch (err) {
|
||||
if (isAbortError(err)) return
|
||||
setError(err instanceof ApiError ? err.message : 'بارگذاری سبد خرید ممکن نشد.')
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
return () => controller.abort()
|
||||
}, [navigate])
|
||||
|
||||
function handleApplyDiscount() {
|
||||
if (!discountCode.trim()) return
|
||||
setDiscountApplied(true)
|
||||
showToast('کد تخفیف هنوز فعال نیست — مبلغ تغییری نکرد.', 'info')
|
||||
}
|
||||
|
||||
function buildPaymentPayload() {
|
||||
if (paymentType === 'transfer') {
|
||||
return {
|
||||
type: 'transfer' as const,
|
||||
transferAccount: transferAccount.trim(),
|
||||
transferRefNumber: transferRefNumber.trim(),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'e_payment_gate' as const,
|
||||
gatewayType,
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePlaceOrder() {
|
||||
setError('')
|
||||
|
||||
if (deliveryMode === 'delivery' && !selectedAddressId) {
|
||||
setError('لطفاً یک آدرس ارسال انتخاب کنید.')
|
||||
return
|
||||
}
|
||||
|
||||
// Only bank transfer requires extra fields before creating the order
|
||||
if (paymentType === 'transfer') {
|
||||
if (!transferAccount.trim() || !transferRefNumber.trim()) {
|
||||
setError('لطفاً شماره حساب/کارت و شماره مرجع را وارد کنید.')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
|
||||
const notes: string[] = []
|
||||
if (deliveryMode === 'pickup') {
|
||||
notes.push('تحویل حضوری از فروشگاه')
|
||||
}
|
||||
if (discountCode.trim()) {
|
||||
notes.push(`کد تخفیف: ${discountCode.trim()}`)
|
||||
}
|
||||
|
||||
try {
|
||||
// Both types persist via existing backend TransactionType:
|
||||
// e_payment_gate (+ gatewayType) | transfer (+ account/ref)
|
||||
const payment = buildPaymentPayload()
|
||||
const payload =
|
||||
deliveryMode === 'pickup'
|
||||
? {
|
||||
shippingAddress: {
|
||||
province: 'Pickup',
|
||||
city: 'Store',
|
||||
address: 'In-store pickup',
|
||||
postalCode: '0000000000',
|
||||
},
|
||||
customerNotes: notes.join(' · ') || undefined,
|
||||
payment,
|
||||
}
|
||||
: {
|
||||
addressId: selectedAddressId!,
|
||||
customerNotes: notes.join(' · ') || undefined,
|
||||
payment,
|
||||
}
|
||||
|
||||
const result = await checkoutCart(payload)
|
||||
setPlacedOrder(result.order)
|
||||
// Pass order in navigation state so success page does not race context updates
|
||||
navigate('/checkout/success', { replace: true, state: { order: result.order } })
|
||||
showToast('سفارش با موفقیت ثبت شد.', 'success')
|
||||
} catch (err) {
|
||||
const message = err instanceof ApiError ? err.message : 'ثبت سفارش ممکن نشد.'
|
||||
navigate('/checkout/failed', { replace: true, state: { message } })
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className={styles.loading}>در حال بارگذاری...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.stepContent}>
|
||||
<CheckoutBackButton
|
||||
label="بازگشت به ارسال"
|
||||
onClick={() => navigate('/checkout/delivery')}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<h1 className={styles.stepTitle}>پرداخت</h1>
|
||||
<p className={styles.stepDesc}>کد تخفیف را وارد کنید و روش پرداخت را انتخاب کنید.</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="discount-code">کد تخفیف</label>
|
||||
<div className={styles.discountRow}>
|
||||
<input
|
||||
id="discount-code"
|
||||
type="text"
|
||||
placeholder="کد را وارد کنید"
|
||||
value={discountCode}
|
||||
onChange={(e) => {
|
||||
setDiscountCode(e.target.value)
|
||||
setDiscountApplied(false)
|
||||
}}
|
||||
disabled={submitting}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.applyBtn}
|
||||
onClick={handleApplyDiscount}
|
||||
disabled={submitting || !discountCode.trim()}
|
||||
>
|
||||
اعمال
|
||||
</button>
|
||||
</div>
|
||||
{discountApplied && (
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
کد ذخیره شد — در یادداشت سفارش ثبت میشود.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label>روش پرداخت</label>
|
||||
<div className={styles.paymentOptions}>
|
||||
<button
|
||||
type="button"
|
||||
className={[
|
||||
styles.paymentOption,
|
||||
paymentType === 'e_payment_gate' ? styles.paymentOptionActive : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
onClick={() => setPaymentType('e_payment_gate')}
|
||||
disabled={submitting}
|
||||
>
|
||||
<CreditCard size={20} />
|
||||
<span className={styles.paymentOptionBody}>
|
||||
<span className={styles.paymentOptionTitle}>پرداخت آنلاین</span>
|
||||
<span className={styles.paymentOptionHint}>پرداخت امن از طریق درگاه</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{paymentType === 'e_payment_gate' && (
|
||||
<div className={styles.paymentSubOptions} role="radiogroup" aria-label="انتخاب درگاه">
|
||||
{ONLINE_GATEWAYS.map((gateway) => {
|
||||
const active = gatewayType === gateway.id
|
||||
return (
|
||||
<button
|
||||
key={gateway.id}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
className={[
|
||||
styles.paymentSubOption,
|
||||
active ? styles.paymentSubOptionActive : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
onClick={() => setGatewayType(gateway.id)}
|
||||
disabled={submitting}
|
||||
>
|
||||
<span className={styles.paymentSubRadio}>
|
||||
{active ? <span className={styles.paymentSubRadioInner} /> : null}
|
||||
</span>
|
||||
<span>{gateway.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={[
|
||||
styles.paymentOption,
|
||||
paymentType === 'transfer' ? styles.paymentOptionActive : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
onClick={() => setPaymentType('transfer')}
|
||||
disabled={submitting}
|
||||
>
|
||||
<Building2 size={20} />
|
||||
<span className={styles.paymentOptionBody}>
|
||||
<span className={styles.paymentOptionTitle}>جابجایی بانکی</span>
|
||||
<span className={styles.paymentOptionHint}>واریز به حساب و ثبت شماره مرجع</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{paymentType === 'transfer' && (
|
||||
<div className={styles.transferFields}>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="transfer-account">شماره حساب بانکی یا کارت شما</label>
|
||||
<input
|
||||
id="transfer-account"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={transferAccount}
|
||||
onChange={(e) => setTransferAccount(e.target.value)}
|
||||
placeholder="شماره حساب یا کارت"
|
||||
disabled={submitting}
|
||||
dir="ltr"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="transfer-ref">شماره مرجع</label>
|
||||
<input
|
||||
id="transfer-ref"
|
||||
type="text"
|
||||
value={transferRefNumber}
|
||||
onChange={(e) => setTransferRefNumber(e.target.value)}
|
||||
placeholder="شماره مرجع تراکنش"
|
||||
disabled={submitting}
|
||||
dir="ltr"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.summary}>
|
||||
<div className={styles.summaryRow}>
|
||||
<span>جمع جزء</span>
|
||||
<span className={styles.priceEn}>{formatIrtPrice(subtotal)}</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CheckoutActionBar
|
||||
primaryLabel={submitting ? 'در حال ثبت سفارش...' : 'پرداخت و ثبت سفارش'}
|
||||
onPrimary={() => void handlePlaceOrder()}
|
||||
primaryDisabled={submitting}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,807 @@
|
||||
.stepContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.stepTitle {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.stepDesc {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 4px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.actionsStack {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.primaryBtn {
|
||||
flex: 1;
|
||||
padding: 13px 16px;
|
||||
font-size: 14px;
|
||||
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.35);
|
||||
transition: transform 0.2s, box-shadow 0.2s, opacity 0.2s;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.secondaryBtn {
|
||||
flex: 1;
|
||||
padding: 12px 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
font-family: inherit;
|
||||
color: var(--primary);
|
||||
background: rgba(var(--primary-rgb) / 0.08);
|
||||
border: 1px solid rgba(var(--primary-rgb) / 0.25);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.secondaryBtn:hover:not(:disabled) {
|
||||
background: rgba(var(--primary-rgb) / 0.14);
|
||||
}
|
||||
|
||||
.backBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: -8px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.backBtn:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 24px 12px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.emptyIcon {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 32px;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.cartList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.cartItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 12px 14px;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.cartItemImage {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
background: rgba(148, 163, 184, 0.15);
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.cartItemImagePlaceholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.cartItemInfo {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.cartItemTitle {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.cartItemVariant {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.cartItemFooter {
|
||||
display: flex;
|
||||
direction: ltr;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
flex-shrink: 0;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.cartItemPrice {
|
||||
font-family: 'Montserrat', var(--font-en), sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
margin-left: 14px;
|
||||
}
|
||||
|
||||
.qtyControls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.qtyBtn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
color: var(--text-secondary);
|
||||
background: rgba(148, 163, 184, 0.15);
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.qtyBtn:hover:not(:disabled) {
|
||||
background: rgba(var(--primary-rgb) / 0.15);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.qtyBtn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.qtyValue {
|
||||
min-width: 24px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.removeBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
color: #b91c1c;
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.removeBtn:hover:not(:disabled) {
|
||||
background: rgba(239, 68, 68, 0.16);
|
||||
}
|
||||
|
||||
.summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.25);
|
||||
}
|
||||
|
||||
.summaryRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.priceEn {
|
||||
font-family: 'Montserrat', var(--font-en), sans-serif;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
text-align: end;
|
||||
direction: ltr;
|
||||
unicode-bidi: isolate;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.summaryRowTotal {
|
||||
margin-top: 8px;
|
||||
padding: 12px 14px;
|
||||
background: #f4f4f5;
|
||||
border: 1px solid #e4e4e7;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #18181b;
|
||||
}
|
||||
|
||||
.summaryRowTotal .priceEn {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #18181b;
|
||||
}
|
||||
|
||||
.deliveryOptions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.deliveryOption {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px 12px;
|
||||
border: 2px solid rgba(148, 163, 184, 0.3);
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.deliveryOption:hover {
|
||||
border-color: rgba(var(--primary-rgb) / 0.4);
|
||||
}
|
||||
|
||||
.deliveryOptionActive {
|
||||
border-color: var(--primary);
|
||||
background: rgba(var(--primary-rgb) / 0.08);
|
||||
}
|
||||
|
||||
.deliveryOptionLabel {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.deliveryOptionHint {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.addressList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.addressEmptyHint {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
padding: 0 2px 4px;
|
||||
}
|
||||
|
||||
.addressCard {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border: 2px solid rgba(148, 163, 184, 0.3);
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.addressCard:hover {
|
||||
border-color: rgba(var(--primary-rgb) / 0.4);
|
||||
}
|
||||
|
||||
.addressCardActive {
|
||||
border-color: var(--primary);
|
||||
background: rgba(var(--primary-rgb) / 0.08);
|
||||
}
|
||||
|
||||
.addressCardBody {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.addressCardTitle {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.addressCardText {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.radio {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(148, 163, 184, 0.5);
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.addressCardActive .radio {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.radioInner {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
.pickupNote {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
padding: 14px;
|
||||
background: rgba(var(--primary-rgb) / 0.06);
|
||||
border: 1px solid rgba(var(--primary-rgb) / 0.15);
|
||||
border-radius: var(--radius-sm);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.field label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.field input {
|
||||
width: 100%;
|
||||
min-height: var(--field-height);
|
||||
padding: var(--field-padding-y) var(--field-padding-x);
|
||||
font-size: var(--field-font-size);
|
||||
font-family: inherit;
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
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:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
|
||||
}
|
||||
|
||||
.discountRow {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.discountRow input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.applyBtn {
|
||||
padding: 0 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
font-family: inherit;
|
||||
color: var(--primary);
|
||||
background: rgba(var(--primary-rgb) / 0.08);
|
||||
border: 1px solid rgba(var(--primary-rgb) / 0.25);
|
||||
border-radius: var(--radius-sm);
|
||||
white-space: nowrap;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.applyBtn:hover:not(:disabled) {
|
||||
background: rgba(var(--primary-rgb) / 0.14);
|
||||
}
|
||||
|
||||
.paymentOption {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
border: 2px solid rgba(148, 163, 184, 0.3);
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.paymentOptionActive {
|
||||
border-color: var(--primary);
|
||||
background: rgba(var(--primary-rgb) / 0.08);
|
||||
}
|
||||
|
||||
.paymentOptionBody {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.paymentOptionTitle {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.paymentOptionHint {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.paymentOptions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.paymentSubOptions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 4px 2px 0;
|
||||
}
|
||||
|
||||
.paymentSubOption {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border: 1.5px solid rgba(148, 163, 184, 0.3);
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.65);
|
||||
cursor: pointer;
|
||||
text-align: right;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
font-family: var(--font-fa);
|
||||
color: var(--text-primary);
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.paymentSubOptionActive {
|
||||
border-color: var(--primary);
|
||||
background: rgba(var(--primary-rgb) / 0.06);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.paymentSubRadio {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(148, 163, 184, 0.55);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.paymentSubOptionActive .paymentSubRadio {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.paymentSubRadioInner {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
.transferFields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 4px 2px 0;
|
||||
}
|
||||
|
||||
.successIcon {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.successImage {
|
||||
width: min(320px, 78vw);
|
||||
height: auto;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
background: transparent;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.successContent {
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 18px;
|
||||
padding-block: 8px 4px;
|
||||
}
|
||||
|
||||
.successHeader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.successHeader .stepTitle,
|
||||
.successHeader .stepDesc {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.successMeta {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
margin: 0;
|
||||
padding: 4px 0;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.22);
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.22);
|
||||
}
|
||||
|
||||
.successMetaRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 11px 4px;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.14);
|
||||
}
|
||||
|
||||
.successMetaRow:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.successMetaRow dt {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.successMetaRow dd {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.successMetaValue {
|
||||
font-family: 'Montserrat', var(--font-en), sans-serif;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
.successActions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.successPrimaryBtn,
|
||||
.successNeutralBtn,
|
||||
.successDarkBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 188px;
|
||||
padding: 9px 18px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
border-radius: var(--radius-sm);
|
||||
text-decoration: none;
|
||||
transition: background 0.2s, transform 0.2s, box-shadow 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.successDarkBtn {
|
||||
color: #f8fafc;
|
||||
background: #1e293b;
|
||||
border: 1px solid #0f172a;
|
||||
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.18);
|
||||
}
|
||||
|
||||
.successDarkBtn:hover {
|
||||
background: #0f172a;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.successNeutralBtn {
|
||||
color: #1e293b;
|
||||
background: #e8eaed;
|
||||
border: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
.successNeutralBtn:hover {
|
||||
background: #dfe3e8;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
/* Kept for failed-page retry button (non-theme) */
|
||||
.successPrimaryBtn {
|
||||
color: #f8fafc;
|
||||
background: #1e293b;
|
||||
border: 1px solid #0f172a;
|
||||
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.18);
|
||||
}
|
||||
|
||||
.successPrimaryBtn:hover {
|
||||
background: #0f172a;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.successOrder {
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.successOrder strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.loginForm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.inputWrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.inputIcon {
|
||||
position: absolute;
|
||||
inset-inline-start: 12px;
|
||||
color: var(--text-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.inputWrap input {
|
||||
width: 100%;
|
||||
min-height: var(--field-height);
|
||||
padding-block: var(--field-padding-y);
|
||||
padding-inline: 38px 40px;
|
||||
font-size: var(--field-font-size);
|
||||
font-family: inherit;
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
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;
|
||||
}
|
||||
|
||||
.inputWrap input:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
|
||||
}
|
||||
|
||||
.togglePassword {
|
||||
position: absolute;
|
||||
inset-inline-end: 12px;
|
||||
display: flex;
|
||||
color: var(--text-muted);
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.footerLink {
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.footerLink button {
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.deliveryOptions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useEffect } from 'react'
|
||||
import { Link, Navigate, useLocation } from 'react-router-dom'
|
||||
import { useCheckout } from '../../context/CheckoutContext'
|
||||
import type { Order } from '../../services/orderService'
|
||||
import { getWebsiteUrl } from '../../services/websiteService'
|
||||
import { formatIrtPrice } from '../../utils/irtPrice'
|
||||
import orderSucceedImg from '../../assets/order-succeed.png'
|
||||
import styles from './CheckoutSteps.module.css'
|
||||
|
||||
export function CheckoutSuccessStep() {
|
||||
const location = useLocation()
|
||||
const { placedOrder, setPlacedOrder, resetCheckout } = useCheckout()
|
||||
const orderFromNav = (location.state as { order?: Order } | null)?.order
|
||||
const order = placedOrder ?? orderFromNav ?? null
|
||||
const websiteUrl = getWebsiteUrl()
|
||||
|
||||
useEffect(() => {
|
||||
if (!placedOrder && orderFromNav) {
|
||||
setPlacedOrder(orderFromNav)
|
||||
}
|
||||
}, [placedOrder, orderFromNav, setPlacedOrder])
|
||||
|
||||
if (!order) {
|
||||
return <Navigate to="/checkout/cart" replace />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${styles.stepContent} ${styles.successContent}`}>
|
||||
<div className={styles.successIcon}>
|
||||
<img
|
||||
src={orderSucceedImg}
|
||||
alt="سفارش با موفقیت ثبت شد"
|
||||
className={styles.successImage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.successHeader}>
|
||||
<h1 className={styles.stepTitle}>سفارش ثبت شد</h1>
|
||||
<p className={styles.stepDesc}>از خرید شما سپاسگزاریم — سفارش دریافت شد.</p>
|
||||
</div>
|
||||
|
||||
<dl className={styles.successMeta}>
|
||||
<div className={styles.successMetaRow}>
|
||||
<dt>شماره سفارش</dt>
|
||||
<dd className={styles.successMetaValue}>{order.orderNumber}</dd>
|
||||
</div>
|
||||
<div className={styles.successMetaRow}>
|
||||
<dt>مبلغ</dt>
|
||||
<dd className={styles.successMetaValue}>{formatIrtPrice(order.total)}</dd>
|
||||
</div>
|
||||
<div className={styles.successMetaRow}>
|
||||
<dt>وضعیت</dt>
|
||||
<dd>در انتظار تأیید</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div className={styles.successActions}>
|
||||
<Link to="/" className={styles.successDarkBtn} onClick={() => resetCheckout()}>
|
||||
رفتن به داشبورد مدیریت
|
||||
</Link>
|
||||
<a href={websiteUrl} className={styles.successNeutralBtn} onClick={() => resetCheckout()}>
|
||||
بازگشت به وبسایت
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user