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 @@
+
+
+
+
+
+
+ سال
+
+
+
+ ماه
+
+
+
+ روز
+
+
+
+
+
در حال بارگذاری ساعات…
+
{{ slotsError }}
+
+ برای این روز ساعت خالی وجود ندارد. روز دیگری انتخاب کنید یا با کسبوکار هماهنگ کنید.
+
+
+
ساعتهای قابل رزرو (شمسی):
+
+
+
+
+
+
+ انتخاب شده: {{ selectedLabel }}
+
+
+
+
+
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 @@
+
+
+
+
ساعات پذیرش آنلاین
+
+ مشتری فقط در بازههای زیر میتواند رزرو کند. برای هر روز میتوانید چند بازه تعریف کنید.
+
+
+
+ {{ error }}
+ {{ message }}
+
+ در حال بارگذاری…
+
+
+
+
{{ day.label }}
+
+
+ -
+ {{ slot.startTime }} – {{ slot.endTime }}
+
+
+
+
بازهای ثبت نشده
+
+
+
+ از
+
+
+
+ تا
+
+
+
+ افزودن بازه
+
+
+
+
+
ذخیره ساعات پذیرش
+
+
+
+
+
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 @@
+
+
+
+
+
+
+ سال
+
+
+
+ ماه
+
+
+
+ روز
+
+
+
+
+
+
+ ساعت
+
+
+
+ دقیقه
+
+
+
+
+
+ {{ preview }}
+
+ — زمان باید حداقل {{ minLeadMinutes }} دقیقه بعد از الان باشد
+
+
+
+
+
+
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 @@
-
-
-
-
حداقل یک ساعت بعد از الان
-
+