Performing logins, mobile cards, and other tasks
This commit is contained in:
@@ -45,6 +45,7 @@ export type Business = {
|
|||||||
promoDiscountMode?: 'first_booking' | 'always';
|
promoDiscountMode?: 'first_booking' | 'always';
|
||||||
logoLightUrl?: string | null;
|
logoLightUrl?: string | null;
|
||||||
logoDarkUrl?: string | null;
|
logoDarkUrl?: string | null;
|
||||||
|
heroHighlightKeys?: string[] | null;
|
||||||
averageRating?: number | null;
|
averageRating?: number | null;
|
||||||
reviewCount?: number;
|
reviewCount?: number;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
@@ -69,6 +70,7 @@ export type UpdateBusinessPayload = {
|
|||||||
promoDiscountPercent?: number | null;
|
promoDiscountPercent?: number | null;
|
||||||
promoDiscountCode?: string | null;
|
promoDiscountCode?: string | null;
|
||||||
promoDiscountMode?: 'first_booking' | 'always';
|
promoDiscountMode?: 'first_booking' | 'always';
|
||||||
|
heroHighlightKeys?: string[] | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ServiceTemplate = {
|
export type ServiceTemplate = {
|
||||||
|
|||||||
@@ -126,7 +126,12 @@ const tipSubmitting = ref(false);
|
|||||||
const tipAmountToman = computed(() => parsePriceInput(tipAmountInput.value));
|
const tipAmountToman = computed(() => parsePriceInput(tipAmountInput.value));
|
||||||
|
|
||||||
async function loadPending() {
|
async function loadPending() {
|
||||||
if (!auth.isLoggedIn || auth.isBusinessOwner) return;
|
if (!auth.isLoggedIn) return;
|
||||||
|
if (auth.token && !auth.user) {
|
||||||
|
await auth.loadUser();
|
||||||
|
}
|
||||||
|
// Business owners get 403 from this customer-only endpoint.
|
||||||
|
if (auth.isBusinessOwner || !auth.user) return;
|
||||||
try {
|
try {
|
||||||
pending.value = await fetchPendingReviews();
|
pending.value = await fetchPendingReviews();
|
||||||
if (!activeReview.value && pending.value.length) {
|
if (!activeReview.value && pending.value.length) {
|
||||||
@@ -241,9 +246,14 @@ async function submitTip() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => auth.isLoggedIn,
|
() => [auth.isLoggedIn, auth.user?.role] as const,
|
||||||
(loggedIn) => {
|
([loggedIn]) => {
|
||||||
if (loggedIn) void loadPending();
|
if (loggedIn) void loadPending();
|
||||||
|
else {
|
||||||
|
pending.value = [];
|
||||||
|
activeReview.value = null;
|
||||||
|
reviewOpen.value = false;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -4,33 +4,33 @@
|
|||||||
class="flex items-center gap-3 rounded-xl border border-border bg-surface-2 p-2.5 transition hover:border-brand-500/40 hover:shadow-sm"
|
class="flex items-center gap-3 rounded-xl border border-border bg-surface-2 p-2.5 transition hover:border-brand-500/40 hover:shadow-sm"
|
||||||
>
|
>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="flex items-start justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<div class="min-w-0">
|
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||||
<h2 class="truncate text-sm font-semibold text-text">{{ business.title }}</h2>
|
<h2 class="min-w-0 truncate text-sm font-semibold text-text">{{ business.title }}</h2>
|
||||||
<BusinessRatingBadge
|
<BusinessRatingBadge
|
||||||
class="mt-1"
|
class="shrink-0"
|
||||||
:average-rating="business.averageRating"
|
:average-rating="business.averageRating"
|
||||||
:review-count="business.reviewCount"
|
:review-count="business.reviewCount"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
v-if="business.category"
|
v-if="business.category"
|
||||||
class="shrink-0 rounded-lg bg-brand-500/10 px-1.5 py-0.5 text-[10px] text-brand-700 dark:text-brand-500"
|
class="shrink-0 rounded-lg bg-brand-500/10 px-1.5 py-0.5 text-[10px] text-brand-700 dark:text-brand-500"
|
||||||
|
>
|
||||||
|
{{ business.category.title }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p v-if="summary" class="mt-0.5 truncate text-xs text-muted">
|
||||||
|
{{ summary }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="business.address"
|
||||||
|
class="mt-0.5 flex items-center gap-1 truncate text-xs text-muted"
|
||||||
>
|
>
|
||||||
{{ business.category.title }}
|
<AppIcon name="map-pin" size="sm" class="shrink-0" />
|
||||||
</span>
|
<span class="truncate">{{ business.address }}</span>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="business.description" class="mt-0.5 truncate text-xs text-muted">
|
|
||||||
{{ business.description }}
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
v-if="business.address"
|
|
||||||
class="mt-0.5 flex items-center gap-1 truncate text-xs text-muted"
|
|
||||||
>
|
|
||||||
<AppIcon name="map-pin" size="sm" class="shrink-0" />
|
|
||||||
<span class="truncate">{{ business.address }}</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="relative h-14 w-14 shrink-0 overflow-hidden rounded-lg bg-surface-3 sm:h-16 sm:w-16"
|
class="relative h-14 w-14 shrink-0 overflow-hidden rounded-lg bg-surface-3 sm:h-16 sm:w-16"
|
||||||
@@ -64,4 +64,6 @@ const props = defineProps<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const cover = computed(() => businessCoverUrl(props.business.images));
|
const cover = computed(() => businessCoverUrl(props.business.images));
|
||||||
|
|
||||||
|
const summary = computed(() => props.business.subtitle?.trim() || '');
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,5 +1,32 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="-mx-1 flex gap-2 overflow-x-auto px-1 pb-1">
|
<!-- Mobile / tablet: select + «همه» -->
|
||||||
|
<div class="flex items-stretch gap-2 lg:hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="shrink-0 rounded-xl px-4 text-sm font-medium transition"
|
||||||
|
:class="
|
||||||
|
!selectedId
|
||||||
|
? 'bg-brand-600 text-white'
|
||||||
|
: 'border border-border bg-surface-3 text-muted hover:text-text'
|
||||||
|
"
|
||||||
|
@click="emit('select', null)"
|
||||||
|
>
|
||||||
|
همه
|
||||||
|
</button>
|
||||||
|
<select
|
||||||
|
class="min-w-0 flex-1 rounded-xl border border-border bg-surface-2 px-3 py-2.5 text-sm text-text outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-500/30"
|
||||||
|
:value="selectedId ?? ''"
|
||||||
|
@change="onSelectChange"
|
||||||
|
>
|
||||||
|
<option value="" disabled>انتخاب دسته</option>
|
||||||
|
<option v-for="cat in categories" :key="cat.id" :value="cat.id">
|
||||||
|
{{ cat.title }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Desktop: chip row -->
|
||||||
|
<div class="-mx-1 hidden gap-2 overflow-x-auto px-1 pb-1 lg:flex">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="shrink-0 rounded-full px-4 py-2 text-sm font-medium transition"
|
class="shrink-0 rounded-full px-4 py-2 text-sm font-medium transition"
|
||||||
@@ -8,7 +35,7 @@
|
|||||||
? 'bg-brand-600 text-white'
|
? 'bg-brand-600 text-white'
|
||||||
: 'bg-surface-3 text-muted hover:text-text'
|
: 'bg-surface-3 text-muted hover:text-text'
|
||||||
"
|
"
|
||||||
@click="$emit('select', null)"
|
@click="emit('select', null)"
|
||||||
>
|
>
|
||||||
همه
|
همه
|
||||||
</button>
|
</button>
|
||||||
@@ -22,7 +49,7 @@
|
|||||||
? 'bg-brand-600 text-white'
|
? 'bg-brand-600 text-white'
|
||||||
: 'bg-surface-3 text-muted hover:text-text'
|
: 'bg-surface-3 text-muted hover:text-text'
|
||||||
"
|
"
|
||||||
@click="$emit('select', cat.id)"
|
@click="emit('select', cat.id)"
|
||||||
>
|
>
|
||||||
{{ cat.title }}
|
{{ cat.title }}
|
||||||
</button>
|
</button>
|
||||||
@@ -37,7 +64,12 @@ defineProps<{
|
|||||||
selectedId: string | null;
|
selectedId: string | null;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
defineEmits<{
|
const emit = defineEmits<{
|
||||||
select: [categoryId: string | null];
|
select: [categoryId: string | null];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
function onSelectChange(event: Event) {
|
||||||
|
const value = (event.target as HTMLSelectElement).value;
|
||||||
|
emit('select', value || null);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,48 +1,55 @@
|
|||||||
<template>
|
<template>
|
||||||
<RouterLink
|
<RouterLink
|
||||||
:to="{ name: 'app-business-detail', params: { id: business.id } }"
|
:to="{ name: 'app-business-detail', params: { id: business.id } }"
|
||||||
class="group flex h-full flex-col rounded-xl border border-slate-200 bg-white p-4 text-center shadow-sm transition hover:-translate-y-0.5 hover:border-brand-500 hover:shadow-md dark:border-border dark:bg-surface"
|
class="group flex h-full flex-col overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm transition hover:-translate-y-0.5 hover:border-brand-500 hover:shadow-md dark:border-border dark:bg-surface"
|
||||||
:class="featured ? 'border-brand-500 ring-1 ring-brand-500/20' : ''"
|
:class="featured ? 'border-brand-500 ring-1 ring-brand-500/20' : ''"
|
||||||
>
|
>
|
||||||
<div
|
<div class="relative aspect-[16/10] w-full overflow-hidden bg-slate-100 dark:bg-surface-3">
|
||||||
class="mx-auto mb-3 flex h-12 w-12 items-center justify-center overflow-hidden rounded-full bg-brand-500/10 text-brand-600 transition group-hover:bg-brand-500/15 dark:text-brand-500"
|
|
||||||
>
|
|
||||||
<img
|
<img
|
||||||
v-if="cover"
|
v-if="cover"
|
||||||
:src="cover"
|
:src="cover"
|
||||||
:alt="business.title"
|
:alt="business.title"
|
||||||
class="h-full w-full object-cover"
|
class="h-full w-full object-cover transition duration-300 group-hover:scale-[1.03]"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
<AppIcon v-else name="store" size="md" />
|
<div v-else class="flex h-full w-full items-center justify-center text-muted/40">
|
||||||
|
<AppIcon name="store" size="lg" />
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
v-if="business.category"
|
||||||
|
class="absolute right-2 top-2 rounded-md bg-white/95 px-2 py-0.5 text-[11px] font-medium text-brand-600 shadow-sm backdrop-blur-sm dark:bg-surface/95 dark:text-brand-500"
|
||||||
|
>
|
||||||
|
{{ business.category.title }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 class="text-sm font-bold text-slate-800 dark:text-text">{{ business.title }}</h3>
|
<div class="flex flex-1 flex-col space-y-1.5 p-3.5 text-start">
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<h3 class="min-w-0 flex-1 text-sm font-bold text-slate-800 dark:text-text">
|
||||||
|
{{ business.title }}
|
||||||
|
</h3>
|
||||||
|
<BusinessRatingBadge
|
||||||
|
class="shrink-0"
|
||||||
|
:average-rating="business.averageRating"
|
||||||
|
:review-count="business.reviewCount"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<BusinessRatingBadge
|
<p
|
||||||
class="mx-auto mt-1.5"
|
v-if="summary"
|
||||||
:average-rating="business.averageRating"
|
class="line-clamp-2 text-xs leading-5 text-slate-600 dark:text-muted"
|
||||||
:review-count="business.reviewCount"
|
>
|
||||||
/>
|
{{ summary }}
|
||||||
|
</p>
|
||||||
|
|
||||||
<p
|
<p
|
||||||
v-if="business.description"
|
v-if="business.address"
|
||||||
class="mt-1.5 line-clamp-2 text-xs leading-6 text-slate-600 dark:text-muted"
|
class="mt-auto flex items-center gap-1 pt-1 text-[11px] text-slate-500 dark:text-muted"
|
||||||
>
|
>
|
||||||
{{ business.description }}
|
<AppIcon name="map-pin" size="sm" class="shrink-0" />
|
||||||
</p>
|
<span class="line-clamp-1">{{ business.address }}</span>
|
||||||
|
</p>
|
||||||
<p v-if="business.category" class="mt-2 text-[11px] font-medium text-brand-600 dark:text-brand-500">
|
</div>
|
||||||
{{ business.category.title }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p
|
|
||||||
v-if="business.address"
|
|
||||||
class="mt-1.5 flex items-center justify-center gap-1 text-[11px] text-slate-500 dark:text-muted"
|
|
||||||
>
|
|
||||||
<AppIcon name="map-pin" size="sm" class="shrink-0" />
|
|
||||||
<span class="line-clamp-1">{{ business.address }}</span>
|
|
||||||
</p>
|
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -59,4 +66,5 @@ const props = defineProps<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const cover = computed(() => businessCoverUrl(props.business.images));
|
const cover = computed(() => businessCoverUrl(props.business.images));
|
||||||
|
const summary = computed(() => props.business.subtitle?.trim() || '');
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -13,20 +13,9 @@
|
|||||||
>
|
>
|
||||||
کسبوکاری یافت نشد.
|
کسبوکاری یافت نشد.
|
||||||
</div>
|
</div>
|
||||||
<ul
|
<ul v-else :class="gridClass">
|
||||||
v-else
|
<li v-for="business in businesses" :key="business.id">
|
||||||
:class="
|
<HomeBusinessCard v-if="useFeaturedCards" :business="business" />
|
||||||
variant === 'featured'
|
|
||||||
? 'grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4'
|
|
||||||
: 'grid grid-cols-1 gap-2'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<li v-for="(business, index) in businesses" :key="business.id">
|
|
||||||
<HomeBusinessCard
|
|
||||||
v-if="variant === 'featured'"
|
|
||||||
:business="business"
|
|
||||||
:featured="index === 1"
|
|
||||||
/>
|
|
||||||
<BusinessCard v-else :business="business" />
|
<BusinessCard v-else :business="business" />
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -34,6 +23,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useMediaQuery } from '@vueuse/core';
|
||||||
|
import { computed } from 'vue';
|
||||||
import type { Business } from '@/api/types';
|
import type { Business } from '@/api/types';
|
||||||
import BusinessCard from '@/components/business/BusinessCard.vue';
|
import BusinessCard from '@/components/business/BusinessCard.vue';
|
||||||
import HomeBusinessCard from '@/components/home/HomeBusinessCard.vue';
|
import HomeBusinessCard from '@/components/home/HomeBusinessCard.vue';
|
||||||
@@ -49,4 +40,25 @@ const props = withDefaults(
|
|||||||
}>(),
|
}>(),
|
||||||
{ variant: 'compact', class: '' },
|
{ variant: 'compact', class: '' },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Cover-style cards from 425px up. */
|
||||||
|
const isWideMobile = useMediaQuery('(min-width: 425px)');
|
||||||
|
|
||||||
|
const useFeaturedCards = computed(
|
||||||
|
() => props.variant === 'featured' || isWideMobile.value,
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <425: 1 col (list)
|
||||||
|
* ≥425: 2 cols
|
||||||
|
* ≥768: 3 cols
|
||||||
|
* ≥1024: 4 cols
|
||||||
|
* ≥1440: 5 cols
|
||||||
|
*/
|
||||||
|
const gridClass = computed(() => {
|
||||||
|
if (!useFeaturedCards.value) {
|
||||||
|
return 'grid grid-cols-1 gap-2';
|
||||||
|
}
|
||||||
|
return 'grid grid-cols-2 gap-3 sm:gap-4 md:grid-cols-3 lg:grid-cols-4 min-[1440px]:grid-cols-5';
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -117,10 +117,20 @@ watch(selectedCategoryId, () => {
|
|||||||
void loadBusinesses();
|
void loadBusinesses();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [auth.isLoggedIn, auth.user?.id, auth.user?.role] as const,
|
||||||
|
async () => {
|
||||||
|
if (auth.isLoggedIn) {
|
||||||
|
await auth.loadUser();
|
||||||
|
await ensureLoaded(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (auth.isLoggedIn) {
|
if (auth.isLoggedIn) {
|
||||||
await auth.loadUser();
|
await auth.loadUser();
|
||||||
await ensureLoaded();
|
await ensureLoaded(true);
|
||||||
}
|
}
|
||||||
await loadCategories();
|
await loadCategories();
|
||||||
await loadBusinesses();
|
await loadBusinesses();
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ import { storeToRefs } from 'pinia';
|
|||||||
import BusinessImageGallery from '@/components/business/BusinessImageGallery.vue';
|
import BusinessImageGallery from '@/components/business/BusinessImageGallery.vue';
|
||||||
import AppIcon from '@/components/ui/AppIcon.vue';
|
import AppIcon from '@/components/ui/AppIcon.vue';
|
||||||
import { useTenantStore } from '@/stores/tenant';
|
import { useTenantStore } from '@/stores/tenant';
|
||||||
import type { AppIconName } from '@/types/icons';
|
import { resolveBusinessHighlights } from '@/constants/business-highlights';
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -111,9 +111,7 @@ const heroSubtitle = computed(() => landingBusiness.value?.subtitle?.trim() ?? '
|
|||||||
|
|
||||||
const heroDescription = computed(() => landingBusiness.value?.description?.trim() ?? '');
|
const heroDescription = computed(() => landingBusiness.value?.description?.trim() ?? '');
|
||||||
|
|
||||||
const highlights: { label: string; icon: AppIconName }[] = [
|
const highlights = computed(() =>
|
||||||
{ label: 'رضایت مشتریان', icon: 'shield' },
|
resolveBusinessHighlights(landingBusiness.value?.heroHighlightKeys),
|
||||||
{ label: 'تجهیزات مدرن', icon: 'settings' },
|
);
|
||||||
{ label: 'مواد شوینده با کیفیت', icon: 'droplet' },
|
|
||||||
];
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-text">{{ label }}</label>
|
||||||
|
<p class="mt-1 text-xs text-muted">
|
||||||
|
حداکثر ۳ مورد برای نمایش در صفحه اصلی کسبوکار. اگر چیزی انتخاب نکنید، موارد پیشفرض نمایش داده میشود.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||||
|
<label
|
||||||
|
v-for="item in catalog"
|
||||||
|
:key="item.key"
|
||||||
|
class="flex cursor-pointer items-center gap-2.5 rounded-xl border px-3 py-2.5 text-sm transition"
|
||||||
|
:class="
|
||||||
|
isSelected(item.key)
|
||||||
|
? 'border-brand-500/50 bg-brand-500/10 text-text'
|
||||||
|
: 'border-border bg-surface-2 text-muted hover:border-brand-500/30'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="rounded border-border"
|
||||||
|
:checked="isSelected(item.key)"
|
||||||
|
:disabled="!isSelected(item.key) && modelValue.length >= 3"
|
||||||
|
@change="toggle(item.key)"
|
||||||
|
/>
|
||||||
|
<AppIcon :name="item.icon" size="sm" class="shrink-0 text-brand-600 dark:text-brand-500" />
|
||||||
|
<span class="min-w-0 flex-1 font-medium text-text">{{ item.label }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-xs text-muted">{{ modelValue.length }} از ۳ انتخاب شده</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import AppIcon from '@/components/ui/AppIcon.vue';
|
||||||
|
import {
|
||||||
|
BUSINESS_HIGHLIGHT_CATALOG,
|
||||||
|
type BusinessHighlightKey,
|
||||||
|
} from '@/constants/business-highlights';
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
modelValue: string[];
|
||||||
|
label?: string;
|
||||||
|
}>(),
|
||||||
|
{ label: 'نکات برجسته لندینگ' },
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: string[]];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const catalog = BUSINESS_HIGHLIGHT_CATALOG;
|
||||||
|
|
||||||
|
function isSelected(key: BusinessHighlightKey) {
|
||||||
|
return props.modelValue.includes(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle(key: BusinessHighlightKey) {
|
||||||
|
if (isSelected(key)) {
|
||||||
|
emit(
|
||||||
|
'update:modelValue',
|
||||||
|
props.modelValue.filter((item) => item !== key),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (props.modelValue.length >= 3) return;
|
||||||
|
emit('update:modelValue', [...props.modelValue, key]);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -21,6 +21,7 @@ import { computed } from 'vue';
|
|||||||
import {
|
import {
|
||||||
Gift,
|
Gift,
|
||||||
Info,
|
Info,
|
||||||
|
LogOut,
|
||||||
Armchair,
|
Armchair,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
BadgePercent,
|
BadgePercent,
|
||||||
@@ -111,6 +112,7 @@ const iconMap: Record<AppIconName, Component> = {
|
|||||||
zap: Zap,
|
zap: Zap,
|
||||||
gift: Gift,
|
gift: Gift,
|
||||||
info: Info,
|
info: Info,
|
||||||
|
'log-out': LogOut,
|
||||||
};
|
};
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
<select
|
<select
|
||||||
:id="id"
|
:id="id"
|
||||||
:value="modelValue"
|
:value="modelValue"
|
||||||
|
:required="required"
|
||||||
class="w-full rounded-xl border border-border bg-surface-2 px-4 py-3 text-text outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-500/30"
|
class="w-full rounded-xl border border-border bg-surface-2 px-4 py-3 text-text outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-500/30"
|
||||||
:class="ltr ? 'text-left' : ''"
|
:class="ltr ? 'text-left' : ''"
|
||||||
@change="onChange"
|
@change="onChange"
|
||||||
@@ -26,6 +27,7 @@ defineProps<{
|
|||||||
options: { value: string; label: string }[];
|
options: { value: string; label: string }[];
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
ltr?: boolean;
|
ltr?: boolean;
|
||||||
|
required?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{ 'update:modelValue': [value: string] }>();
|
const emit = defineEmits<{ 'update:modelValue': [value: string] }>();
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { computed } from 'vue';
|
|||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import type { AppIconName } from '@/types/icons';
|
import type { AppIconName } from '@/types/icons';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
import { useTenantStore } from '@/stores/tenant';
|
|
||||||
import { loginRouteWithRedirect } from '@/utils/auth-redirect';
|
import { loginRouteWithRedirect } from '@/utils/auth-redirect';
|
||||||
|
|
||||||
export type AppNavItem = {
|
export type AppNavItem = {
|
||||||
@@ -17,7 +16,6 @@ export type AppNavItem = {
|
|||||||
|
|
||||||
export function useAppNav() {
|
export function useAppNav() {
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const tenant = useTenantStore();
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@@ -29,6 +27,7 @@ export function useAppNav() {
|
|||||||
void router.push(loginRouteWithRedirect(path));
|
void router.push(loginRouteWithRedirect(path));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Shared items (desktop helpers / legacy). Mobile uses `mobileNavItems`. */
|
||||||
const navItems = computed((): AppNavItem[] => {
|
const navItems = computed((): AppNavItem[] => {
|
||||||
const items: AppNavItem[] = [
|
const items: AppNavItem[] = [
|
||||||
{
|
{
|
||||||
@@ -39,33 +38,22 @@ export function useAppNav() {
|
|||||||
icon: 'home',
|
icon: 'home',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'businesses',
|
|
||||||
to: '/app',
|
|
||||||
hash: '#businesses',
|
|
||||||
name: 'app-home',
|
|
||||||
label: 'کسبوکارها',
|
|
||||||
icon: 'store',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (auth.isLoggedIn) {
|
|
||||||
items.push({
|
|
||||||
key: 'bookings',
|
key: 'bookings',
|
||||||
to: '/app/bookings',
|
|
||||||
name: 'app-bookings',
|
name: 'app-bookings',
|
||||||
label: 'رزروها',
|
label: 'رزروها',
|
||||||
icon: 'calendar',
|
icon: 'calendar',
|
||||||
});
|
onClick: () => requireAuth('/app/bookings'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
if (auth.isBusinessOwner) {
|
if (auth.isBusinessOwner) {
|
||||||
items.push({
|
items.push({
|
||||||
key: 'owner',
|
key: 'owner',
|
||||||
to: '/app/owner',
|
to: '/app/owner',
|
||||||
name: 'app-owner',
|
name: 'app-owner',
|
||||||
label: 'مدیریت کسبوکار',
|
label: 'کسبوکار من',
|
||||||
icon: 'clipboard-list',
|
icon: 'clipboard-list',
|
||||||
});
|
});
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
items.push({
|
items.push({
|
||||||
key: 'register',
|
key: 'register',
|
||||||
@@ -85,17 +73,24 @@ export function useAppNav() {
|
|||||||
icon: 'users',
|
icon: 'users',
|
||||||
});
|
});
|
||||||
|
|
||||||
if (tenant.isSingleBusiness) {
|
|
||||||
return items.filter((item) => item.key !== 'businesses');
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
});
|
});
|
||||||
|
|
||||||
const mobileNavItems = computed((): AppNavItem[] => {
|
const mobileNavItems = computed((): AppNavItem[] => {
|
||||||
const items = navItems.value.filter((item) => item.key !== 'about');
|
const items = navItems.value.filter((item) => item.key !== 'about');
|
||||||
|
|
||||||
if (!auth.isLoggedIn) {
|
if (auth.isLoggedIn) {
|
||||||
|
items.push({
|
||||||
|
key: 'logout',
|
||||||
|
name: 'logout',
|
||||||
|
label: 'خروج',
|
||||||
|
icon: 'log-out',
|
||||||
|
onClick: () => {
|
||||||
|
auth.logout();
|
||||||
|
void router.push({ name: 'app-home' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
items.push({
|
items.push({
|
||||||
key: 'login',
|
key: 'login',
|
||||||
name: 'login',
|
name: 'login',
|
||||||
@@ -109,6 +104,12 @@ export function useAppNav() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function isNavActive(item: AppNavItem): boolean {
|
function isNavActive(item: AppNavItem): boolean {
|
||||||
|
if (item.key === 'bookings') {
|
||||||
|
return route.name === 'app-bookings' || route.name === 'app-booking-pay';
|
||||||
|
}
|
||||||
|
if (item.key === 'register') {
|
||||||
|
return route.name === 'app-register-business';
|
||||||
|
}
|
||||||
if (item.hash) {
|
if (item.hash) {
|
||||||
return route.name === item.name && route.hash === item.hash;
|
return route.name === item.name && route.hash === item.hash;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,28 @@
|
|||||||
import { computed, ref } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import { fetchMyBusinesses } from '@/api/businesses';
|
import { fetchMyBusinesses } from '@/api/businesses';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
|
||||||
const ownedBusinessIds = ref<Set<string>>(new Set());
|
const ownedBusinessIds = ref<Set<string>>(new Set());
|
||||||
const loaded = ref(false);
|
const loaded = ref(false);
|
||||||
let loadPromise: Promise<void> | null = null;
|
let loadPromise: Promise<void> | null = null;
|
||||||
|
let authWatchStarted = false;
|
||||||
|
|
||||||
export function useOwnedBusiness() {
|
export function useOwnedBusiness() {
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
|
|
||||||
async function ensureLoaded() {
|
async function ensureLoaded(force = false) {
|
||||||
if (!auth.isBusinessOwner) {
|
if (auth.token && !auth.user) {
|
||||||
|
await auth.loadUser();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!auth.isLoggedIn || !auth.isBusinessOwner) {
|
||||||
ownedBusinessIds.value = new Set();
|
ownedBusinessIds.value = new Set();
|
||||||
loaded.value = true;
|
loaded.value = true;
|
||||||
|
loadPromise = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (loaded.value) return;
|
|
||||||
|
if (loaded.value && !force) return;
|
||||||
if (loadPromise) {
|
if (loadPromise) {
|
||||||
await loadPromise;
|
await loadPromise;
|
||||||
return;
|
return;
|
||||||
@@ -36,6 +43,18 @@ export function useOwnedBusiness() {
|
|||||||
await loadPromise;
|
await loadPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!authWatchStarted) {
|
||||||
|
authWatchStarted = true;
|
||||||
|
watch(
|
||||||
|
() => [auth.token, auth.user?.id, auth.user?.role] as const,
|
||||||
|
() => {
|
||||||
|
loaded.value = false;
|
||||||
|
loadPromise = null;
|
||||||
|
void ensureLoaded(true);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function isOwnBusiness(businessId: string | null | undefined): boolean {
|
function isOwnBusiness(businessId: string | null | undefined): boolean {
|
||||||
if (!businessId || !auth.isBusinessOwner) return false;
|
if (!businessId || !auth.isBusinessOwner) return false;
|
||||||
return ownedBusinessIds.value.has(businessId);
|
return ownedBusinessIds.value.has(businessId);
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import type { AppIconName } from '@/types/icons';
|
||||||
|
|
||||||
|
export const BUSINESS_HIGHLIGHT_KEYS = [
|
||||||
|
'customer_satisfaction',
|
||||||
|
'professional_staff',
|
||||||
|
'online_booking',
|
||||||
|
'fair_pricing',
|
||||||
|
'clean_space',
|
||||||
|
'modern_equipment',
|
||||||
|
'quality_materials',
|
||||||
|
'fast_service',
|
||||||
|
'experienced_team',
|
||||||
|
'easy_access',
|
||||||
|
'secure_payment',
|
||||||
|
'friendly_support',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type BusinessHighlightKey = (typeof BUSINESS_HIGHLIGHT_KEYS)[number];
|
||||||
|
|
||||||
|
export type BusinessHighlightItem = {
|
||||||
|
key: BusinessHighlightKey;
|
||||||
|
label: string;
|
||||||
|
icon: AppIconName;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BUSINESS_HIGHLIGHT_CATALOG: BusinessHighlightItem[] = [
|
||||||
|
{ key: 'customer_satisfaction', label: 'رضایت مشتریان', icon: 'shield' },
|
||||||
|
{ key: 'professional_staff', label: 'پرسنل حرفهای', icon: 'users' },
|
||||||
|
{ key: 'online_booking', label: 'رزرو آنلاین آسان', icon: 'calendar' },
|
||||||
|
{ key: 'fair_pricing', label: 'قیمت شفاف', icon: 'badge-percent' },
|
||||||
|
{ key: 'clean_space', label: 'محیط تمیز و مرتب', icon: 'sparkles' },
|
||||||
|
{ key: 'modern_equipment', label: 'تجهیزات مدرن', icon: 'settings' },
|
||||||
|
{ key: 'quality_materials', label: 'مواد باکیفیت', icon: 'droplet' },
|
||||||
|
{ key: 'fast_service', label: 'سرویس سریع', icon: 'zap' },
|
||||||
|
{ key: 'experienced_team', label: 'تیم با تجربه', icon: 'clipboard-list' },
|
||||||
|
{ key: 'easy_access', label: 'دسترسی آسان', icon: 'map-pin' },
|
||||||
|
{ key: 'secure_payment', label: 'پرداخت امن', icon: 'shield' },
|
||||||
|
{ key: 'friendly_support', label: 'پشتیبانی دوستانه', icon: 'headphones' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DEFAULT_BUSINESS_HIGHLIGHT_KEYS: BusinessHighlightKey[] = [
|
||||||
|
'customer_satisfaction',
|
||||||
|
'professional_staff',
|
||||||
|
'online_booking',
|
||||||
|
];
|
||||||
|
|
||||||
|
const catalogByKey = new Map(
|
||||||
|
BUSINESS_HIGHLIGHT_CATALOG.map((item) => [item.key, item]),
|
||||||
|
);
|
||||||
|
|
||||||
|
export function resolveBusinessHighlights(
|
||||||
|
keys: string[] | null | undefined,
|
||||||
|
): BusinessHighlightItem[] {
|
||||||
|
const selected = (keys ?? [])
|
||||||
|
.map((key) => catalogByKey.get(key as BusinessHighlightKey))
|
||||||
|
.filter((item): item is BusinessHighlightItem => Boolean(item))
|
||||||
|
.slice(0, 3);
|
||||||
|
|
||||||
|
if (selected.length > 0) {
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
return DEFAULT_BUSINESS_HIGHLIGHT_KEYS.map(
|
||||||
|
(key) => catalogByKey.get(key)!,
|
||||||
|
).filter(Boolean);
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex min-h-dvh flex-col bg-surface-2 dark:bg-[#0d1117]">
|
||||||
|
<div class="relative isolate shrink-0">
|
||||||
|
<div class="relative h-[min(34dvh,280px)] min-h-[200px] overflow-hidden">
|
||||||
|
<img
|
||||||
|
:src="heroSrc"
|
||||||
|
alt=""
|
||||||
|
class="absolute inset-0 h-full w-full object-cover"
|
||||||
|
@error="onImageError"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 bg-gradient-to-b from-slate-950/55 via-slate-900/45 to-slate-950/80"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="absolute inset-x-0 top-0 z-10 flex items-center justify-between px-4 pt-[max(0.75rem,env(safe-area-inset-top))]"
|
||||||
|
>
|
||||||
|
<AppLogo
|
||||||
|
variant="horizontal"
|
||||||
|
class="h-10 shrink-0"
|
||||||
|
:light-src="logoSources.dark"
|
||||||
|
:dark-src="logoSources.dark"
|
||||||
|
alt="منتایم"
|
||||||
|
/>
|
||||||
|
<ThemeToggle
|
||||||
|
class="!border-white/20 !bg-black/25 !text-white hover:!bg-black/40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="absolute inset-x-0 bottom-0 z-10 flex flex-col items-center px-6 pb-10 pt-8 text-center"
|
||||||
|
>
|
||||||
|
<h1 class="text-xl font-black leading-snug text-white">
|
||||||
|
وقت شما
|
||||||
|
<span class="text-brand-400"> ارزشمند است</span>
|
||||||
|
</h1>
|
||||||
|
<p class="mt-1 text-sm text-white/80">
|
||||||
|
نوبتگیری سریع و مدیریت آسان خدمات
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main
|
||||||
|
class="relative z-20 -mt-5 flex min-h-0 flex-1 flex-col px-4 pb-[max(1.5rem,env(safe-area-inset-bottom))]"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx-auto w-full max-w-md flex-1 rounded-2xl border border-border bg-surface p-5 shadow-[0_16px_40px_-18px_rgba(15,23,42,0.35)] dark:bg-[#121a24]"
|
||||||
|
>
|
||||||
|
<div class="mb-5 flex items-center gap-2 text-sm font-semibold text-text">
|
||||||
|
<AppIcon name="user" size="sm" class="text-brand-600 dark:text-brand-500" />
|
||||||
|
<span>{{ headerLabel }}</span>
|
||||||
|
</div>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
import { onMounted, ref, watch } from 'vue';
|
||||||
|
import AppLogo from '@/components/brand/AppLogo.vue';
|
||||||
|
import AppIcon from '@/components/ui/AppIcon.vue';
|
||||||
|
import ThemeToggle from '@/components/ui/ThemeToggle.vue';
|
||||||
|
import { usePlatformSettingsStore } from '@/stores/platform-settings';
|
||||||
|
import { useTenantStore } from '@/stores/tenant';
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
headerLabel?: string;
|
||||||
|
}>(),
|
||||||
|
{ headerLabel: 'ورود به حساب کاربری' },
|
||||||
|
);
|
||||||
|
|
||||||
|
const platformSettings = usePlatformSettingsStore();
|
||||||
|
const tenant = useTenantStore();
|
||||||
|
const { loginHeroSrc, logoSources } = storeToRefs(platformSettings);
|
||||||
|
|
||||||
|
const heroSrc = ref(loginHeroSrc.value);
|
||||||
|
const fallbackUsed = ref(false);
|
||||||
|
|
||||||
|
watch(loginHeroSrc, (src) => {
|
||||||
|
fallbackUsed.value = false;
|
||||||
|
heroSrc.value = src;
|
||||||
|
});
|
||||||
|
|
||||||
|
function onImageError() {
|
||||||
|
if (fallbackUsed.value) return;
|
||||||
|
fallbackUsed.value = true;
|
||||||
|
heroSrc.value = '/landing/hero-car-wash.jpg';
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void Promise.all([platformSettings.load(), tenant.bootstrap()]);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -3,6 +3,13 @@
|
|||||||
<slot />
|
<slot />
|
||||||
</DesktopLoginLayout>
|
</DesktopLoginLayout>
|
||||||
|
|
||||||
|
<MobileLoginLayout
|
||||||
|
v-else-if="brand"
|
||||||
|
:header-label="desktopHeaderLabel"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</MobileLoginLayout>
|
||||||
|
|
||||||
<AuthLayout
|
<AuthLayout
|
||||||
v-else
|
v-else
|
||||||
:title="title"
|
:title="title"
|
||||||
@@ -18,6 +25,7 @@ import { useMediaQuery } from '@vueuse/core';
|
|||||||
import { computed, onMounted } from 'vue';
|
import { computed, onMounted } from 'vue';
|
||||||
import AuthLayout from '@/layouts/AuthLayout.vue';
|
import AuthLayout from '@/layouts/AuthLayout.vue';
|
||||||
import DesktopLoginLayout from '@/layouts/DesktopLoginLayout.vue';
|
import DesktopLoginLayout from '@/layouts/DesktopLoginLayout.vue';
|
||||||
|
import MobileLoginLayout from '@/layouts/MobileLoginLayout.vue';
|
||||||
import { useTenantStore } from '@/stores/tenant';
|
import { useTenantStore } from '@/stores/tenant';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -38,4 +46,3 @@ onMounted(() => {
|
|||||||
void tenant.bootstrap();
|
void tenant.bootstrap();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -42,7 +42,8 @@ export type AppIconName =
|
|||||||
| 'spray'
|
| 'spray'
|
||||||
| 'zap'
|
| 'zap'
|
||||||
| 'gift'
|
| 'gift'
|
||||||
| 'info';
|
| 'info'
|
||||||
|
| 'log-out';
|
||||||
|
|
||||||
export type NavItem = {
|
export type NavItem = {
|
||||||
to: string;
|
to: string;
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ const visibleEntries = computed(() => {
|
|||||||
|
|
||||||
const emptyMessage = computed(() => {
|
const emptyMessage = computed(() => {
|
||||||
if (!auth.isBusinessOwner) {
|
if (!auth.isBusinessOwner) {
|
||||||
return 'هنوز رزروی ندارید.';
|
return 'هنوز رزروی ثبت نکردید.';
|
||||||
}
|
}
|
||||||
if (activeOwnerTab.value === 'pending') {
|
if (activeOwnerTab.value === 'pending') {
|
||||||
return 'رزروی در انتظار تأیید نیست.';
|
return 'رزروی در انتظار تأیید نیست.';
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ watch(showSingleBusinessLanding, (show) => {
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (auth.isLoggedIn) {
|
if (auth.isLoggedIn) {
|
||||||
await auth.loadUser();
|
await auth.loadUser();
|
||||||
await ensureLoaded();
|
await ensureLoaded(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
await tenant.bootstrap();
|
await tenant.bootstrap();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<ResponsiveAppLayout>
|
<ResponsiveAppLayout>
|
||||||
<h1 class="mb-1 text-xl font-bold text-text">مدیریت کسبوکار</h1>
|
<h1 class="mb-1 text-xl font-bold text-text">کسبوکار من</h1>
|
||||||
<p class="mb-6 text-sm text-muted">تنظیمات کسبوکار، خدمات و ساعات کاری</p>
|
<p class="mb-6 text-sm text-muted">تنظیمات کسبوکار، خدمات و ساعات کاری</p>
|
||||||
|
|
||||||
<p
|
<p
|
||||||
@@ -70,6 +70,8 @@
|
|||||||
/>
|
/>
|
||||||
<AppInput id="ownerAddress" v-model="form.address" label="آدرس" />
|
<AppInput id="ownerAddress" v-model="form.address" label="آدرس" />
|
||||||
|
|
||||||
|
<BusinessHighlightPicker v-model="form.heroHighlightKeys" />
|
||||||
|
|
||||||
<AppButton type="submit" :loading="saving">ذخیره تغییرات</AppButton>
|
<AppButton type="submit" :loading="saving">ذخیره تغییرات</AppButton>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -594,6 +596,7 @@ import type {
|
|||||||
} from '@/api/types';
|
} from '@/api/types';
|
||||||
import { ApiError } from '@/api/client';
|
import { ApiError } from '@/api/client';
|
||||||
import BusinessAvailabilityEditor from '@/components/owner/BusinessAvailabilityEditor.vue';
|
import BusinessAvailabilityEditor from '@/components/owner/BusinessAvailabilityEditor.vue';
|
||||||
|
import BusinessHighlightPicker from '@/components/owner/BusinessHighlightPicker.vue';
|
||||||
import BusinessImageUpload from '@/components/owner/BusinessImageUpload.vue';
|
import BusinessImageUpload from '@/components/owner/BusinessImageUpload.vue';
|
||||||
import LogoUploadPair from '@/components/shared/LogoUploadPair.vue';
|
import LogoUploadPair from '@/components/shared/LogoUploadPair.vue';
|
||||||
import ServiceClassManager from '@/components/owner/ServiceClassManager.vue';
|
import ServiceClassManager from '@/components/owner/ServiceClassManager.vue';
|
||||||
@@ -650,6 +653,7 @@ const form = ref({
|
|||||||
description: '',
|
description: '',
|
||||||
contactPhone: '',
|
contactPhone: '',
|
||||||
address: '',
|
address: '',
|
||||||
|
heroHighlightKeys: [] as string[],
|
||||||
});
|
});
|
||||||
|
|
||||||
const bookingForm = ref({
|
const bookingForm = ref({
|
||||||
@@ -815,6 +819,7 @@ onMounted(async () => {
|
|||||||
description: business.value.description ?? '',
|
description: business.value.description ?? '',
|
||||||
contactPhone: business.value.contactPhone ?? business.value.phone ?? '',
|
contactPhone: business.value.contactPhone ?? business.value.phone ?? '',
|
||||||
address: business.value.address ?? '',
|
address: business.value.address ?? '',
|
||||||
|
heroHighlightKeys: [...(business.value.heroHighlightKeys ?? [])],
|
||||||
};
|
};
|
||||||
bookingForm.value = {
|
bookingForm.value = {
|
||||||
concurrentCapacity: String(business.value.concurrentCapacity ?? 1),
|
concurrentCapacity: String(business.value.concurrentCapacity ?? 1),
|
||||||
@@ -982,7 +987,9 @@ async function onSave() {
|
|||||||
description: form.value.description.trim() || null,
|
description: form.value.description.trim() || null,
|
||||||
contactPhone: form.value.contactPhone.trim() || null,
|
contactPhone: form.value.contactPhone.trim() || null,
|
||||||
address: form.value.address.trim() || null,
|
address: form.value.address.trim() || null,
|
||||||
|
heroHighlightKeys: form.value.heroHighlightKeys,
|
||||||
});
|
});
|
||||||
|
form.value.heroHighlightKeys = [...(business.value.heroHighlightKeys ?? [])];
|
||||||
tenant.syncSingleBusiness(business.value);
|
tenant.syncSingleBusiness(business.value);
|
||||||
message.value = 'اطلاعات ذخیره شد.';
|
message.value = 'اطلاعات ذخیره شد.';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
id="bizCategory"
|
id="bizCategory"
|
||||||
v-model="form.categoryId"
|
v-model="form.categoryId"
|
||||||
label="دستهبندی"
|
label="دستهبندی"
|
||||||
|
placeholder="انتخاب دستهبندی"
|
||||||
:options="categoryOptions"
|
:options="categoryOptions"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@@ -67,6 +68,16 @@ const categoryOptions = computed(() =>
|
|||||||
async function onSubmit() {
|
async function onSubmit() {
|
||||||
error.value = '';
|
error.value = '';
|
||||||
message.value = '';
|
message.value = '';
|
||||||
|
|
||||||
|
if (!form.value.businessTitle.trim()) {
|
||||||
|
error.value = 'نام کسبوکار را وارد کنید.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!form.value.categoryId) {
|
||||||
|
error.value = 'دستهبندی را انتخاب کنید.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
await submitBusinessRegistration({
|
await submitBusinessRegistration({
|
||||||
@@ -85,12 +96,30 @@ async function onSubmit() {
|
|||||||
phone: '',
|
phone: '',
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e instanceof ApiError ? e.message : 'ثبت درخواست ناموفق بود.';
|
error.value =
|
||||||
|
e instanceof ApiError ? mapRegistrationError(e.message) : 'ثبت درخواست ناموفق بود.';
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mapRegistrationError(raw: string): string {
|
||||||
|
const lower = raw.toLowerCase();
|
||||||
|
if (lower.includes('uuid') || lower.includes('categoryid')) {
|
||||||
|
return 'دستهبندی را بهدرستی انتخاب کنید.';
|
||||||
|
}
|
||||||
|
if (lower.includes('already manage a business') || lower.includes('already have a registered')) {
|
||||||
|
return 'شما از قبل کسبوکار ثبتشده دارید.';
|
||||||
|
}
|
||||||
|
if (lower.includes('pending registration')) {
|
||||||
|
return 'یک درخواست در انتظار بررسی دارید.';
|
||||||
|
}
|
||||||
|
if (lower.includes('admin cannot')) {
|
||||||
|
return 'حساب ادمین نمیتواند درخواست ثبت کسبوکار بفرستد.';
|
||||||
|
}
|
||||||
|
return raw || 'ثبت درخواست ناموفق بود.';
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
categories.value = await fetchBusinessCategories();
|
categories.value = await fetchBusinessCategories();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<ResponsiveAuthLayout brand subtitle="ورود به حساب کاربری">
|
<ResponsiveAuthLayout brand subtitle="ورود به حساب کاربری">
|
||||||
<div class="flex flex-1 flex-col justify-start gap-8">
|
<div class="flex flex-1 flex-col justify-start gap-6 lg:gap-8">
|
||||||
<section class="flex flex-col items-center gap-4 text-center">
|
<section class="hidden flex-col items-center gap-4 text-center lg:flex">
|
||||||
<AppLogo variant="vertical" class="h-24 w-auto object-contain lg:h-28" />
|
<AppLogo variant="vertical" class="h-24 w-auto object-contain lg:h-28" />
|
||||||
<AppWelcomeMessage />
|
<AppWelcomeMessage />
|
||||||
</section>
|
</section>
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
{{ error }}
|
{{ error }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="flex flex-col gap-8">
|
<div class="flex flex-col gap-6 lg:gap-8">
|
||||||
<AuthModeSwitch v-model="mode" />
|
<AuthModeSwitch v-model="mode" />
|
||||||
|
|
||||||
<form class="flex flex-col gap-4" @submit.prevent="onSubmit">
|
<form class="flex flex-col gap-4" @submit.prevent="onSubmit">
|
||||||
|
|||||||
Reference in New Issue
Block a user