From 27b73c981a0758c211b2725797c86ff23c5b182c Mon Sep 17 00:00:00 2001 From: Mahdi Date: Mon, 25 May 2026 19:13:28 +0330 Subject: [PATCH] Add time rezerv --- package-lock.json | 7 + package.json | 1 + src/api/availability.ts | 22 ++ src/api/bookings.ts | 15 ++ src/components/booking/BookingSlotPicker.vue | 193 ++++++++++++++++++ .../owner/BusinessAvailabilityEditor.vue | 175 ++++++++++++++++ src/components/ui/JalaliDateTimePicker.vue | 165 +++++++++++++++ src/utils/format.ts | 53 ++++- src/utils/jalali.ts | 128 ++++++++++++ src/views/app/AppBusinessDetailView.vue | 36 ++-- src/views/app/AppOwnerView.vue | 39 ++-- src/vite-env.d.ts | 14 ++ 12 files changed, 803 insertions(+), 45 deletions(-) create mode 100644 src/api/availability.ts create mode 100644 src/components/booking/BookingSlotPicker.vue create mode 100644 src/components/owner/BusinessAvailabilityEditor.vue create mode 100644 src/components/ui/JalaliDateTimePicker.vue create mode 100644 src/utils/jalali.ts diff --git a/package-lock.json b/package-lock.json index 7f16997..109c29b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@lucide/vue": "^1.16.0", "@vueuse/core": "^12.8.2", + "jalaali-js": "^1.2.8", "pinia": "^2.3.1", "vue": "^3.5.13", "vue-router": "^4.5.0" @@ -1795,6 +1796,12 @@ "node": ">=0.12.0" } }, + "node_modules/jalaali-js": { + "version": "1.2.8", + "resolved": "https://mirror-npm.runflare.com/jalaali-js/-/jalaali-js-1.2.8.tgz", + "integrity": "sha512-Jl/EwY84JwjW2wsWqeU4pNd22VNQ7EkjI36bDuLw31wH98WQW4fPjD0+mG7cdCK+Y8D6s9R3zLiQ3LaKu6bD8A==", + "license": "MIT" + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://mirror-npm.runflare.com/jiti/-/jiti-2.7.0.tgz", diff --git a/package.json b/package.json index a37f114..092f238 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "dependencies": { "@lucide/vue": "^1.16.0", "@vueuse/core": "^12.8.2", + "jalaali-js": "^1.2.8", "pinia": "^2.3.1", "vue": "^3.5.13", "vue-router": "^4.5.0" diff --git a/src/api/availability.ts b/src/api/availability.ts new file mode 100644 index 0000000..89f15d2 --- /dev/null +++ b/src/api/availability.ts @@ -0,0 +1,22 @@ +import { api } from './client'; + +export type AvailabilitySlot = { + id?: string; + dayOfWeek: number; + startTime: string; + endTime: string; +}; + +export function fetchBusinessAvailability(businessId: string) { + return api(`/businesses/${businessId}/availability`); +} + +export function setBusinessAvailability( + businessId: string, + slots: Omit[], +) { + return api(`/businesses/${businessId}/availability`, { + method: 'PUT', + body: JSON.stringify({ slots }), + }); +} diff --git a/src/api/bookings.ts b/src/api/bookings.ts index a853996..6819d02 100644 --- a/src/api/bookings.ts +++ b/src/api/bookings.ts @@ -34,3 +34,18 @@ export function cancelBooking(id: string) { export function confirmBooking(id: string) { return api(`/bookings/${id}/confirm`, { method: 'POST' }); } + +export function fetchAvailableSlots( + businessId: string, + businessServiceId: string, + dateYmd: string, +) { + const search = new URLSearchParams({ + businessId, + businessServiceId, + date: dateYmd, + }); + return api<{ date: string; slots: string[] }>( + `/bookings/available-slots?${search}`, + ); +} diff --git a/src/components/booking/BookingSlotPicker.vue b/src/components/booking/BookingSlotPicker.vue new file mode 100644 index 0000000..ab38d7d --- /dev/null +++ b/src/components/booking/BookingSlotPicker.vue @@ -0,0 +1,193 @@ + + + diff --git a/src/components/owner/BusinessAvailabilityEditor.vue b/src/components/owner/BusinessAvailabilityEditor.vue new file mode 100644 index 0000000..8549ff9 --- /dev/null +++ b/src/components/owner/BusinessAvailabilityEditor.vue @@ -0,0 +1,175 @@ + + + diff --git a/src/components/ui/JalaliDateTimePicker.vue b/src/components/ui/JalaliDateTimePicker.vue new file mode 100644 index 0000000..242f782 --- /dev/null +++ b/src/components/ui/JalaliDateTimePicker.vue @@ -0,0 +1,165 @@ + + + diff --git a/src/utils/format.ts b/src/utils/format.ts index 2d259ab..3b622c8 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -1,3 +1,44 @@ +/** Parses price from form input (string or number from type="number"). */ +export function parsePriceInput( + value: string | number | null | undefined, +): number | null { + if (value == null || value === '') { + return null; + } + if (typeof value === 'number') { + return Number.isFinite(value) && value >= 0 ? value : null; + } + const n = Number.parseFloat(String(value).replace(/,/g, '').trim()); + if (!Number.isFinite(n) || n < 0) { + return null; + } + return n; +} + +/** Parses optional duration in minutes from form input. */ +export function parseOptionalMinutes( + value: string | number | null | undefined, +): number | null | undefined { + if (value == null || value === '') { + return undefined; + } + if (typeof value === 'number') { + if (!Number.isFinite(value) || value <= 0) { + return null; + } + return Math.trunc(value); + } + const raw = String(value).trim(); + if (!raw) { + return undefined; + } + const n = Number.parseInt(raw, 10); + if (!Number.isFinite(n) || n <= 0) { + return null; + } + return n; +} + /** Formats API numeric price string (e.g. "150000.00") for display. */ export function formatPriceToman(price: string): string { const value = Number.parseFloat(price); @@ -12,7 +53,8 @@ export function formatDate(value?: string | Date): string { const date = typeof value === 'string' ? new Date(value) : value; if (Number.isNaN(date.getTime())) return '—'; return new Intl.DateTimeFormat('fa-IR', { - dateStyle: 'short', + calendar: 'persian', + dateStyle: 'medium', timeStyle: 'short', }).format(date); } @@ -27,15 +69,6 @@ export function bookingStatusLabel(status: string): string { return map[status] ?? status; } -/** Default value for datetime-local (at least ~90 min ahead). */ -export function defaultBookingDateTimeLocal(): string { - const d = new Date(); - d.setMinutes(d.getMinutes() + 90); - d.setSeconds(0, 0); - const pad = (n: number) => String(n).padStart(2, '0'); - return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; -} - export function formatDuration(minutes: number | null, fallback?: number): string { const m = minutes ?? fallback; if (!m) { diff --git a/src/utils/jalali.ts b/src/utils/jalali.ts new file mode 100644 index 0000000..5bb0f64 --- /dev/null +++ b/src/utils/jalali.ts @@ -0,0 +1,128 @@ +import { jalaaliMonthLength, toGregorian, toJalaali } from 'jalaali-js'; + +/** Must match API default BOOKING_MIN_LEAD_MINUTES (60). */ +export const BOOKING_MIN_LEAD_MINUTES = 60; + +export const JALALI_MONTH_NAMES = [ + 'فروردین', + 'اردیبهشت', + 'خرداد', + 'تیر', + 'مرداد', + 'شهریور', + 'مهر', + 'آبان', + 'آذر', + 'دی', + 'بهمن', + 'اسفند', +] as const; + +export type JalaliDateTimeParts = { + jy: number; + jm: number; + jd: number; + hour: number; + minute: number; +}; + +export function gregorianToJalaliParts(date: Date): JalaliDateTimeParts { + const { jy, jm, jd } = toJalaali( + date.getFullYear(), + date.getMonth() + 1, + date.getDate(), + ); + return { + jy, + jm, + jd, + hour: date.getHours(), + minute: date.getMinutes(), + }; +} + +export function jalaliPartsToDate(parts: JalaliDateTimeParts): Date { + const { gy, gm, gd } = toGregorian(parts.jy, parts.jm, parts.jd); + return new Date(gy, gm - 1, gd, parts.hour, parts.minute, 0, 0); +} + +export function jalaliPartsToIso(parts: JalaliDateTimeParts): string { + return jalaliPartsToDate(parts).toISOString(); +} + +export function jalaliToGregorianYmd(parts: { + jy: number; + jm: number; + jd: number; +}): string { + const { gy, gm, gd } = toGregorian(parts.jy, parts.jm, parts.jd); + return `${gy}-${String(gm).padStart(2, '0')}-${String(gd).padStart(2, '0')}`; +} + +export function isJalaliBeforeToday(parts: { + jy: number; + jm: number; + jd: number; +}): boolean { + const today = gregorianToJalaliParts(new Date()); + if (parts.jy < today.jy) return true; + if (parts.jy > today.jy) return false; + if (parts.jm < today.jm) return true; + if (parts.jm > today.jm) return false; + return parts.jd < today.jd; +} + +export function firstAllowedJalaliDate(): JalaliDateTimeParts { + return gregorianToJalaliParts(minBookingDate()); +} + +export function isoToJalaliParts(iso: string): JalaliDateTimeParts | null { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) { + return null; + } + return gregorianToJalaliParts(date); +} + +export function daysInJalaliMonth(jy: number, jm: number): number { + return jalaaliMonthLength(jy, jm); +} + +export function minBookingDate(minLeadMinutes = BOOKING_MIN_LEAD_MINUTES): Date { + return new Date(Date.now() + minLeadMinutes * 60 * 1000); +} + +/** Earliest allowed booking moment (default: now + 1 hour). */ +export function defaultBookingDate( + minLeadMinutes = BOOKING_MIN_LEAD_MINUTES, +): JalaliDateTimeParts { + const d = minBookingDate(minLeadMinutes); + const minutes = d.getMinutes(); + const roundUp = (15 - (minutes % 15)) % 15; + if (roundUp > 0) { + d.setMinutes(minutes + roundUp); + } + d.setSeconds(0, 0); + return gregorianToJalaliParts(d); +} + +export function isBookingTimeValid( + scheduled: Date, + minLeadMinutes = BOOKING_MIN_LEAD_MINUTES, +): boolean { + return scheduled.getTime() >= minBookingDate(minLeadMinutes).getTime(); +} + +export function formatJalaliDateTime(parts: JalaliDateTimeParts): string { + const month = JALALI_MONTH_NAMES[parts.jm - 1] ?? String(parts.jm); + const time = `${String(parts.hour).padStart(2, '0')}:${String(parts.minute).padStart(2, '0')}`; + return `${parts.jd} ${month} ${parts.jy} — ${time}`; +} + +export function clampJalaliDay(parts: JalaliDateTimeParts): JalaliDateTimeParts { + const maxDay = daysInJalaliMonth(parts.jy, parts.jm); + return { + ...parts, + jd: Math.min(Math.max(1, parts.jd), maxDay), + }; +} diff --git a/src/views/app/AppBusinessDetailView.vue b/src/views/app/AppBusinessDetailView.vue index 10d568d..f6e79e4 100644 --- a/src/views/app/AppBusinessDetailView.vue +++ b/src/views/app/AppBusinessDetailView.vue @@ -127,19 +127,12 @@

-
- - -

حداقل یک ساعت بعد از الان

-
+