diff --git a/package-lock.json b/package-lock.json index 605b104..5f7d506 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 7710624..796026e 100644 --- a/package.json +++ b/package.json @@ -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": { @@ -24,4 +25,4 @@ "typescript": "~6.0.2", "vite": "^8.2.0" } -} \ No newline at end of file +} diff --git a/src/components/PersianDateInput.tsx b/src/components/PersianDateInput.tsx new file mode 100644 index 0000000..2b88969 --- /dev/null +++ b/src/components/PersianDateInput.tsx @@ -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 | 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 ( + + | InstanceType[] + | 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} + /> + ) +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 62bb382..d128e59 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -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 diff --git a/src/lib/specialOrderOptions.ts b/src/lib/specialOrderOptions.ts new file mode 100644 index 0000000..d39e120 --- /dev/null +++ b/src/lib/specialOrderOptions.ts @@ -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 +} diff --git a/src/pages/SpecialOrderPage.css b/src/pages/SpecialOrderPage.css index 75e8d92..834dae9 100644 --- a/src/pages/SpecialOrderPage.css +++ b/src/pages/SpecialOrderPage.css @@ -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)); } diff --git a/src/pages/SpecialOrderPage.tsx b/src/pages/SpecialOrderPage.tsx index 30152e6..0fa4ec1 100644 --- a/src/pages/SpecialOrderPage.tsx +++ b/src/pages/SpecialOrderPage.tsx @@ -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(() => getAuthUser()) const [category, setCategory] = useState('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('pickup') + const [branchId, setBranchId] = useState('') + const [branches, setBranches] = useState([]) const [gallery, setGallery] = useState([]) 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) { 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() {
- {authenticated ? ( + {authenticated && submittedOrder ? ( +
+
+ +
+

سفارش ویژه ثبت شد

+

+ درخواست شما با موفقیت ثبت شد. تیم بلوط به‌زودی برای هماهنگی با شما + تماس می‌گیرد. +

+
+ کد سفارش + {submittedOrder.code} +
+ + بازگشت به صفحه اصلی + + +
+ ) : authenticated ? (
void handleSubmit(e)} @@ -199,14 +337,6 @@ export default function SpecialOrderPage() { {error}
) : null} - {success ? ( -
- {success} -
- ) : null}
دسته‌بندی @@ -227,26 +357,262 @@ export default function SpecialOrderPage() {
- +
+

۱. اطلاعات سفارش

+ +
+ + +
+
-