Expand special-order form with selects and success screen.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
389fc6fc51
commit
cc4334e048
Generated
+31
@@ -11,6 +11,7 @@
|
||||
"lucide-react": "^0.511.0",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-multi-date-picker": "^4.5.2",
|
||||
"react-router-dom": "^7.18.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -1116,6 +1117,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-date-object": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/react-date-object/-/react-date-object-2.1.9.tgz",
|
||||
"integrity": "sha512-BHxD/quWOTo9fLKV/cfL/M31ePoj4a1JaJ/CnOf8Ndg3mrkh4x9wEMMkCfTrzduxDOgU8ZgR8uarhqI5G71sTg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "19.2.8",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
|
||||
@@ -1128,6 +1135,30 @@
|
||||
"react": "^19.2.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-element-popper": {
|
||||
"version": "2.1.7",
|
||||
"resolved": "https://registry.npmjs.org/react-element-popper/-/react-element-popper-2.1.7.tgz",
|
||||
"integrity": "sha512-tuM2OxKlW32h+6uFSK6EENHPeZ2OGgOipHfOAl+VLWEv9/j3QkSGbD+ADX3A9uJlmq24i37n28RjJmAbGTfpEg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-multi-date-picker": {
|
||||
"version": "4.5.2",
|
||||
"resolved": "https://registry.npmjs.org/react-multi-date-picker/-/react-multi-date-picker-4.5.2.tgz",
|
||||
"integrity": "sha512-FgWjZB3Z6IA6XpcWiLPk85PwcRUhOiYhKK42o5k672gD/n2I6rzPfQ8bUrldOIiF/Z7FfOCdH7a6FeubzqteLg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-date-object": "^2.1.8",
|
||||
"react-element-popper": "^2.1.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz",
|
||||
|
||||
+2
-1
@@ -10,9 +10,10 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^0.511.0",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"lucide-react": "^0.511.0",
|
||||
"react-multi-date-picker": "^4.5.2",
|
||||
"react-router-dom": "^7.18.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import MultiDatePicker from 'react-multi-date-picker'
|
||||
import DateObjectModule from 'react-date-object'
|
||||
import persian from 'react-date-object/calendars/persian'
|
||||
import persian_fa from 'react-date-object/locales/persian_fa'
|
||||
|
||||
const DatePicker =
|
||||
(MultiDatePicker as unknown as { default?: typeof MultiDatePicker }).default ??
|
||||
MultiDatePicker
|
||||
const DateObject =
|
||||
(DateObjectModule as unknown as { default?: typeof DateObjectModule })
|
||||
.default ?? DateObjectModule
|
||||
|
||||
type PersianDateInputProps = {
|
||||
id?: string
|
||||
value: string
|
||||
onChange: (isoDate: string) => void
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
function isoToPickerValue(iso: string) {
|
||||
if (!iso) return null
|
||||
const date = new Date(iso)
|
||||
if (Number.isNaN(date.getTime())) return null
|
||||
return new DateObject({
|
||||
date,
|
||||
calendar: persian,
|
||||
locale: persian_fa,
|
||||
})
|
||||
}
|
||||
|
||||
function pickerValueToIso(value: InstanceType<typeof DateObject> | null) {
|
||||
if (!value) return ''
|
||||
const date = value.toDate()
|
||||
date.setHours(12, 0, 0, 0)
|
||||
return date.toISOString()
|
||||
}
|
||||
|
||||
export default function PersianDateInput({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
placeholder = 'انتخاب تاریخ',
|
||||
}: PersianDateInputProps) {
|
||||
return (
|
||||
<DatePicker
|
||||
id={id}
|
||||
value={isoToPickerValue(value)}
|
||||
onChange={(
|
||||
date:
|
||||
| InstanceType<typeof DateObject>
|
||||
| InstanceType<typeof DateObject>[]
|
||||
| null,
|
||||
) => {
|
||||
if (date instanceof DateObject) {
|
||||
onChange(pickerValueToIso(date))
|
||||
return
|
||||
}
|
||||
onChange('')
|
||||
}}
|
||||
calendar={persian}
|
||||
locale={persian_fa}
|
||||
format="YYYY/MM/DD"
|
||||
calendarPosition="bottom-center"
|
||||
containerClassName="persian-date-input"
|
||||
inputClass="persian-date-input__field"
|
||||
portal
|
||||
zIndex={200}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
editable={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -70,6 +70,7 @@ export type CreateMyOrderPayload = {
|
||||
}
|
||||
|
||||
export type SpecialOrderCategory = 'specialCake' | 'specialPastry'
|
||||
export type SpecialOrderDeliveryType = 'pickup' | 'shipping'
|
||||
|
||||
export type SpecialOrderImageInput = {
|
||||
url: string
|
||||
@@ -80,6 +81,18 @@ export type CreateMySpecialOrderPayload = {
|
||||
category: SpecialOrderCategory
|
||||
name: string
|
||||
description?: string
|
||||
neededDate?: string
|
||||
receiveTime?: string
|
||||
servings?: number
|
||||
cakeFlavor?: string
|
||||
filling?: string
|
||||
baloutSuggestedCombo?: boolean
|
||||
coating?: string
|
||||
designStyle?: string
|
||||
cakeText?: string
|
||||
decorations?: string
|
||||
deliveryType?: SpecialOrderDeliveryType
|
||||
branchId?: string
|
||||
images?: SpecialOrderImageInput[]
|
||||
}
|
||||
|
||||
@@ -91,9 +104,25 @@ export type MediaUploadResult = {
|
||||
|
||||
export type SpecialOrder = {
|
||||
id: string
|
||||
number: number
|
||||
code: string
|
||||
category: SpecialOrderCategory
|
||||
name: string
|
||||
description: string
|
||||
neededDate: string | null
|
||||
receiveTime: string
|
||||
servings: number | null
|
||||
cakeFlavor: string
|
||||
filling: string
|
||||
baloutSuggestedCombo: boolean
|
||||
coating: string
|
||||
designStyle: string
|
||||
cakeText: string
|
||||
decorations: string
|
||||
deliveryType: SpecialOrderDeliveryType | null
|
||||
deliveryTypeLabel: string | null
|
||||
branchId: string | null
|
||||
branchName: string
|
||||
createdAt: string
|
||||
images: Array<{
|
||||
id: string
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
export const SPONGE_FLAVORS = [
|
||||
'وانیلی',
|
||||
'شکلاتی',
|
||||
'ردولوت',
|
||||
'نسکافه',
|
||||
'سایر',
|
||||
] as const
|
||||
|
||||
export const FILLINGS = [
|
||||
'چیپس شکلات',
|
||||
'گاناش',
|
||||
'فندق',
|
||||
'پسته',
|
||||
'کارامل',
|
||||
'توتفرنگی',
|
||||
'موز',
|
||||
'موز و گردو',
|
||||
'گردو',
|
||||
'سایر',
|
||||
] as const
|
||||
|
||||
export const COATINGS = [
|
||||
'خامه شکلاتی',
|
||||
'خامه وانیلی',
|
||||
'خامه نسکافهای',
|
||||
'فوندانت',
|
||||
'روکش خاص',
|
||||
'بدون روکش',
|
||||
'انتخاب توسط قناد',
|
||||
] as const
|
||||
|
||||
export const DESIGN_STYLES = [
|
||||
'مینیمال',
|
||||
'لوکس',
|
||||
'کلاسیک',
|
||||
'مدرن',
|
||||
'رمانتیک',
|
||||
'گل و طبیعت',
|
||||
'فانتزی',
|
||||
'کودک',
|
||||
'ایرانی / سنتی',
|
||||
'سازمانی',
|
||||
'سایر',
|
||||
] as const
|
||||
|
||||
export const OTHER_OPTION = 'سایر'
|
||||
|
||||
export function resolveSelectValue(selected: string, custom: string) {
|
||||
if (!selected) return ''
|
||||
if (selected === OTHER_OPTION) return custom.trim() || OTHER_OPTION
|
||||
return selected
|
||||
}
|
||||
@@ -15,6 +15,109 @@
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.special-order-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 18px 16px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--color-gray-100);
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.special-order-section__title {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.special-order-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.special-order-hint {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.8;
|
||||
color: var(--color-secondary);
|
||||
padding-inline: 4px;
|
||||
}
|
||||
|
||||
.special-order-choice-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.special-order-choice {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--color-gray-100);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.special-order-choice input {
|
||||
margin-top: 3px;
|
||||
accent-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.special-order-choice span {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.special-order-choice strong {
|
||||
font-size: 0.92rem;
|
||||
font-weight: 500;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.special-order-choice em {
|
||||
font-style: normal;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.6;
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
|
||||
.special-order-choice.is-active {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.persian-date-input {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.persian-date-input__field {
|
||||
width: 100%;
|
||||
border: 1px solid var(--color-gray-100);
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
padding: 12px 14px;
|
||||
font-family: inherit;
|
||||
font-size: 15px;
|
||||
color: #000;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease;
|
||||
direction: ltr;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.persian-date-input__field:focus {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.special-order-form__alert {
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
@@ -31,6 +134,67 @@
|
||||
color: #2f6b4f;
|
||||
}
|
||||
|
||||
.special-order-success {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
padding: 28px 24px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(47, 107, 79, 0.18);
|
||||
background: rgba(47, 107, 79, 0.06);
|
||||
}
|
||||
|
||||
.special-order-success__icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
color: #2f6b4f;
|
||||
background: rgba(47, 107, 79, 0.12);
|
||||
}
|
||||
|
||||
.special-order-success__title {
|
||||
margin: 0;
|
||||
font-size: clamp(20px, 3vw, 28px);
|
||||
font-weight: 700;
|
||||
color: #2f6b4f;
|
||||
}
|
||||
|
||||
.special-order-success__text {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.9;
|
||||
color: var(--color-secondary);
|
||||
text-align: justify;
|
||||
}
|
||||
|
||||
.special-order-success__code {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(47, 107, 79, 0.16);
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.special-order-success__code span {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
|
||||
.special-order-success__code strong {
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
color: #000;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.special-order-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -47,7 +211,8 @@
|
||||
}
|
||||
|
||||
.special-order-field input,
|
||||
.special-order-field textarea {
|
||||
.special-order-field textarea,
|
||||
.special-order-field select {
|
||||
width: 100%;
|
||||
border: 1px solid var(--color-gray-100);
|
||||
border-radius: 12px;
|
||||
@@ -62,12 +227,20 @@
|
||||
}
|
||||
|
||||
.special-order-field input:focus,
|
||||
.special-order-field textarea:focus {
|
||||
.special-order-field textarea:focus,
|
||||
.special-order-field select:focus {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.special-order-field input[type='time'] {
|
||||
direction: ltr;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-height: 46px;
|
||||
}
|
||||
|
||||
.special-order-field--textarea textarea {
|
||||
min-height: 160px;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.special-order-radios {
|
||||
@@ -309,6 +482,10 @@
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.special-order-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.special-order-gallery__grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
+404
-38
@@ -2,11 +2,15 @@ import { useEffect, useState, type FormEvent } from 'react'
|
||||
import { ChevronLeft, ImagePlus, LogIn, Sparkles, X } from 'lucide-react'
|
||||
import PageHeader from '../components/PageHeader'
|
||||
import OffersHours from '../components/OffersHours'
|
||||
import PersianDateInput from '../components/PersianDateInput'
|
||||
import cakeIcon from '../assets/images/Cake.svg'
|
||||
import {
|
||||
createMySpecialOrder,
|
||||
listBranches,
|
||||
uploadMedia,
|
||||
type Branch,
|
||||
type SpecialOrderCategory,
|
||||
type SpecialOrderDeliveryType,
|
||||
} from '../lib/api'
|
||||
import {
|
||||
consumeAuthHashFromUrl,
|
||||
@@ -15,6 +19,14 @@ import {
|
||||
isAuthenticated,
|
||||
type AuthUser,
|
||||
} from '../lib/auth'
|
||||
import {
|
||||
COATINGS,
|
||||
DESIGN_STYLES,
|
||||
FILLINGS,
|
||||
OTHER_OPTION,
|
||||
resolveSelectValue,
|
||||
SPONGE_FLAVORS,
|
||||
} from '../lib/specialOrderOptions'
|
||||
import './SpecialOrderPage.css'
|
||||
|
||||
const CATEGORIES: { id: SpecialOrderCategory; label: string }[] = [
|
||||
@@ -22,6 +34,11 @@ const CATEGORIES: { id: SpecialOrderCategory; label: string }[] = [
|
||||
{ id: 'specialPastry', label: 'شیرینی ویژه' },
|
||||
]
|
||||
|
||||
const DELIVERY_OPTIONS: { id: SpecialOrderDeliveryType; label: string }[] = [
|
||||
{ id: 'pickup', label: 'دریافت حضوری' },
|
||||
{ id: 'shipping', label: 'ارسال با پیک' },
|
||||
]
|
||||
|
||||
const SUBMIT_COOLDOWN_MS = 60_000
|
||||
const COOLDOWN_STORAGE_KEY = 'balout.specialOrderCooldownUntil'
|
||||
|
||||
@@ -31,8 +48,8 @@ const SIDE_NOTES = [
|
||||
text: 'این فرم فقط برای ثبت درخواست است؛ هزینه بعداً با هماهنگی فروشگاه مشخص میشود.',
|
||||
},
|
||||
{
|
||||
title: 'کیک یا شیرینی سفارشی',
|
||||
text: 'ایده، تم تولد، تعداد و زمان تحویل را در توضیحات بنویسید تا دقیقتر راهنمایی شوید.',
|
||||
title: 'جزئیات دقیقتر = ساخت بهتر',
|
||||
text: 'مناسبت، تاریخ، تعداد نفرات و طعم را کامل بنویسید تا تیم بلوط دقیقتر راهنمایی کند.',
|
||||
},
|
||||
{
|
||||
title: 'تصاویر ایده',
|
||||
@@ -80,11 +97,29 @@ function cooldownSecondsLeft(until = readCooldownUntil()) {
|
||||
export default function SpecialOrderPage() {
|
||||
const [user, setUser] = useState<AuthUser | null>(() => getAuthUser())
|
||||
const [category, setCategory] = useState<SpecialOrderCategory>('specialCake')
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [occasion, setOccasion] = useState('')
|
||||
const [neededDate, setNeededDate] = useState('')
|
||||
const [receiveTime, setReceiveTime] = useState('')
|
||||
const [servings, setServings] = useState('')
|
||||
const [spongeFlavor, setSpongeFlavor] = useState('')
|
||||
const [spongeFlavorCustom, setSpongeFlavorCustom] = useState('')
|
||||
const [filling, setFilling] = useState('')
|
||||
const [fillingCustom, setFillingCustom] = useState('')
|
||||
const [coating, setCoating] = useState('')
|
||||
const [designStyle, setDesignStyle] = useState('')
|
||||
const [designStyleCustom, setDesignStyleCustom] = useState('')
|
||||
const [cakeText, setCakeText] = useState('')
|
||||
const [decorations, setDecorations] = useState('')
|
||||
const [deliveryType, setDeliveryType] =
|
||||
useState<SpecialOrderDeliveryType>('pickup')
|
||||
const [branchId, setBranchId] = useState('')
|
||||
const [branches, setBranches] = useState<Branch[]>([])
|
||||
const [gallery, setGallery] = useState<string[]>([])
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState('')
|
||||
const [submittedOrder, setSubmittedOrder] = useState<{
|
||||
id: string
|
||||
code: string
|
||||
} | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [cooldownLeft, setCooldownLeft] = useState(() => cooldownSecondsLeft())
|
||||
|
||||
@@ -105,11 +140,50 @@ export default function SpecialOrderPage() {
|
||||
}, [cooldownLeft])
|
||||
|
||||
const authenticated = Boolean(user) || isAuthenticated()
|
||||
|
||||
useEffect(() => {
|
||||
if (!authenticated) {
|
||||
setBranches([])
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
void listBranches()
|
||||
.then((items) => {
|
||||
if (!cancelled) setBranches(items)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setBranches([])
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [authenticated])
|
||||
|
||||
const loginUrl = getCustomerLoginUrl(
|
||||
`${window.location.origin}/special-order`,
|
||||
)
|
||||
const submitDisabled = saving || cooldownLeft > 0
|
||||
|
||||
function resetForm() {
|
||||
setCategory('specialCake')
|
||||
setOccasion('')
|
||||
setNeededDate('')
|
||||
setReceiveTime('')
|
||||
setServings('')
|
||||
setSpongeFlavor('')
|
||||
setSpongeFlavorCustom('')
|
||||
setFilling('')
|
||||
setFillingCustom('')
|
||||
setCoating('')
|
||||
setDesignStyle('')
|
||||
setDesignStyleCustom('')
|
||||
setCakeText('')
|
||||
setDecorations('')
|
||||
setDeliveryType('pickup')
|
||||
setBranchId('')
|
||||
setGallery([])
|
||||
}
|
||||
|
||||
async function handleFiles(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const files = Array.from(event.target.files ?? [])
|
||||
event.target.value = ''
|
||||
@@ -121,7 +195,6 @@ export default function SpecialOrderPage() {
|
||||
async function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
setError('')
|
||||
setSuccess('')
|
||||
|
||||
if (!authenticated) {
|
||||
window.location.assign(loginUrl)
|
||||
@@ -131,10 +204,48 @@ export default function SpecialOrderPage() {
|
||||
setError(`لطفاً ${cooldownLeft} ثانیه دیگر دوباره تلاش کنید`)
|
||||
return
|
||||
}
|
||||
if (!name.trim()) {
|
||||
setError('نام سفارش الزامی است')
|
||||
if (!occasion.trim()) {
|
||||
setError('مناسبت سفارش الزامی است')
|
||||
return
|
||||
}
|
||||
if (!neededDate) {
|
||||
setError('تاریخ موردنیاز الزامی است')
|
||||
return
|
||||
}
|
||||
if (!receiveTime.trim()) {
|
||||
setError('ساعت دریافت الزامی است')
|
||||
return
|
||||
}
|
||||
if (deliveryType === 'pickup' && !branchId) {
|
||||
setError('برای دریافت حضوری، انتخاب شعبه الزامی است')
|
||||
return
|
||||
}
|
||||
|
||||
const servingsValue = servings.trim() ? Number(servings) : undefined
|
||||
if (
|
||||
servings.trim() &&
|
||||
(!Number.isInteger(servingsValue) || (servingsValue ?? 0) < 1)
|
||||
) {
|
||||
setError('تعداد نفرات باید عدد صحیح و حداقل ۱ باشد')
|
||||
return
|
||||
}
|
||||
|
||||
if (spongeFlavor === OTHER_OPTION && !spongeFlavorCustom.trim()) {
|
||||
setError('لطفاً طعم اسفنج سفارشی را بنویسید')
|
||||
return
|
||||
}
|
||||
if (filling === OTHER_OPTION && !fillingCustom.trim()) {
|
||||
setError('لطفاً فیلینگ / مغزی سفارشی را بنویسید')
|
||||
return
|
||||
}
|
||||
if (designStyle === OTHER_OPTION && !designStyleCustom.trim()) {
|
||||
setError('لطفاً سبک طراحی سفارشی را بنویسید')
|
||||
return
|
||||
}
|
||||
|
||||
const cakeFlavorValue = resolveSelectValue(spongeFlavor, spongeFlavorCustom)
|
||||
const fillingValue = resolveSelectValue(filling, fillingCustom)
|
||||
const designStyleValue = resolveSelectValue(designStyle, designStyleCustom)
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
@@ -145,20 +256,28 @@ export default function SpecialOrderPage() {
|
||||
images.push({ url: uploaded.url, storageKey: uploaded.storageKey })
|
||||
}
|
||||
|
||||
await createMySpecialOrder({
|
||||
const created = await createMySpecialOrder({
|
||||
category,
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
name: occasion.trim(),
|
||||
neededDate,
|
||||
receiveTime: receiveTime.trim(),
|
||||
servings: servingsValue,
|
||||
cakeFlavor: cakeFlavorValue || undefined,
|
||||
filling: fillingValue || undefined,
|
||||
coating: coating || undefined,
|
||||
designStyle: designStyleValue || undefined,
|
||||
cakeText: cakeText.trim() || undefined,
|
||||
decorations: decorations.trim() || undefined,
|
||||
deliveryType,
|
||||
branchId: branchId || undefined,
|
||||
images,
|
||||
})
|
||||
|
||||
setName('')
|
||||
setDescription('')
|
||||
setGallery([])
|
||||
setCategory('specialCake')
|
||||
setSuccess(
|
||||
'سفارش ویژه شما ثبت شد. تیم بلوط بهزودی برای هماهنگی با شما تماس میگیرد.',
|
||||
)
|
||||
resetForm()
|
||||
setSubmittedOrder({
|
||||
id: created.id,
|
||||
code: created.code || `BL-S-${created.number}`,
|
||||
})
|
||||
const until = Date.now() + SUBMIT_COOLDOWN_MS
|
||||
writeCooldownUntil(until)
|
||||
setCooldownLeft(cooldownSecondsLeft(until))
|
||||
@@ -189,7 +308,26 @@ export default function SpecialOrderPage() {
|
||||
|
||||
<div className="max-container special-order-page__body">
|
||||
<div className="special-order-page__grid">
|
||||
{authenticated ? (
|
||||
{authenticated && submittedOrder ? (
|
||||
<div className="special-order-success" role="status">
|
||||
<div className="special-order-success__icon" aria-hidden>
|
||||
<Sparkles size={28} strokeWidth={1.5} />
|
||||
</div>
|
||||
<h2 className="special-order-success__title">سفارش ویژه ثبت شد</h2>
|
||||
<p className="special-order-success__text">
|
||||
درخواست شما با موفقیت ثبت شد. تیم بلوط بهزودی برای هماهنگی با شما
|
||||
تماس میگیرد.
|
||||
</p>
|
||||
<div className="special-order-success__code">
|
||||
<span>کد سفارش</span>
|
||||
<strong dir="ltr">{submittedOrder.code}</strong>
|
||||
</div>
|
||||
<a href="/" className="btn-primary">
|
||||
بازگشت به صفحه اصلی
|
||||
<ChevronLeft size={14} />
|
||||
</a>
|
||||
</div>
|
||||
) : authenticated ? (
|
||||
<form
|
||||
className="special-order-form"
|
||||
onSubmit={(e) => void handleSubmit(e)}
|
||||
@@ -199,14 +337,6 @@ export default function SpecialOrderPage() {
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
{success ? (
|
||||
<div
|
||||
className="special-order-form__alert special-order-form__alert--ok"
|
||||
role="status"
|
||||
>
|
||||
{success}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="special-order-field">
|
||||
<span>دستهبندی</span>
|
||||
@@ -227,26 +357,262 @@ export default function SpecialOrderPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="special-order-section">
|
||||
<h2 className="special-order-section__title">۱. اطلاعات سفارش</h2>
|
||||
<label className="special-order-field">
|
||||
<span>نام سفارش</span>
|
||||
<span>مناسبت سفارش</span>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="مثلاً کیک تولد با تم خاص"
|
||||
value={occasion}
|
||||
onChange={(e) => setOccasion(e.target.value)}
|
||||
placeholder="مثلاً تولد، سالگرد، مراسم کاری"
|
||||
disabled={saving}
|
||||
/>
|
||||
</label>
|
||||
<div className="special-order-row">
|
||||
<label className="special-order-field">
|
||||
<span>تاریخ موردنیاز (شمسی)</span>
|
||||
<PersianDateInput
|
||||
value={neededDate}
|
||||
onChange={setNeededDate}
|
||||
disabled={saving}
|
||||
placeholder="انتخاب تاریخ"
|
||||
/>
|
||||
</label>
|
||||
<label className="special-order-field">
|
||||
<span>ساعت دریافت</span>
|
||||
<input
|
||||
type="time"
|
||||
value={receiveTime}
|
||||
onChange={(e) => setReceiveTime(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<label className="special-order-field special-order-field--textarea">
|
||||
<span>توضیحات</span>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="جزئیات، سلیقه، تعداد، زمان تحویل و ..."
|
||||
rows={6}
|
||||
<section className="special-order-section">
|
||||
<h2 className="special-order-section__title">۲. اندازه کیک</h2>
|
||||
<label className="special-order-field">
|
||||
<span>تعداد نفرات</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={500}
|
||||
inputMode="numeric"
|
||||
value={servings}
|
||||
onChange={(e) => setServings(e.target.value)}
|
||||
placeholder="مثلاً ۱۲"
|
||||
disabled={saving}
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section className="special-order-section">
|
||||
<h2 className="special-order-section__title">۳. طعم کیک</h2>
|
||||
<label className="special-order-field">
|
||||
<span>طعم اسفنج</span>
|
||||
<select
|
||||
value={spongeFlavor}
|
||||
onChange={(e) => {
|
||||
setSpongeFlavor(e.target.value)
|
||||
if (e.target.value !== OTHER_OPTION) {
|
||||
setSpongeFlavorCustom('')
|
||||
}
|
||||
}}
|
||||
disabled={saving}
|
||||
>
|
||||
<option value="">انتخاب کنید</option>
|
||||
{SPONGE_FLAVORS.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{spongeFlavor === OTHER_OPTION ? (
|
||||
<label className="special-order-field">
|
||||
<span>طعم اسفنج (سایر)</span>
|
||||
<input
|
||||
value={spongeFlavorCustom}
|
||||
onChange={(e) => setSpongeFlavorCustom(e.target.value)}
|
||||
placeholder="طعم موردنظر را بنویسید"
|
||||
disabled={saving}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
<label className="special-order-field">
|
||||
<span>فیلینگ / مغزی</span>
|
||||
<select
|
||||
value={filling}
|
||||
onChange={(e) => {
|
||||
setFilling(e.target.value)
|
||||
if (e.target.value !== OTHER_OPTION) {
|
||||
setFillingCustom('')
|
||||
}
|
||||
}}
|
||||
disabled={saving}
|
||||
>
|
||||
<option value="">انتخاب کنید</option>
|
||||
{FILLINGS.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{filling === OTHER_OPTION ? (
|
||||
<label className="special-order-field">
|
||||
<span>فیلینگ / مغزی (سایر)</span>
|
||||
<input
|
||||
value={fillingCustom}
|
||||
onChange={(e) => setFillingCustom(e.target.value)}
|
||||
placeholder="فیلینگ موردنظر را بنویسید"
|
||||
disabled={saving}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
<label className="special-order-field">
|
||||
<span>روکش کیک</span>
|
||||
<select
|
||||
value={coating}
|
||||
onChange={(e) => setCoating(e.target.value)}
|
||||
disabled={saving}
|
||||
>
|
||||
<option value="">انتخاب کنید</option>
|
||||
{COATINGS.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section className="special-order-section">
|
||||
<h2 className="special-order-section__title">۴. نوشته و تزئینات</h2>
|
||||
<label className="special-order-field">
|
||||
<span>متن روی کیک</span>
|
||||
<input
|
||||
value={cakeText}
|
||||
onChange={(e) => setCakeText(e.target.value)}
|
||||
placeholder="متنی که روی کیک نوشته شود"
|
||||
disabled={saving}
|
||||
/>
|
||||
</label>
|
||||
<label className="special-order-field special-order-field--textarea">
|
||||
<span>تزئینات خاص در صورت نیاز</span>
|
||||
<textarea
|
||||
value={decorations}
|
||||
onChange={(e) => setDecorations(e.target.value)}
|
||||
placeholder="توضیح تم، رنگ، گل، فیگور و ..."
|
||||
rows={4}
|
||||
disabled={saving}
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section className="special-order-section">
|
||||
<h2 className="special-order-section__title">۵. سبک طراحی</h2>
|
||||
<label className="special-order-field">
|
||||
<span>سبک طراحی</span>
|
||||
<select
|
||||
value={designStyle}
|
||||
onChange={(e) => {
|
||||
setDesignStyle(e.target.value)
|
||||
if (e.target.value !== OTHER_OPTION) {
|
||||
setDesignStyleCustom('')
|
||||
}
|
||||
}}
|
||||
disabled={saving}
|
||||
>
|
||||
<option value="">انتخاب کنید</option>
|
||||
{DESIGN_STYLES.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{designStyle === OTHER_OPTION ? (
|
||||
<label className="special-order-field">
|
||||
<span>سبک طراحی (سایر)</span>
|
||||
<input
|
||||
value={designStyleCustom}
|
||||
onChange={(e) => setDesignStyleCustom(e.target.value)}
|
||||
placeholder="سبک موردنظر را بنویسید"
|
||||
disabled={saving}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="special-order-section">
|
||||
<h2 className="special-order-section__title">
|
||||
۶. اطلاعات دریافت و تماس
|
||||
</h2>
|
||||
<div className="special-order-field">
|
||||
<span>نحوه دریافت</span>
|
||||
<div className="special-order-radios" role="radiogroup">
|
||||
{DELIVERY_OPTIONS.map((item) => (
|
||||
<label key={item.id} className="special-order-radio">
|
||||
<input
|
||||
type="radio"
|
||||
name="special-order-delivery"
|
||||
value={item.id}
|
||||
checked={deliveryType === item.id}
|
||||
onChange={() => {
|
||||
setDeliveryType(item.id)
|
||||
if (item.id === 'shipping') setBranchId('')
|
||||
}}
|
||||
disabled={saving}
|
||||
/>
|
||||
<span>{item.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{deliveryType === 'pickup' ? (
|
||||
<div className="special-order-field">
|
||||
<span>انتخاب شعبه</span>
|
||||
{branches.length === 0 ? (
|
||||
<p className="special-order-hint">شعبهای ثبت نشده است.</p>
|
||||
) : (
|
||||
<div className="special-order-choice-list">
|
||||
{branches.map((branch) => (
|
||||
<label
|
||||
key={branch.id}
|
||||
className={`special-order-choice${
|
||||
branchId === branch.id ? ' is-active' : ''
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="special-order-branch"
|
||||
checked={branchId === branch.id}
|
||||
onChange={() => setBranchId(branch.id)}
|
||||
disabled={saving}
|
||||
/>
|
||||
<span>
|
||||
<strong>{branch.name}</strong>
|
||||
<em>
|
||||
{branch.district} — {branch.address}
|
||||
</em>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="special-order-hint">
|
||||
پس از ثبت درخواست، برای هماهنگی آدرس و زمان ارسال با شما تماس
|
||||
گرفته میشود.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="special-order-gallery">
|
||||
<div className="special-order-gallery__header">
|
||||
|
||||
Reference in New Issue
Block a user