fix: AppButton import in owner panel and service-pricing types

This commit is contained in:
2026-06-10 14:16:50 +03:30
parent b4300a17ad
commit 820e80ac16
18 changed files with 334 additions and 159 deletions
+4
View File
@@ -0,0 +1,4 @@
{
"recommendations": ["Vue.volar"],
"unwantedRecommendations": ["octref.vetur"]
}
+11
View File
@@ -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
}
+14
View File
@@ -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"]
}
+5
View File
@@ -0,0 +1,5 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue';
const component: DefineComponent<object, object, unknown>;
export default component;
}
+43 -17
View File
@@ -20,6 +20,36 @@ export function getApiToken(): string | null {
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> {
const token = options.token ?? getApiToken() ?? undefined;
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 res = await fetch(`${baseUrl}${path}`, {
...fetchOptions,
headers,
});
let res: Response;
try {
res = await fetch(`${baseUrl}${path}`, {
...fetchOptions,
headers,
});
} catch {
throw new ApiError(
0,
'ارتباط با سرور برقرار نشد. اتصال اینترنت را بررسی کنید یا چند لحظه بعد دوباره تلاش کنید.',
);
}
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
| { message?: string | string[] }
| null;
let msg: 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),
);
msg =
timeErrors.length > 0
? 'فرمت ساعت نامعتبر است. لطفاً دوباره بازه را اضافه و ذخیره کنید.'
: lines.join(' · ');
} else {
msg = body?.message ?? res.statusText;
}
const msg = resolveApiErrorMessage(res.status, res.statusText, body);
throw new ApiError(res.status, msg);
}
@@ -27,9 +27,19 @@
مشتری فقط در بازه‌های تعریف‌شده می‌تواند رزرو کند. ابتدا حالت کلی را انتخاب کنید.
</p>
<p v-if="error" class="rounded-xl bg-red-500/10 px-3 py-2 text-sm text-red-600">
{{ error }}
</p>
<div
v-if="error"
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
v-if="message"
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>
</div>
<div class="rounded-2xl border border-border bg-surface-2 p-4">
<label class="flex items-center justify-between gap-3">
<span class="text-sm text-text">پذیرش آنلاین این روز</span>
<div class="overflow-hidden rounded-2xl border border-border bg-surface-2">
<label
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
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]"
@change="toggleDayActive(activeDay)"
/>
</label>
<div v-if="dayActive[activeDay]" class="mt-4">
<div v-if="dayActive[activeDay]" class="p-4">
<TimeRangeList
:ranges="slotsByDay[activeDay] ?? []"
@add="(start, end) => addCustomRange(activeDay, start, end)"
@remove="(i) => removeSlot(activeDay, i)"
/>
</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>
</div>
</div>
+58 -76
View File
@@ -1,92 +1,74 @@
<template>
<div ref="rootRef" class="relative min-w-0 flex-1">
<button
type="button"
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"
:aria-expanded="open"
@click.stop="open = !open"
<div class="flex items-center gap-2">
<label class="sr-only">ساعت</label>
<select
v-model="hour"
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"
>
<span class="text-lg font-semibold tabular-nums text-text" dir="ltr">
{{ hour }}:{{ minute }}
</span>
</button>
<option v-for="h in hours" :key="h" :value="h">{{ h }}</option>
</select>
<div
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"
@click.stop
<span class="shrink-0 text-xl font-semibold text-muted" aria-hidden="true">:</span>
<label class="sr-only">دقیقه</label>
<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">
<div class="min-w-0 flex-1 border-e border-border">
<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>
<option v-for="m in minuteOptions" :key="m" :value="m">{{ m }}</option>
</select>
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
<script lang="ts">
import { defineComponent } from 'vue';
const hours = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'));
const minutes = Array.from({ length: 60 }, (_, i) => String(i).padStart(2, '0'));
export default defineComponent({
name: 'HmTimePickers',
});
</script>
const hour = defineModel<string>('hour', { required: true });
const minute = defineModel<string>('minute', { required: true });
<script setup lang="ts">
import { computed } from 'vue';
const open = ref(false);
const rootRef = ref<HTMLElement | null>(null);
const HOUR_OPTIONS = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'));
const MINUTE_STEPS = Array.from({ length: 12 }, (_, i) => String(i * 5).padStart(2, '0'));
function pickHour(v: string) {
hour.value = v;
const model = defineModel<string>({ required: true });
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) {
minute.value = v;
}
const minute = computed({
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) {
if (!open.value || !rootRef.value) return;
if (!rootRef.value.contains(e.target as Node)) {
open.value = false;
const minuteOptions = computed(() => {
const current = minute.value;
if (MINUTE_STEPS.includes(current)) {
return MINUTE_STEPS;
}
}
onMounted(() => document.addEventListener('click', onDocumentClick));
onUnmounted(() => document.removeEventListener('click', onDocumentClick));
return [...MINUTE_STEPS, current].sort((a, b) => Number(a) - Number(b));
});
</script>
+111 -27
View File
@@ -30,32 +30,103 @@
هنوز بازه‌ای ثبت نشده
</p>
<div class="rounded-2xl border border-dashed border-brand-500/40 bg-brand-500/5 p-3">
<p class="mb-2 text-xs font-medium text-muted">بازه جدید</p>
<div class="flex items-center gap-2">
<span class="shrink-0 text-xs font-medium text-muted">از</span>
<HmTimePickers v-model:hour="startH" v-model:minute="startM" />
<span class="shrink-0 text-xs font-medium text-muted">تا</span>
<HmTimePickers v-model:hour="endH" v-model:minute="endM" />
<div class="overflow-hidden rounded-2xl border border-border bg-surface-3">
<div class="border-b border-border bg-brand-500/5 px-4 py-3">
<p class="text-sm font-semibold text-text">افزودن بازه جدید</p>
<p class="mt-0.5 text-xs text-muted">ساعت شروع و پایان را جداگانه انتخاب کنید</p>
</div>
<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>
<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>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import AppIcon from '@/components/ui/AppIcon.vue';
import HmTimePickers from '@/components/owner/HmTimePickers.vue';
import { isHmRangeValid, normalizeHmTime } from '@/utils/format';
import AppIcon from '../ui/app-icon';
import HmTimePickers from './hm-time-pickers';
import { isHmRangeValid, normalizeHmTime } from '../../utils/format';
defineProps<{
ranges: { startTime: string; endTime: string }[];
@@ -66,20 +137,31 @@ const emit = defineEmits<{
remove: [index: number];
}>();
const startH = ref('09');
const startM = ref('00');
const endH = ref('13');
const endM = ref('00');
const presets = [
{ label: 'صبح · ۰۹ تا ۱۳', start: '09:00', end: '13:00' },
{ label: 'ظهر · ۱۳ تا ۱۷', start: '13:00', end: '17: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('');
function toHm(h: string, m: string): string {
return `${h.padStart(2, '0')}:${m.padStart(2, '0')}`;
function isPresetActive(preset: (typeof presets)[number]): boolean {
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() {
localError.value = '';
const start = normalizeHmTime(toHm(startH.value, startM.value));
const end = normalizeHmTime(toHm(endH.value, endM.value));
const start = normalizeHmTime(startTime.value);
const end = normalizeHmTime(endTime.value);
if (!start || !end) {
localError.value = 'فرمت ساعت نامعتبر است.';
return;
@@ -89,5 +171,7 @@ function onAdd() {
return;
}
emit('add', start, end);
startTime.value = '09:00';
endTime.value = '13:00';
}
</script>
+6
View File
@@ -0,0 +1,6 @@
import type { DefineComponent } from 'vue';
import HmTimePickers from './HmTimePickers.vue';
export default HmTimePickers as DefineComponent<{
modelValue: string;
}>;
+8
View File
@@ -7,6 +7,14 @@
/>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
export default defineComponent({
name: 'AppIcon',
});
</script>
<script setup lang="ts">
import type { Component } from 'vue';
import { computed } from 'vue';
+9
View File
@@ -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;
}>;
+5
View File
@@ -0,0 +1,5 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue';
const component: DefineComponent<object, object, unknown>;
export default component;
}
+3 -3
View File
@@ -90,14 +90,14 @@ export function serviceDurationLabel(
const classPrice = getClassPriceForService(service, classId);
return formatDuration(
classPrice?.durationMinutes ?? service.durationMinutes,
service.serviceTemplate?.defaultDurationMinutes,
service.serviceTemplate?.defaultDurationMinutes ?? undefined,
);
}
if (!usesServiceClasses(classesEnabled, classes)) {
return formatDuration(
service.durationMinutes,
service.serviceTemplate?.defaultDurationMinutes,
service.serviceTemplate?.defaultDurationMinutes ?? undefined,
);
}
@@ -113,7 +113,7 @@ export function serviceDurationLabel(
if (!durations.length) {
return formatDuration(
service.durationMinutes,
service.serviceTemplate?.defaultDurationMinutes,
service.serviceTemplate?.defaultDurationMinutes ?? undefined,
);
}
+9 -23
View File
@@ -262,33 +262,17 @@
class="flex flex-wrap items-center gap-2"
>
<span class="font-medium text-text">{{ cls.title }}:</span>
<span>
{{
getClassPriceForService(svc, cls.id)
? `${formatPriceToman(getClassPriceForService(svc, cls.id)!.price)} تومان`
: '—'
}}
</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>
<span>{{ servicePriceLabel(svc, true, serviceClasses, cls.id) }}</span>
<template v-if="getClassPriceForService(svc, cls.id)">
<span>·</span>
<span>{{ serviceDurationLabel(svc, true, serviceClasses, cls.id) }}</span>
</template>
</div>
</template>
<div v-else class="flex flex-wrap items-center gap-2">
<span>{{ formatPriceToman(svc.price) }} تومان</span>
<span>·</span>
<span>{{
formatDuration(
svc.durationMinutes,
svc.serviceTemplate?.defaultDurationMinutes,
)
}}</span>
<span>{{ serviceDurationLabel(svc, false, serviceClasses) }}</span>
</div>
</div>
</li>
@@ -463,13 +447,13 @@ import BusinessAvailabilityEditor from '@/components/owner/BusinessAvailabilityE
import ServiceClassManager from '@/components/owner/ServiceClassManager.vue';
import FormPageSkeleton from '@/components/ui/FormPageSkeleton.vue';
import ListCardSkeleton from '@/components/ui/ListCardSkeleton.vue';
import AppButton from '@/components/ui/AppButton.vue';
import AppIcon from '@/components/ui/AppIcon.vue';
import AppIconButton from '@/components/ui/AppIconButton.vue';
import AppInput from '@/components/ui/AppInput.vue';
import PriceInput from '@/components/ui/PriceInput.vue';
import WebAppLayout from '@/layouts/WebAppLayout.vue';
import {
formatDuration,
formatPriceInputDisplay,
formatPriceToman,
parseOptionalMinutes,
@@ -478,6 +462,8 @@ import {
import {
activeServiceClasses,
getClassPriceForService,
serviceDurationLabel,
servicePriceLabel,
} from '@/utils/service-pricing';
const business = ref<Business | null>(null);
+2 -1
View File
@@ -15,10 +15,11 @@
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"jsx": "preserve",
"baseUrl": ".",
"paths": {
"@/*": ["./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"]
}
+6
View File
@@ -1,4 +1,10 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
+9
View File
@@ -0,0 +1,9 @@
/** @type {import('vls').VeturConfiguration} */
module.exports = {
projects: [
{
root: '.',
jsconfig: './jsconfig.json',
},
],
};
+4 -2
View File
@@ -17,13 +17,15 @@ export default defineConfig({
'/api': {
target: API_TARGET,
changeOrigin: true,
secure: true,
secure: false,
timeout: 30_000,
rewrite: (path) => path.replace(/^\/api/, ''),
},
'/uploads': {
target: API_TARGET,
changeOrigin: true,
secure: true,
secure: false,
timeout: 30_000,
},
},
},