Initial commit: Meshkee dashboards monorepo.

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-07-22 13:48:53 +03:30
co-authored by Cursor
commit f566387c61
509 changed files with 62690 additions and 0 deletions
@@ -0,0 +1,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);
}
+259
View File
@@ -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);
}
}
+138
View File
@@ -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>
)
}
+75
View File
@@ -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;
}
}
+720
View File
@@ -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&apos;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;
}
+201
View File
@@ -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 &amp; 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;
}
}
+213
View File
@@ -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>
)
}