fix: AppButton import in owner panel and service-pricing types
This commit is contained in:
Vendored
+4
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["Vue.volar"],
|
||||||
|
"unwantedRecommendations": ["octref.vetur"]
|
||||||
|
}
|
||||||
Vendored
+11
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"typescript.tsdk": "node_modules/typescript/lib",
|
||||||
|
"typescript.enablePromptUseWorkspaceTsdk": true,
|
||||||
|
"vetur.validation.script": false,
|
||||||
|
"vetur.validation.template": false,
|
||||||
|
"vetur.validation.style": false,
|
||||||
|
"vetur.useWorkspaceDependencies": true,
|
||||||
|
"vetur.ignoreProjectWarning": true,
|
||||||
|
"vetur.experimental.templateInterpolationService": false,
|
||||||
|
"vue.server.hybridMode": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"target": "ES2022",
|
||||||
|
"jsx": "preserve"
|
||||||
|
},
|
||||||
|
"include": ["shims-vue.d.ts", "src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||||
|
"exclude": ["dist", "dist-admin", "node_modules"]
|
||||||
|
}
|
||||||
Vendored
+5
@@ -0,0 +1,5 @@
|
|||||||
|
declare module '*.vue' {
|
||||||
|
import type { DefineComponent } from 'vue';
|
||||||
|
const component: DefineComponent<object, object, unknown>;
|
||||||
|
export default component;
|
||||||
|
}
|
||||||
+43
-17
@@ -20,6 +20,36 @@ export function getApiToken(): string | null {
|
|||||||
return localStorage.getItem('montime_access_token');
|
return localStorage.getItem('montime_access_token');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveApiErrorMessage(
|
||||||
|
status: number,
|
||||||
|
statusText: string,
|
||||||
|
body: { message?: string | string[] } | null,
|
||||||
|
): string {
|
||||||
|
if (Array.isArray(body?.message)) {
|
||||||
|
const lines = body.message.map(String);
|
||||||
|
const timeErrors = lines.filter(
|
||||||
|
(l) => /startTime|endTime/.test(l) && /regular expression|HH:mm/i.test(l),
|
||||||
|
);
|
||||||
|
if (timeErrors.length > 0) {
|
||||||
|
return 'فرمت ساعت نامعتبر است. لطفاً دوباره بازه را اضافه و ذخیره کنید.';
|
||||||
|
}
|
||||||
|
return lines.join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = typeof body?.message === 'string' ? body.message : statusText;
|
||||||
|
const genericServer =
|
||||||
|
status >= 500 &&
|
||||||
|
(!raw ||
|
||||||
|
raw === 'Internal Server Error' ||
|
||||||
|
/^5\d\d/.test(String(raw)));
|
||||||
|
|
||||||
|
if (genericServer) {
|
||||||
|
return 'ارتباط با سرور برقرار نشد. اتصال اینترنت را بررسی کنید یا چند لحظه بعد دوباره تلاش کنید.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return raw || 'خطا در ارتباط با سرور';
|
||||||
|
}
|
||||||
|
|
||||||
export async function api<T>(path: string, options: ApiOptions = {}): Promise<T> {
|
export async function api<T>(path: string, options: ApiOptions = {}): Promise<T> {
|
||||||
const token = options.token ?? getApiToken() ?? undefined;
|
const token = options.token ?? getApiToken() ?? undefined;
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
@@ -33,10 +63,18 @@ export async function api<T>(path: string, options: ApiOptions = {}): Promise<T>
|
|||||||
|
|
||||||
const { token: _token, ...fetchOptions } = options;
|
const { token: _token, ...fetchOptions } = options;
|
||||||
|
|
||||||
const res = await fetch(`${baseUrl}${path}`, {
|
let res: Response;
|
||||||
...fetchOptions,
|
try {
|
||||||
headers,
|
res = await fetch(`${baseUrl}${path}`, {
|
||||||
});
|
...fetchOptions,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
throw new ApiError(
|
||||||
|
0,
|
||||||
|
'ارتباط با سرور برقرار نشد. اتصال اینترنت را بررسی کنید یا چند لحظه بعد دوباره تلاش کنید.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
logApiServerTiming(path, res);
|
logApiServerTiming(path, res);
|
||||||
|
|
||||||
@@ -44,19 +82,7 @@ export async function api<T>(path: string, options: ApiOptions = {}): Promise<T>
|
|||||||
const body = (await res.json().catch(() => null)) as
|
const body = (await res.json().catch(() => null)) as
|
||||||
| { message?: string | string[] }
|
| { message?: string | string[] }
|
||||||
| null;
|
| null;
|
||||||
let msg: string;
|
const msg = resolveApiErrorMessage(res.status, res.statusText, body);
|
||||||
if (Array.isArray(body?.message)) {
|
|
||||||
const lines = body.message.map(String);
|
|
||||||
const timeErrors = lines.filter((l) =>
|
|
||||||
/startTime|endTime/.test(l) && /regular expression|HH:mm/i.test(l),
|
|
||||||
);
|
|
||||||
msg =
|
|
||||||
timeErrors.length > 0
|
|
||||||
? 'فرمت ساعت نامعتبر است. لطفاً دوباره بازه را اضافه و ذخیره کنید.'
|
|
||||||
: lines.join(' · ');
|
|
||||||
} else {
|
|
||||||
msg = body?.message ?? res.statusText;
|
|
||||||
}
|
|
||||||
throw new ApiError(res.status, msg);
|
throw new ApiError(res.status, msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,9 +27,19 @@
|
|||||||
مشتری فقط در بازههای تعریفشده میتواند رزرو کند. ابتدا حالت کلی را انتخاب کنید.
|
مشتری فقط در بازههای تعریفشده میتواند رزرو کند. ابتدا حالت کلی را انتخاب کنید.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p v-if="error" class="rounded-xl bg-red-500/10 px-3 py-2 text-sm text-red-600">
|
<div
|
||||||
{{ error }}
|
v-if="error"
|
||||||
</p>
|
class="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-600"
|
||||||
|
>
|
||||||
|
<p class="min-w-0 flex-1">{{ error }}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="shrink-0 rounded-lg border border-red-500/30 px-3 py-1 text-xs font-medium text-red-700 transition hover:bg-red-500/10"
|
||||||
|
@click="load"
|
||||||
|
>
|
||||||
|
تلاش مجدد
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<p
|
<p
|
||||||
v-if="message"
|
v-if="message"
|
||||||
class="rounded-xl bg-brand-500/10 px-3 py-2 text-sm text-brand-700 dark:text-brand-500"
|
class="rounded-xl bg-brand-500/10 px-3 py-2 text-sm text-brand-700 dark:text-brand-500"
|
||||||
@@ -124,26 +134,33 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-2xl border border-border bg-surface-2 p-4">
|
<div class="overflow-hidden rounded-2xl border border-border bg-surface-2">
|
||||||
<label class="flex items-center justify-between gap-3">
|
<label
|
||||||
<span class="text-sm text-text">پذیرش آنلاین این روز</span>
|
class="flex cursor-pointer items-center justify-between gap-3 border-b border-border px-4 py-3 transition hover:bg-surface-3/60"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span class="text-sm font-medium text-text">پذیرش آنلاین این روز</span>
|
||||||
|
<p class="mt-0.5 text-xs text-muted">
|
||||||
|
{{ dayActive[activeDay] ? 'مشتری میتواند در این روز رزرو کند' : 'این روز از رزرو آنلاین خارج است' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="h-5 w-5 rounded border-border text-brand-600 focus:ring-brand-500/30"
|
class="h-5 w-5 shrink-0 rounded border-border text-brand-600 focus:ring-brand-500/30"
|
||||||
:checked="dayActive[activeDay]"
|
:checked="dayActive[activeDay]"
|
||||||
@change="toggleDayActive(activeDay)"
|
@change="toggleDayActive(activeDay)"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div v-if="dayActive[activeDay]" class="mt-4">
|
<div v-if="dayActive[activeDay]" class="p-4">
|
||||||
<TimeRangeList
|
<TimeRangeList
|
||||||
:ranges="slotsByDay[activeDay] ?? []"
|
:ranges="slotsByDay[activeDay] ?? []"
|
||||||
@add="(start, end) => addCustomRange(activeDay, start, end)"
|
@add="(start, end) => addCustomRange(activeDay, start, end)"
|
||||||
@remove="(i) => removeSlot(activeDay, i)"
|
@remove="(i) => removeSlot(activeDay, i)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p v-else class="mt-3 text-center text-sm text-muted">
|
<p v-else class="px-4 py-8 text-center text-sm text-muted">
|
||||||
این روز غیرفعال است — رزرو آنلاین نمایش داده نمیشود.
|
برای فعالسازی، گزینه بالا را روشن کنید.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,92 +1,74 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="rootRef" class="relative min-w-0 flex-1">
|
<div class="flex items-center gap-2">
|
||||||
<button
|
<label class="sr-only">ساعت</label>
|
||||||
type="button"
|
<select
|
||||||
class="flex w-full items-center justify-center rounded-xl border border-border bg-surface-2 px-2 py-2.5 transition hover:border-brand-500/40"
|
v-model="hour"
|
||||||
:aria-expanded="open"
|
class="min-w-0 flex-1 rounded-xl border border-border bg-surface-2 px-2 py-3 text-center text-lg font-semibold tabular-nums text-text outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"
|
||||||
@click.stop="open = !open"
|
dir="ltr"
|
||||||
>
|
>
|
||||||
<span class="text-lg font-semibold tabular-nums text-text" dir="ltr">
|
<option v-for="h in hours" :key="h" :value="h">{{ h }}</option>
|
||||||
{{ hour }}:{{ minute }}
|
</select>
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div
|
<span class="shrink-0 text-xl font-semibold text-muted" aria-hidden="true">:</span>
|
||||||
v-if="open"
|
|
||||||
class="absolute inset-x-0 top-full z-50 mt-1 overflow-hidden rounded-xl border border-border bg-surface shadow-lg"
|
<label class="sr-only">دقیقه</label>
|
||||||
@click.stop
|
<select
|
||||||
|
v-model="minute"
|
||||||
|
class="min-w-0 flex-1 rounded-xl border border-border bg-surface-2 px-2 py-3 text-center text-lg font-semibold tabular-nums text-text outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"
|
||||||
|
dir="ltr"
|
||||||
>
|
>
|
||||||
<div class="flex">
|
<option v-for="m in minuteOptions" :key="m" :value="m">{{ m }}</option>
|
||||||
<div class="min-w-0 flex-1 border-e border-border">
|
</select>
|
||||||
<p class="border-b border-border py-1 text-center text-xs text-muted">ساعت</p>
|
|
||||||
<ul class="max-h-36 overflow-y-auto overscroll-contain">
|
|
||||||
<li v-for="h in hours" :key="h">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="w-full px-2 py-2 text-center text-sm tabular-nums transition"
|
|
||||||
:class="
|
|
||||||
h === hour
|
|
||||||
? 'bg-brand-500/15 font-semibold text-brand-700 dark:text-brand-500'
|
|
||||||
: 'text-text hover:bg-surface-3'
|
|
||||||
"
|
|
||||||
@click="pickHour(h)"
|
|
||||||
>
|
|
||||||
{{ h }}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<p class="border-b border-border py-1 text-center text-xs text-muted">دقیقه</p>
|
|
||||||
<ul class="max-h-36 overflow-y-auto overscroll-contain">
|
|
||||||
<li v-for="m in minutes" :key="m">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="w-full px-2 py-2 text-center text-sm tabular-nums transition"
|
|
||||||
:class="
|
|
||||||
m === minute
|
|
||||||
? 'bg-brand-500/15 font-semibold text-brand-700 dark:text-brand-500'
|
|
||||||
: 'text-text hover:bg-surface-3'
|
|
||||||
"
|
|
||||||
@click="pickMinute(m)"
|
|
||||||
>
|
|
||||||
{{ m }}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script lang="ts">
|
||||||
import { onMounted, onUnmounted, ref } from 'vue';
|
import { defineComponent } from 'vue';
|
||||||
|
|
||||||
const hours = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'));
|
export default defineComponent({
|
||||||
const minutes = Array.from({ length: 60 }, (_, i) => String(i).padStart(2, '0'));
|
name: 'HmTimePickers',
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
const hour = defineModel<string>('hour', { required: true });
|
<script setup lang="ts">
|
||||||
const minute = defineModel<string>('minute', { required: true });
|
import { computed } from 'vue';
|
||||||
|
|
||||||
const open = ref(false);
|
const HOUR_OPTIONS = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'));
|
||||||
const rootRef = ref<HTMLElement | null>(null);
|
const MINUTE_STEPS = Array.from({ length: 12 }, (_, i) => String(i * 5).padStart(2, '0'));
|
||||||
|
|
||||||
function pickHour(v: string) {
|
const model = defineModel<string>({ required: true });
|
||||||
hour.value = v;
|
|
||||||
|
const hours = HOUR_OPTIONS;
|
||||||
|
|
||||||
|
function parseHm(value: string): { hour: string; minute: string } {
|
||||||
|
const [hour = '09', minute = '00'] = value.split(':');
|
||||||
|
return {
|
||||||
|
hour: hour.padStart(2, '0'),
|
||||||
|
minute: minute.padStart(2, '0'),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hour = computed({
|
||||||
|
get: () => parseHm(model.value).hour,
|
||||||
|
set: (value: string) => {
|
||||||
|
const { minute: currentMinute } = parseHm(model.value);
|
||||||
|
model.value = `${value.padStart(2, '0')}:${currentMinute}`;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
function pickMinute(v: string) {
|
const minute = computed({
|
||||||
minute.value = v;
|
get: () => parseHm(model.value).minute,
|
||||||
}
|
set: (value: string) => {
|
||||||
|
const { hour: currentHour } = parseHm(model.value);
|
||||||
|
model.value = `${currentHour}:${value.padStart(2, '0')}`;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
function onDocumentClick(e: MouseEvent) {
|
const minuteOptions = computed(() => {
|
||||||
if (!open.value || !rootRef.value) return;
|
const current = minute.value;
|
||||||
if (!rootRef.value.contains(e.target as Node)) {
|
if (MINUTE_STEPS.includes(current)) {
|
||||||
open.value = false;
|
return MINUTE_STEPS;
|
||||||
}
|
}
|
||||||
}
|
return [...MINUTE_STEPS, current].sort((a, b) => Number(a) - Number(b));
|
||||||
|
});
|
||||||
onMounted(() => document.addEventListener('click', onDocumentClick));
|
|
||||||
onUnmounted(() => document.removeEventListener('click', onDocumentClick));
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -30,32 +30,103 @@
|
|||||||
هنوز بازهای ثبت نشده
|
هنوز بازهای ثبت نشده
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="rounded-2xl border border-dashed border-brand-500/40 bg-brand-500/5 p-3">
|
<div class="overflow-hidden rounded-2xl border border-border bg-surface-3">
|
||||||
<p class="mb-2 text-xs font-medium text-muted">بازه جدید</p>
|
<div class="border-b border-border bg-brand-500/5 px-4 py-3">
|
||||||
<div class="flex items-center gap-2">
|
<p class="text-sm font-semibold text-text">افزودن بازه جدید</p>
|
||||||
<span class="shrink-0 text-xs font-medium text-muted">از</span>
|
<p class="mt-0.5 text-xs text-muted">ساعت شروع و پایان را جداگانه انتخاب کنید</p>
|
||||||
<HmTimePickers v-model:hour="startH" v-model:minute="startM" />
|
</div>
|
||||||
<span class="shrink-0 text-xs font-medium text-muted">تا</span>
|
|
||||||
<HmTimePickers v-model:hour="endH" v-model:minute="endM" />
|
<div class="space-y-4 p-4">
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-center gap-4 rounded-2xl border border-brand-500/20 bg-surface-2 px-4 py-5"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="mb-1 text-xs font-medium text-muted">از</p>
|
||||||
|
<p class="text-3xl font-bold tabular-nums tracking-tight text-text" dir="ltr">
|
||||||
|
{{ startTime }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-brand-500/10 text-brand-700 dark:text-brand-500"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<AppIcon name="arrow-right" size="sm" class="rotate-180" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="mb-1 text-xs font-medium text-muted">تا</p>
|
||||||
|
<p class="text-3xl font-bold tabular-nums tracking-tight text-text" dir="ltr">
|
||||||
|
{{ endTime }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p class="mb-2 text-xs font-medium text-muted">انتخاب سریع</p>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
v-for="preset in presets"
|
||||||
|
:key="preset.label"
|
||||||
|
type="button"
|
||||||
|
class="rounded-full border px-3 py-1.5 text-xs font-medium transition"
|
||||||
|
:class="
|
||||||
|
isPresetActive(preset)
|
||||||
|
? 'border-brand-500 bg-brand-500/15 text-brand-700 dark:text-brand-500'
|
||||||
|
: 'border-border bg-surface-2 text-text hover:border-brand-500/40'
|
||||||
|
"
|
||||||
|
@click="applyPreset(preset)"
|
||||||
|
>
|
||||||
|
{{ preset.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div class="rounded-xl border border-border bg-surface-2 p-3">
|
||||||
|
<div class="mb-2 flex items-center gap-2">
|
||||||
|
<span class="flex h-6 w-6 items-center justify-center rounded-full bg-brand-500/15 text-xs font-bold text-brand-700 dark:text-brand-500">
|
||||||
|
۱
|
||||||
|
</span>
|
||||||
|
<p class="text-sm font-medium text-text">ساعت شروع</p>
|
||||||
|
</div>
|
||||||
|
<HmTimePickers v-model="startTime" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-border bg-surface-2 p-3">
|
||||||
|
<div class="mb-2 flex items-center gap-2">
|
||||||
|
<span class="flex h-6 w-6 items-center justify-center rounded-full bg-brand-500/15 text-xs font-bold text-brand-700 dark:text-brand-500">
|
||||||
|
۲
|
||||||
|
</span>
|
||||||
|
<p class="text-sm font-medium text-text">ساعت پایان</p>
|
||||||
|
</div>
|
||||||
|
<HmTimePickers v-model="endTime" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="localError" class="rounded-xl bg-red-500/10 px-3 py-2 text-xs text-red-600">
|
||||||
|
{{ localError }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-center justify-center gap-2 rounded-xl bg-brand-600 py-3 text-sm font-semibold text-white shadow-sm transition hover:bg-brand-700 active:scale-[0.99]"
|
||||||
|
@click="onAdd"
|
||||||
|
>
|
||||||
|
<AppIcon name="plus" size="sm" />
|
||||||
|
افزودن این بازه
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="localError" class="mt-2 text-xs text-red-600">{{ localError }}</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="mt-3 flex w-full items-center justify-center gap-2 rounded-xl border border-brand-500/30 bg-surface-2 py-2.5 text-sm font-medium text-brand-700 transition hover:bg-brand-500/10 dark:text-brand-500"
|
|
||||||
@click="onAdd"
|
|
||||||
>
|
|
||||||
<AppIcon name="plus" size="sm" />
|
|
||||||
افزودن بازه
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import AppIcon from '@/components/ui/AppIcon.vue';
|
import AppIcon from '../ui/app-icon';
|
||||||
import HmTimePickers from '@/components/owner/HmTimePickers.vue';
|
import HmTimePickers from './hm-time-pickers';
|
||||||
import { isHmRangeValid, normalizeHmTime } from '@/utils/format';
|
import { isHmRangeValid, normalizeHmTime } from '../../utils/format';
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
ranges: { startTime: string; endTime: string }[];
|
ranges: { startTime: string; endTime: string }[];
|
||||||
@@ -66,20 +137,31 @@ const emit = defineEmits<{
|
|||||||
remove: [index: number];
|
remove: [index: number];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const startH = ref('09');
|
const presets = [
|
||||||
const startM = ref('00');
|
{ label: 'صبح · ۰۹ تا ۱۳', start: '09:00', end: '13:00' },
|
||||||
const endH = ref('13');
|
{ label: 'ظهر · ۱۳ تا ۱۷', start: '13:00', end: '17:00' },
|
||||||
const endM = ref('00');
|
{ label: 'عصر · ۱۷ تا ۲۱', start: '17:00', end: '21:00' },
|
||||||
|
{ label: 'تمامروز · ۰۹ تا ۲۱', start: '09:00', end: '21:00' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const startTime = ref('09:00');
|
||||||
|
const endTime = ref('13:00');
|
||||||
const localError = ref('');
|
const localError = ref('');
|
||||||
|
|
||||||
function toHm(h: string, m: string): string {
|
function isPresetActive(preset: (typeof presets)[number]): boolean {
|
||||||
return `${h.padStart(2, '0')}:${m.padStart(2, '0')}`;
|
return startTime.value === preset.start && endTime.value === preset.end;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPreset(preset: (typeof presets)[number]) {
|
||||||
|
startTime.value = preset.start;
|
||||||
|
endTime.value = preset.end;
|
||||||
|
localError.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function onAdd() {
|
function onAdd() {
|
||||||
localError.value = '';
|
localError.value = '';
|
||||||
const start = normalizeHmTime(toHm(startH.value, startM.value));
|
const start = normalizeHmTime(startTime.value);
|
||||||
const end = normalizeHmTime(toHm(endH.value, endM.value));
|
const end = normalizeHmTime(endTime.value);
|
||||||
if (!start || !end) {
|
if (!start || !end) {
|
||||||
localError.value = 'فرمت ساعت نامعتبر است.';
|
localError.value = 'فرمت ساعت نامعتبر است.';
|
||||||
return;
|
return;
|
||||||
@@ -89,5 +171,7 @@ function onAdd() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
emit('add', start, end);
|
emit('add', start, end);
|
||||||
|
startTime.value = '09:00';
|
||||||
|
endTime.value = '13:00';
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import type { DefineComponent } from 'vue';
|
||||||
|
import HmTimePickers from './HmTimePickers.vue';
|
||||||
|
|
||||||
|
export default HmTimePickers as DefineComponent<{
|
||||||
|
modelValue: string;
|
||||||
|
}>;
|
||||||
@@ -7,6 +7,14 @@
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent } from 'vue';
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'AppIcon',
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Component } from 'vue';
|
import type { Component } from 'vue';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { DefineComponent } from 'vue';
|
||||||
|
import AppIcon from './AppIcon.vue';
|
||||||
|
import type { AppIconName } from '../../types/icons';
|
||||||
|
|
||||||
|
export default AppIcon as DefineComponent<{
|
||||||
|
name: AppIconName;
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
strokeWidth?: number;
|
||||||
|
}>;
|
||||||
Vendored
+5
@@ -0,0 +1,5 @@
|
|||||||
|
declare module '*.vue' {
|
||||||
|
import type { DefineComponent } from 'vue';
|
||||||
|
const component: DefineComponent<object, object, unknown>;
|
||||||
|
export default component;
|
||||||
|
}
|
||||||
@@ -90,14 +90,14 @@ export function serviceDurationLabel(
|
|||||||
const classPrice = getClassPriceForService(service, classId);
|
const classPrice = getClassPriceForService(service, classId);
|
||||||
return formatDuration(
|
return formatDuration(
|
||||||
classPrice?.durationMinutes ?? service.durationMinutes,
|
classPrice?.durationMinutes ?? service.durationMinutes,
|
||||||
service.serviceTemplate?.defaultDurationMinutes,
|
service.serviceTemplate?.defaultDurationMinutes ?? undefined,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!usesServiceClasses(classesEnabled, classes)) {
|
if (!usesServiceClasses(classesEnabled, classes)) {
|
||||||
return formatDuration(
|
return formatDuration(
|
||||||
service.durationMinutes,
|
service.durationMinutes,
|
||||||
service.serviceTemplate?.defaultDurationMinutes,
|
service.serviceTemplate?.defaultDurationMinutes ?? undefined,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +113,7 @@ export function serviceDurationLabel(
|
|||||||
if (!durations.length) {
|
if (!durations.length) {
|
||||||
return formatDuration(
|
return formatDuration(
|
||||||
service.durationMinutes,
|
service.durationMinutes,
|
||||||
service.serviceTemplate?.defaultDurationMinutes,
|
service.serviceTemplate?.defaultDurationMinutes ?? undefined,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -262,33 +262,17 @@
|
|||||||
class="flex flex-wrap items-center gap-2"
|
class="flex flex-wrap items-center gap-2"
|
||||||
>
|
>
|
||||||
<span class="font-medium text-text">{{ cls.title }}:</span>
|
<span class="font-medium text-text">{{ cls.title }}:</span>
|
||||||
<span>
|
<span>{{ servicePriceLabel(svc, true, serviceClasses, cls.id) }}</span>
|
||||||
{{
|
<template v-if="getClassPriceForService(svc, cls.id)">
|
||||||
getClassPriceForService(svc, cls.id)
|
<span>·</span>
|
||||||
? `${formatPriceToman(getClassPriceForService(svc, cls.id)!.price)} تومان`
|
<span>{{ serviceDurationLabel(svc, true, serviceClasses, cls.id) }}</span>
|
||||||
: '—'
|
</template>
|
||||||
}}
|
|
||||||
</span>
|
|
||||||
<span v-if="getClassPriceForService(svc, cls.id)">·</span>
|
|
||||||
<span v-if="getClassPriceForService(svc, cls.id)">
|
|
||||||
{{
|
|
||||||
formatDuration(
|
|
||||||
getClassPriceForService(svc, cls.id)!.durationMinutes,
|
|
||||||
svc.durationMinutes ?? svc.serviceTemplate?.defaultDurationMinutes,
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div v-else class="flex flex-wrap items-center gap-2">
|
<div v-else class="flex flex-wrap items-center gap-2">
|
||||||
<span>{{ formatPriceToman(svc.price) }} تومان</span>
|
<span>{{ formatPriceToman(svc.price) }} تومان</span>
|
||||||
<span>·</span>
|
<span>·</span>
|
||||||
<span>{{
|
<span>{{ serviceDurationLabel(svc, false, serviceClasses) }}</span>
|
||||||
formatDuration(
|
|
||||||
svc.durationMinutes,
|
|
||||||
svc.serviceTemplate?.defaultDurationMinutes,
|
|
||||||
)
|
|
||||||
}}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@@ -463,13 +447,13 @@ import BusinessAvailabilityEditor from '@/components/owner/BusinessAvailabilityE
|
|||||||
import ServiceClassManager from '@/components/owner/ServiceClassManager.vue';
|
import ServiceClassManager from '@/components/owner/ServiceClassManager.vue';
|
||||||
import FormPageSkeleton from '@/components/ui/FormPageSkeleton.vue';
|
import FormPageSkeleton from '@/components/ui/FormPageSkeleton.vue';
|
||||||
import ListCardSkeleton from '@/components/ui/ListCardSkeleton.vue';
|
import ListCardSkeleton from '@/components/ui/ListCardSkeleton.vue';
|
||||||
|
import AppButton from '@/components/ui/AppButton.vue';
|
||||||
import AppIcon from '@/components/ui/AppIcon.vue';
|
import AppIcon from '@/components/ui/AppIcon.vue';
|
||||||
import AppIconButton from '@/components/ui/AppIconButton.vue';
|
import AppIconButton from '@/components/ui/AppIconButton.vue';
|
||||||
import AppInput from '@/components/ui/AppInput.vue';
|
import AppInput from '@/components/ui/AppInput.vue';
|
||||||
import PriceInput from '@/components/ui/PriceInput.vue';
|
import PriceInput from '@/components/ui/PriceInput.vue';
|
||||||
import WebAppLayout from '@/layouts/WebAppLayout.vue';
|
import WebAppLayout from '@/layouts/WebAppLayout.vue';
|
||||||
import {
|
import {
|
||||||
formatDuration,
|
|
||||||
formatPriceInputDisplay,
|
formatPriceInputDisplay,
|
||||||
formatPriceToman,
|
formatPriceToman,
|
||||||
parseOptionalMinutes,
|
parseOptionalMinutes,
|
||||||
@@ -478,6 +462,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
activeServiceClasses,
|
activeServiceClasses,
|
||||||
getClassPriceForService,
|
getClassPriceForService,
|
||||||
|
serviceDurationLabel,
|
||||||
|
servicePriceLabel,
|
||||||
} from '@/utils/service-pricing';
|
} from '@/utils/service-pricing';
|
||||||
|
|
||||||
const business = ref<Business | null>(null);
|
const business = ref<Business | null>(null);
|
||||||
|
|||||||
+2
-1
@@ -15,10 +15,11 @@
|
|||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"jsx": "preserve",
|
"jsx": "preserve",
|
||||||
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
|
"include": ["shims-vue.d.ts", "src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||||
"exclude": ["dist", "dist-admin", "node_modules"]
|
"exclude": ["dist", "dist-admin", "node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
{
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
"files": [],
|
"files": [],
|
||||||
"references": [
|
"references": [
|
||||||
{ "path": "./tsconfig.app.json" },
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/** @type {import('vls').VeturConfiguration} */
|
||||||
|
module.exports = {
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
root: '.',
|
||||||
|
jsconfig: './jsconfig.json',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
+4
-2
@@ -17,13 +17,15 @@ export default defineConfig({
|
|||||||
'/api': {
|
'/api': {
|
||||||
target: API_TARGET,
|
target: API_TARGET,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: true,
|
secure: false,
|
||||||
|
timeout: 30_000,
|
||||||
rewrite: (path) => path.replace(/^\/api/, ''),
|
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||||
},
|
},
|
||||||
'/uploads': {
|
'/uploads': {
|
||||||
target: API_TARGET,
|
target: API_TARGET,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: true,
|
secure: false,
|
||||||
|
timeout: 30_000,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user