edit image business
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
# Dev: /api (Vite proxy) | Production: see .env.production
|
||||
# Full local API: http://localhost:3000
|
||||
VITE_API_URL=/api
|
||||
# Proxy target when VITE_API_URL=/api (uncomment for local API during dev)
|
||||
# VITE_DEV_API_PROXY=http://localhost:3000
|
||||
# Production: https://api.montime.ir (uploads served from API)
|
||||
# VITE_ASSETS_URL=https://api.montime.ir
|
||||
|
||||
+22
-1
@@ -1,5 +1,6 @@
|
||||
import { api } from './client';
|
||||
import type { Business, UpdateBusinessPayload } from './types';
|
||||
import { apiFormData } from './client';
|
||||
import type { Business, BusinessImage, UpdateBusinessPayload } from './types';
|
||||
|
||||
export function fetchBusinesses(params?: {
|
||||
categoryId?: string;
|
||||
@@ -30,3 +31,23 @@ export function updateBusiness(id: string, payload: UpdateBusinessPayload) {
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function uploadBusinessImages(files: File[]) {
|
||||
const form = new FormData();
|
||||
for (const file of files) {
|
||||
form.append('files', file);
|
||||
}
|
||||
return apiFormData<BusinessImage[]>('/businesses/mine/images', form);
|
||||
}
|
||||
|
||||
export function deleteBusinessImage(imageId: string) {
|
||||
return api<{ success: true }>(`/businesses/mine/images/${imageId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
export function setBusinessCoverImage(imageId: string) {
|
||||
return api<BusinessImage>(`/businesses/mine/images/${imageId}/cover`, {
|
||||
method: 'PATCH',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -37,6 +37,11 @@ function resolveApiErrorMessage(
|
||||
}
|
||||
|
||||
const raw = typeof body?.message === 'string' ? body.message : statusText;
|
||||
|
||||
if (status === 403) {
|
||||
return 'شما اجازه انجام این کار را ندارید.';
|
||||
}
|
||||
|
||||
const genericServer =
|
||||
status >= 500 &&
|
||||
(!raw ||
|
||||
@@ -92,3 +97,37 @@ export async function api<T>(path: string, options: ApiOptions = {}): Promise<T>
|
||||
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export async function apiFormData<T>(path: string, form: FormData): Promise<T> {
|
||||
const token = getApiToken();
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${baseUrl}${path}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: form,
|
||||
});
|
||||
} catch {
|
||||
throw new ApiError(
|
||||
0,
|
||||
'ارتباط با سرور برقرار نشد. اتصال اینترنت را بررسی کنید یا چند لحظه بعد دوباره تلاش کنید.',
|
||||
);
|
||||
}
|
||||
|
||||
logApiServerTiming(path, res);
|
||||
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => null)) as
|
||||
| { message?: string | string[] }
|
||||
| null;
|
||||
const msg = resolveApiErrorMessage(res.status, res.statusText, body);
|
||||
throw new ApiError(res.status, msg);
|
||||
}
|
||||
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
<template>
|
||||
<div class="-mx-4 -mt-4 flex flex-col bg-white dark:bg-surface">
|
||||
<section class="px-5 pb-2 pt-3 text-center">
|
||||
<h1 class="text-[1.65rem] font-black leading-snug text-brand-600 dark:text-brand-500">
|
||||
{{ businessTitle }}
|
||||
</h1>
|
||||
<p
|
||||
v-if="businessSubtitle"
|
||||
class="mt-1.5 text-sm font-medium text-landing-ink dark:text-text"
|
||||
>
|
||||
{{ businessSubtitle }}
|
||||
<span aria-hidden="true"> ✨</span>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="px-5 py-3">
|
||||
<div
|
||||
class="overflow-hidden rounded-2xl bg-slate-100 shadow-[0_8px_30px_rgba(15,23,42,0.12)] dark:bg-surface-2 dark:shadow-black/30"
|
||||
>
|
||||
<img
|
||||
v-if="heroImage && !heroImageFailed"
|
||||
:src="heroImage"
|
||||
:alt="businessTitle"
|
||||
class="h-52 w-full object-cover"
|
||||
@error="heroImageFailed = true"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-52 w-full items-center justify-center bg-gradient-to-br from-brand-50 via-slate-100 to-brand-100 dark:from-brand-500/10 dark:via-surface-3 dark:to-surface-2"
|
||||
>
|
||||
<AppIcon name="car" size="xl" class="h-16 w-16 text-brand-600/25 dark:text-brand-500/30" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="px-5 pb-5">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex w-full items-center justify-center gap-2 rounded-2xl py-4 text-base font-bold text-white shadow-sm transition"
|
||||
:class="
|
||||
bookingEnabled
|
||||
? 'bg-brand-600 hover:bg-brand-700 active:bg-brand-800'
|
||||
: 'cursor-not-allowed bg-brand-600/45 dark:bg-brand-600/30'
|
||||
"
|
||||
:disabled="!bookingEnabled"
|
||||
:aria-disabled="!bookingEnabled"
|
||||
@click="onBook"
|
||||
>
|
||||
<AppIcon name="calendar" size="sm" />
|
||||
رزرو نوبت
|
||||
</button>
|
||||
<p
|
||||
v-if="!bookingEnabled"
|
||||
class="mt-2 text-center text-xs text-muted"
|
||||
>
|
||||
بهعنوان صاحب کسبوکار نمیتوانید برای خودتان نوبت رزرو کنید.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="px-5 pb-6">
|
||||
<div class="grid grid-cols-4 gap-2.5">
|
||||
<button
|
||||
v-for="action in quickActions"
|
||||
:key="action.id"
|
||||
type="button"
|
||||
class="flex flex-col items-center gap-2 rounded-2xl border border-slate-100 bg-white px-1.5 py-3 text-center shadow-[0_2px_12px_rgba(15,23,42,0.06)] transition active:scale-[0.98] dark:border-border dark:bg-surface-2 dark:shadow-black/20"
|
||||
@click="scrollTo(action.targetId)"
|
||||
>
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-xl border-2 border-brand-500/35 text-brand-600 dark:border-brand-500/50 dark:text-brand-500"
|
||||
>
|
||||
<AppIcon :name="action.icon" size="sm" />
|
||||
</div>
|
||||
<span class="text-[10px] font-bold leading-4 text-landing-ink dark:text-text">
|
||||
{{ action.label }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="club" class="scroll-mt-24 px-5 pb-6">
|
||||
<div
|
||||
class="flex items-center justify-between gap-3 rounded-2xl bg-brand-50 px-4 py-4 dark:bg-brand-500/10"
|
||||
>
|
||||
<div class="min-w-0 flex-1 space-y-1">
|
||||
<p class="text-xs text-landing-muted dark:text-muted">اولین رزرو شما</p>
|
||||
<p class="text-2xl font-black text-brand-600 dark:text-brand-500">۱۰٪ تخفیف</p>
|
||||
<p class="text-xs font-medium text-landing-ink dark:text-text">
|
||||
کد تخفیف:
|
||||
<span class="font-bold" dir="ltr">NEW10</span>
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="flex h-16 w-16 shrink-0 items-center justify-center rounded-2xl bg-white shadow-sm dark:bg-surface dark:shadow-black/20"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<AppIcon name="gift" size="lg" class="text-brand-600 dark:text-brand-500" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<HomeServicesSection
|
||||
:items="services"
|
||||
:loading="loadingServices"
|
||||
:error="servicesError"
|
||||
:booking-enabled="bookingEnabled"
|
||||
/>
|
||||
|
||||
<section
|
||||
id="about"
|
||||
class="scroll-mt-24 border-t border-landing-line bg-white px-5 py-8 dark:border-border dark:bg-surface"
|
||||
>
|
||||
<h2 class="text-base font-bold text-landing-ink dark:text-text">درباره ما</h2>
|
||||
<p class="mt-3 text-sm leading-7 text-landing-muted dark:text-muted">
|
||||
{{ aboutText || `به ${businessTitle} خوش آمدید.` }}
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import HomeServicesSection from '@/components/home/HomeServicesSection.vue';
|
||||
import AppIcon from '@/components/ui/AppIcon.vue';
|
||||
import { useTenantStore } from '@/stores/tenant';
|
||||
import type { AppIconName } from '@/types/icons';
|
||||
import type { LandingServiceItem } from '@/types/landing';
|
||||
import { businessFirstImageUrl } from '@/utils/media';
|
||||
|
||||
const props = defineProps<{
|
||||
services: LandingServiceItem[];
|
||||
loadingServices: boolean;
|
||||
servicesError: string;
|
||||
bookingEnabled: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
book: [];
|
||||
}>();
|
||||
|
||||
const tenant = useTenantStore();
|
||||
const { singleBusiness } = storeToRefs(tenant);
|
||||
|
||||
const heroImageFailed = ref(false);
|
||||
|
||||
const businessTitle = computed(() => singleBusiness.value?.title ?? '');
|
||||
const businessSubtitle = computed(() => singleBusiness.value?.subtitle?.trim() ?? '');
|
||||
const aboutText = computed(() => singleBusiness.value?.description?.trim() ?? '');
|
||||
|
||||
const heroImage = computed(() =>
|
||||
businessFirstImageUrl(singleBusiness.value?.images),
|
||||
);
|
||||
|
||||
watch(heroImage, () => {
|
||||
heroImageFailed.value = false;
|
||||
});
|
||||
|
||||
const quickActions: {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: AppIconName;
|
||||
targetId: string;
|
||||
}[] = [
|
||||
{ id: 'services', label: 'خدمات کارواش', icon: 'car', targetId: 'services' },
|
||||
{ id: 'pricing', label: 'تعرفهها', icon: 'badge-percent', targetId: 'services' },
|
||||
{ id: 'club', label: 'باشگاه مشتریان', icon: 'sparkles', targetId: 'club' },
|
||||
{ id: 'about', label: 'درباره ما', icon: 'info', targetId: 'about' },
|
||||
];
|
||||
|
||||
function scrollTo(targetId: string) {
|
||||
const el = document.getElementById(targetId);
|
||||
el?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
function onBook() {
|
||||
if (!props.bookingEnabled) return;
|
||||
emit('book');
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<label class="block text-sm font-medium text-muted">تصاویر کسبوکار</label>
|
||||
<p class="text-xs text-muted">حداکثر ۱۰ تصویر — هر کدام تا ۵ مگابایت (JPG, PNG, WebP)</p>
|
||||
|
||||
<div v-if="existing.length" class="grid grid-cols-3 gap-2">
|
||||
<div
|
||||
v-for="img in existing"
|
||||
:key="img.id"
|
||||
class="relative aspect-square overflow-hidden rounded-xl border border-border bg-surface-3"
|
||||
>
|
||||
<img
|
||||
:src="uploadUrl(img.url)"
|
||||
:alt="img.originalName ?? ''"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
<span
|
||||
v-if="img.isCover"
|
||||
class="absolute end-1 top-1 rounded bg-brand-600/90 px-1.5 py-0.5 text-[10px] text-white"
|
||||
>
|
||||
کاور
|
||||
</span>
|
||||
<div class="absolute inset-x-1 bottom-1 flex gap-1">
|
||||
<button
|
||||
v-if="!img.isCover"
|
||||
type="button"
|
||||
class="flex-1 rounded-lg bg-black/65 px-1 py-1 text-[10px] font-medium text-white transition hover:bg-brand-600 disabled:opacity-50"
|
||||
:disabled="settingCoverId === img.id || deletingId === img.id || uploading"
|
||||
@click="emit('set-cover', img.id)"
|
||||
>
|
||||
{{ settingCoverId === img.id ? '…' : 'کاور' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-black/65 text-white transition hover:bg-red-600 disabled:opacity-50"
|
||||
:disabled="deletingId === img.id || uploading"
|
||||
aria-label="حذف تصویر"
|
||||
@click="emit('remove-existing', img.id)"
|
||||
>
|
||||
<AppIcon name="trash" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label
|
||||
v-if="existing.length < 10"
|
||||
class="flex cursor-pointer flex-col items-center justify-center rounded-xl border border-dashed border-border bg-surface-3 px-4 py-6 text-sm text-muted transition hover:border-brand-500/50 hover:bg-brand-500/5"
|
||||
:class="uploading ? 'pointer-events-none opacity-60' : ''"
|
||||
>
|
||||
<AppIcon name="camera" size="lg" class="text-muted" />
|
||||
<span class="mt-2">{{ uploading ? 'در حال آپلود…' : 'افزودن تصویر' }}</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
multiple
|
||||
class="hidden"
|
||||
:disabled="uploading"
|
||||
@change="onPick"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { BusinessImage } from '@/api/types';
|
||||
import AppIcon from '@/components/ui/AppIcon.vue';
|
||||
import { uploadUrl } from '@/utils/media';
|
||||
|
||||
defineProps<{
|
||||
existing: BusinessImage[];
|
||||
deletingId?: string | null;
|
||||
settingCoverId?: string | null;
|
||||
uploading?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'remove-existing': [imageId: string];
|
||||
'set-cover': [imageId: string];
|
||||
'files-picked': [files: File[]];
|
||||
}>();
|
||||
|
||||
function onPick(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const picked = Array.from(input.files ?? []);
|
||||
input.value = '';
|
||||
if (!picked.length) return;
|
||||
emit('files-picked', picked);
|
||||
}
|
||||
</script>
|
||||
@@ -28,7 +28,7 @@
|
||||
leave-to-class="opacity-0 translate-y-1"
|
||||
>
|
||||
<div
|
||||
v-if="profileMenu.isOpen"
|
||||
v-if="profileMenu.isOpen && !isMobileProfile"
|
||||
class="absolute z-50 max-h-[min(70dvh,32rem)] w-80 max-w-[calc(100vw-2rem)] overflow-y-auto rounded-2xl border border-border bg-surface p-4 shadow-xl dark:border-slate-700 dark:bg-[#121a24]"
|
||||
:class="panelClass"
|
||||
>
|
||||
@@ -40,7 +40,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { onClickOutside, onKeyStroke } from '@vueuse/core';
|
||||
import { onClickOutside, onKeyStroke, useMediaQuery } from '@vueuse/core';
|
||||
import ProfilePanel from '@/components/profile/ProfilePanel.vue';
|
||||
import AppIcon from '@/components/ui/AppIcon.vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
@@ -57,6 +57,7 @@ const props = withDefaults(
|
||||
const auth = useAuthStore();
|
||||
const profileMenu = useProfileModalStore();
|
||||
const rootRef = ref<HTMLElement | null>(null);
|
||||
const isMobileProfile = useMediaQuery('(max-width: 1023px)');
|
||||
|
||||
const triggerLabel = computed(() => userDisplayName(auth.user));
|
||||
const labelIsMobile = computed(() => userDisplayNameIsMobile(auth.user));
|
||||
@@ -106,14 +107,18 @@ const panelClass = computed(() => {
|
||||
return 'top-full mt-2 end-0';
|
||||
});
|
||||
|
||||
onClickOutside(rootRef, () => {
|
||||
if (profileMenu.isOpen) {
|
||||
profileMenu.close();
|
||||
}
|
||||
});
|
||||
onClickOutside(
|
||||
rootRef,
|
||||
() => {
|
||||
if (profileMenu.isOpen && !isMobileProfile.value) {
|
||||
profileMenu.close();
|
||||
}
|
||||
},
|
||||
{ ignore: ['[data-app-modal]'] },
|
||||
);
|
||||
|
||||
onKeyStroke('Escape', (event) => {
|
||||
if (!profileMenu.isOpen) return;
|
||||
if (!profileMenu.isOpen || isMobileProfile.value) return;
|
||||
event.preventDefault();
|
||||
profileMenu.close();
|
||||
});
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
<template>
|
||||
<AppModal :open="profileModal.isOpen" title="پروفایل" @close="profileModal.close()">
|
||||
<AppModal
|
||||
:open="profileModal.isOpen && isMobileProfile"
|
||||
title="پروفایل"
|
||||
@close="profileModal.close()"
|
||||
>
|
||||
<ProfilePanel />
|
||||
</AppModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMediaQuery } from '@vueuse/core';
|
||||
import ProfilePanel from '@/components/profile/ProfilePanel.vue';
|
||||
import AppModal from '@/components/ui/AppModal.vue';
|
||||
import { useProfileModalStore } from '@/stores/profile-modal';
|
||||
|
||||
const profileModal = useProfileModalStore();
|
||||
const isMobileProfile = useMediaQuery('(max-width: 1023px)');
|
||||
</script>
|
||||
|
||||
@@ -19,6 +19,8 @@ export default defineComponent({
|
||||
import type { Component } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import {
|
||||
Gift,
|
||||
Info,
|
||||
Armchair,
|
||||
ArrowRight,
|
||||
BadgePercent,
|
||||
@@ -103,6 +105,8 @@ const iconMap: Record<AppIconName, Component> = {
|
||||
cog: Cog,
|
||||
spray: SprayCan,
|
||||
zap: Zap,
|
||||
gift: Gift,
|
||||
info: Info,
|
||||
};
|
||||
|
||||
const props = withDefaults(
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
<div
|
||||
class="max-h-[90dvh] w-full max-w-app overflow-y-auto rounded-t-2xl border border-border bg-surface p-4 shadow-xl sm:rounded-2xl"
|
||||
role="dialog"
|
||||
data-app-modal
|
||||
@click.stop
|
||||
>
|
||||
<div class="mb-4 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
--color-border: #e2e8f0;
|
||||
--color-text: #0f172a;
|
||||
--color-muted: #64748b;
|
||||
--color-placeholder: #e2e8f0;
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -19,6 +20,7 @@
|
||||
--color-border: #334155;
|
||||
--color-text: #f1f5f9;
|
||||
--color-muted: #94a3b8;
|
||||
--color-placeholder: #3d4f63;
|
||||
}
|
||||
|
||||
html {
|
||||
|
||||
+3
-1
@@ -38,7 +38,9 @@ export type AppIconName =
|
||||
| 'car'
|
||||
| 'cog'
|
||||
| 'spray'
|
||||
| 'zap';
|
||||
| 'zap'
|
||||
| 'gift'
|
||||
| 'info';
|
||||
|
||||
export type NavItem = {
|
||||
to: string;
|
||||
|
||||
@@ -6,17 +6,22 @@ export type BookingListEntry = {
|
||||
};
|
||||
|
||||
export function groupBookingsForDisplay(bookings: Booking[]): BookingListEntry[] {
|
||||
const sorted = [...bookings].sort(
|
||||
(a, b) =>
|
||||
new Date(b.scheduledAt).getTime() - new Date(a.scheduledAt).getTime(),
|
||||
);
|
||||
|
||||
const seenGroupIds = new Set<string>();
|
||||
const entries: BookingListEntry[] = [];
|
||||
|
||||
for (const booking of bookings) {
|
||||
for (const booking of sorted) {
|
||||
const groupId = booking.bookingGroupId;
|
||||
if (groupId) {
|
||||
if (seenGroupIds.has(groupId)) continue;
|
||||
seenGroupIds.add(groupId);
|
||||
entries.push({
|
||||
key: groupId,
|
||||
bookings: bookings.filter((item) => item.bookingGroupId === groupId),
|
||||
bookings: sorted.filter((item) => item.bookingGroupId === groupId),
|
||||
});
|
||||
} else {
|
||||
entries.push({ key: booking.id, bookings: [booking] });
|
||||
|
||||
@@ -12,6 +12,16 @@ export function uploadUrl(path: string | null | undefined): string {
|
||||
return assetsBase ? `${assetsBase}${rel}` : rel;
|
||||
}
|
||||
|
||||
export function businessFirstImageUrl(
|
||||
images?: { url: string; sortOrder?: number }[] | null,
|
||||
): string | null {
|
||||
if (!images?.length) return null;
|
||||
const [first] = [...images].sort(
|
||||
(a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0),
|
||||
);
|
||||
return uploadUrl(first?.url) || null;
|
||||
}
|
||||
|
||||
export function businessCoverUrl(
|
||||
images?: { url: string; isCover?: boolean }[] | null,
|
||||
): string | null {
|
||||
|
||||
@@ -8,18 +8,13 @@
|
||||
/>
|
||||
|
||||
<WebAppLayout v-else-if="showMobileLanding">
|
||||
<HomeHeroSection
|
||||
:show-book-button="canBookSingleBusiness"
|
||||
@book="goToBooking"
|
||||
@browse="scrollToServices"
|
||||
/>
|
||||
<HomeServicesSection
|
||||
:items="landingServices"
|
||||
:loading="loadingLandingServices"
|
||||
:error="landingServicesError"
|
||||
<MobileBusinessLanding
|
||||
:services="landingServices"
|
||||
:loading-services="loadingLandingServices"
|
||||
:services-error="landingServicesError"
|
||||
:booking-enabled="canBookSingleBusiness"
|
||||
@book="goToBooking"
|
||||
/>
|
||||
<HomeFeatureBar />
|
||||
</WebAppLayout>
|
||||
|
||||
<div
|
||||
@@ -42,9 +37,7 @@ import { useMediaQuery } from '@vueuse/core';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import HomeDiscoveryContent from '@/components/home/HomeDiscoveryContent.vue';
|
||||
import HomeFeatureBar from '@/components/home/HomeFeatureBar.vue';
|
||||
import HomeHeroSection from '@/components/home/HomeHeroSection.vue';
|
||||
import HomeServicesSection from '@/components/home/HomeServicesSection.vue';
|
||||
import MobileBusinessLanding from '@/components/landing/MobileBusinessLanding.vue';
|
||||
import DesktopDiscoveryPage from '@/components/landing/DesktopDiscoveryPage.vue';
|
||||
import DesktopLandingPage from '@/components/landing/DesktopLandingPage.vue';
|
||||
import BusinessCardSkeleton from '@/components/ui/BusinessCardSkeleton.vue';
|
||||
@@ -97,22 +90,11 @@ async function loadLandingServices() {
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToServices() {
|
||||
const el = document.getElementById('services');
|
||||
el?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
function goToBooking() {
|
||||
if (!canBookSingleBusiness.value) {
|
||||
void router.push({ name: 'app-owner' });
|
||||
return;
|
||||
}
|
||||
if (!canBookSingleBusiness.value) return;
|
||||
|
||||
const id = tenant.singleBusinessId;
|
||||
if (!id) {
|
||||
scrollToServices();
|
||||
return;
|
||||
}
|
||||
if (!id) return;
|
||||
|
||||
void router.push({
|
||||
name: 'app-business-detail',
|
||||
|
||||
@@ -73,6 +73,18 @@
|
||||
<AppButton type="submit" :loading="saving">ذخیره تغییرات</AppButton>
|
||||
</form>
|
||||
|
||||
<section class="border-t border-border pt-6">
|
||||
<BusinessImageUpload
|
||||
:existing="business.images ?? []"
|
||||
:deleting-id="deletingImageId"
|
||||
:setting-cover-id="settingCoverId"
|
||||
:uploading="uploadingImages"
|
||||
@files-picked="onUploadImages"
|
||||
@remove-existing="onRemoveImage"
|
||||
@set-cover="onSetCoverImage"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section class="border-t border-border pt-4">
|
||||
<button
|
||||
type="button"
|
||||
@@ -423,7 +435,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { fetchMyBusinesses, updateBusiness } from '@/api/businesses';
|
||||
import { fetchMyBusinesses, updateBusiness, uploadBusinessImages, deleteBusinessImage, setBusinessCoverImage } from '@/api/businesses';
|
||||
import {
|
||||
createServiceRequest,
|
||||
fetchMyServiceRequests,
|
||||
@@ -446,6 +458,7 @@ import type {
|
||||
} from '@/api/types';
|
||||
import { ApiError } from '@/api/client';
|
||||
import BusinessAvailabilityEditor from '@/components/owner/BusinessAvailabilityEditor.vue';
|
||||
import BusinessImageUpload from '@/components/owner/BusinessImageUpload.vue';
|
||||
import ServiceClassManager from '@/components/owner/ServiceClassManager.vue';
|
||||
import FormPageSkeleton from '@/components/ui/FormPageSkeleton.vue';
|
||||
import ListCardSkeleton from '@/components/ui/ListCardSkeleton.vue';
|
||||
@@ -479,6 +492,9 @@ const loadingServices = ref(false);
|
||||
const saving = ref(false);
|
||||
const savingBooking = ref(false);
|
||||
const savingService = ref(false);
|
||||
const uploadingImages = ref(false);
|
||||
const deletingImageId = ref<string | null>(null);
|
||||
const settingCoverId = ref<string | null>(null);
|
||||
const addingService = ref(false);
|
||||
const addingCustom = ref(false);
|
||||
const serviceRequests = ref<BusinessServiceRequest[]>([]);
|
||||
@@ -652,6 +668,73 @@ onMounted(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
async function refreshBusinessImages() {
|
||||
if (!business.value) return;
|
||||
const list = await fetchMyBusinesses();
|
||||
const fresh = list.find((item) => item.id === business.value?.id) ?? list[0];
|
||||
if (!fresh) return;
|
||||
business.value = { ...business.value, images: fresh.images ?? [] };
|
||||
tenant.syncSingleBusiness(business.value);
|
||||
}
|
||||
|
||||
async function onUploadImages(files: File[]) {
|
||||
if (!business.value || !files.length) return;
|
||||
|
||||
const currentCount = business.value.images?.length ?? 0;
|
||||
if (currentCount + files.length > 10) {
|
||||
error.value = 'حداکثر ۱۰ تصویر مجاز است.';
|
||||
return;
|
||||
}
|
||||
|
||||
uploadingImages.value = true;
|
||||
error.value = '';
|
||||
message.value = '';
|
||||
try {
|
||||
await uploadBusinessImages(files);
|
||||
await refreshBusinessImages();
|
||||
message.value = 'تصاویر با موفقیت آپلود شدند.';
|
||||
} catch (e) {
|
||||
error.value = e instanceof ApiError ? e.message : 'آپلود تصویر ناموفق بود';
|
||||
} finally {
|
||||
uploadingImages.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onRemoveImage(imageId: string) {
|
||||
if (!business.value) return;
|
||||
if (!window.confirm('این تصویر حذف شود؟')) return;
|
||||
|
||||
deletingImageId.value = imageId;
|
||||
error.value = '';
|
||||
message.value = '';
|
||||
try {
|
||||
await deleteBusinessImage(imageId);
|
||||
await refreshBusinessImages();
|
||||
message.value = 'تصویر حذف شد.';
|
||||
} catch (e) {
|
||||
error.value = e instanceof ApiError ? e.message : 'حذف تصویر ناموفق بود';
|
||||
} finally {
|
||||
deletingImageId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function onSetCoverImage(imageId: string) {
|
||||
if (!business.value) return;
|
||||
|
||||
settingCoverId.value = imageId;
|
||||
error.value = '';
|
||||
message.value = '';
|
||||
try {
|
||||
await setBusinessCoverImage(imageId);
|
||||
await refreshBusinessImages();
|
||||
message.value = 'تصویر کاور بهروزرسانی شد.';
|
||||
} catch (e) {
|
||||
error.value = e instanceof ApiError ? e.message : 'تنظیم کاور ناموفق بود';
|
||||
} finally {
|
||||
settingCoverId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
if (!business.value) return;
|
||||
saving.value = true;
|
||||
|
||||
+28
-25
@@ -1,32 +1,35 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import { fileURLToPath, URL } from 'node:url';
|
||||
|
||||
const API_TARGET = 'https://api.montime.ir';
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '');
|
||||
const API_TARGET = env.VITE_DEV_API_PROXY || 'https://api.montime.ir';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: API_TARGET,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
timeout: 30_000,
|
||||
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||
},
|
||||
'/uploads': {
|
||||
target: API_TARGET,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
timeout: 30_000,
|
||||
return {
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: API_TARGET,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
timeout: 30_000,
|
||||
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||
},
|
||||
'/uploads': {
|
||||
target: API_TARGET,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
timeout: 30_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user