edit image business
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user