edit image business

This commit is contained in:
2026-06-13 12:38:47 +03:30
parent d1a471199b
commit 945e0ec3b9
16 changed files with 501 additions and 65 deletions
@@ -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>
+13 -8
View File
@@ -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();
});
+7 -1
View File
@@ -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>
+4
View File
@@ -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(
+2
View File
@@ -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>