logo set
This commit is contained in:
@@ -51,3 +51,23 @@ export function setBusinessCoverImage(imageId: string) {
|
|||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function uploadBusinessLogoLight(file: File) {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
return apiFormData<Business>('/businesses/mine/logo-light', form);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uploadBusinessLogoDark(file: File) {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
return apiFormData<Business>('/businesses/mine/logo-dark', form);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeBusinessLogoLight() {
|
||||||
|
return api<Business>('/businesses/mine/logo-light', { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeBusinessLogoDark() {
|
||||||
|
return api<Business>('/businesses/mine/logo-dark', { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { api } from './client';
|
|||||||
|
|
||||||
export type PlatformSettings = {
|
export type PlatformSettings = {
|
||||||
loginHeroImageUrl: string | null;
|
loginHeroImageUrl: string | null;
|
||||||
|
logoLightUrl: string | null;
|
||||||
|
logoDarkUrl: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function fetchPlatformSettings() {
|
export function fetchPlatformSettings() {
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ export type Business = {
|
|||||||
promoDiscountPercent?: number | null;
|
promoDiscountPercent?: number | null;
|
||||||
promoDiscountCode?: string | null;
|
promoDiscountCode?: string | null;
|
||||||
promoDiscountMode?: 'first_booking' | 'always';
|
promoDiscountMode?: 'first_booking' | 'always';
|
||||||
|
logoLightUrl?: string | null;
|
||||||
|
logoDarkUrl?: string | null;
|
||||||
averageRating?: number | null;
|
averageRating?: number | null;
|
||||||
reviewCount?: number;
|
reviewCount?: number;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
<template>
|
<template>
|
||||||
<img
|
<img
|
||||||
v-if="variant === 'mark'"
|
v-if="variant === 'mark'"
|
||||||
src="/icons/icon-192.png"
|
:src="markSrc"
|
||||||
alt="منتایم"
|
:alt="alt"
|
||||||
:class="imgClass"
|
:class="imgClass"
|
||||||
/>
|
/>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<img
|
<img
|
||||||
src="/brand/logo-lite.png"
|
:src="lightSrc"
|
||||||
alt="منتایم"
|
:alt="alt"
|
||||||
:class="['dark:hidden', imgClass]"
|
:class="['dark:hidden', imgClass]"
|
||||||
/>
|
/>
|
||||||
<img
|
<img
|
||||||
src="/brand/logo-dark.png"
|
:src="darkSrc"
|
||||||
alt="منتایم"
|
:alt="alt"
|
||||||
:class="['hidden dark:block', imgClass]"
|
:class="['hidden dark:block', imgClass]"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
@@ -21,16 +21,22 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
import { useAppLogoProps, type LogoVariant } from '@/components/brand/useAppLogo';
|
||||||
export type LogoVariant = 'auto' | 'mark' | 'horizontal' | 'vertical';
|
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
variant?: LogoVariant;
|
variant?: LogoVariant;
|
||||||
class?: string;
|
class?: string;
|
||||||
|
alt?: string;
|
||||||
|
lightSrc?: string;
|
||||||
|
darkSrc?: string;
|
||||||
}>(),
|
}>(),
|
||||||
{ variant: 'auto' },
|
{
|
||||||
|
variant: 'auto',
|
||||||
|
alt: 'منتایم',
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const imgClass = computed(() => props.class ?? 'h-8 w-auto object-contain');
|
const imgClass = computed(() => props.class ?? 'h-8 w-auto object-contain');
|
||||||
|
const { lightSrc, darkSrc, markSrc } = useAppLogoProps(props);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -3,7 +3,13 @@
|
|||||||
<div
|
<div
|
||||||
class="flex h-11 w-11 shrink-0 items-center justify-center overflow-hidden rounded-full bg-white p-1.5 shadow-sm ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-600"
|
class="flex h-11 w-11 shrink-0 items-center justify-center overflow-hidden rounded-full bg-white p-1.5 shadow-sm ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-600"
|
||||||
>
|
>
|
||||||
<AppLogo variant="horizontal" class="h-full w-full object-contain" />
|
<AppLogo
|
||||||
|
variant="horizontal"
|
||||||
|
class="h-full w-full object-contain"
|
||||||
|
:light-src="activeLogos.light"
|
||||||
|
:dark-src="activeLogos.dark"
|
||||||
|
:alt="brand.name"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-lg font-bold text-slate-800 dark:text-slate-100">
|
<span class="text-lg font-bold text-slate-800 dark:text-slate-100">
|
||||||
{{ brand.name }}
|
{{ brand.name }}
|
||||||
@@ -14,8 +20,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import AppLogo from '@/components/brand/AppLogo.vue';
|
import AppLogo from '@/components/brand/AppLogo.vue';
|
||||||
|
import { useLogoSources } from '@/composables/useLogoSources';
|
||||||
import { useTenantStore } from '@/stores/tenant';
|
import { useTenantStore } from '@/stores/tenant';
|
||||||
|
|
||||||
const tenant = useTenantStore();
|
const tenant = useTenantStore();
|
||||||
const { brand } = storeToRefs(tenant);
|
const { brand } = storeToRefs(tenant);
|
||||||
|
const { activeLogos } = useLogoSources();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { computed, onMounted } from 'vue';
|
||||||
|
import { resolveLogoPair, DEFAULT_LOGO_LIGHT, DEFAULT_LOGO_DARK } from '@/utils/media';
|
||||||
|
import { usePlatformSettingsStore } from '@/stores/platform-settings';
|
||||||
|
|
||||||
|
export type LogoVariant = 'auto' | 'mark' | 'horizontal' | 'vertical';
|
||||||
|
|
||||||
|
export function useAppLogoProps(
|
||||||
|
props: {
|
||||||
|
variant?: LogoVariant;
|
||||||
|
lightSrc?: string;
|
||||||
|
darkSrc?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const platform = usePlatformSettingsStore();
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void platform.load();
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolved = computed(() => {
|
||||||
|
if (props.lightSrc || props.darkSrc) {
|
||||||
|
return resolveLogoPair(props.lightSrc, props.darkSrc);
|
||||||
|
}
|
||||||
|
return platform.logoSources;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
lightSrc: computed(() => resolved.value.light),
|
||||||
|
darkSrc: computed(() => resolved.value.dark),
|
||||||
|
markSrc: computed(() => '/icons/icon-192.png'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { DEFAULT_LOGO_LIGHT, DEFAULT_LOGO_DARK };
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<template>
|
||||||
|
<div class="rounded-xl border border-border bg-surface-2 p-3">
|
||||||
|
<p class="mb-2 text-xs font-medium text-muted">{{ label }}</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex aspect-[3/1] items-center justify-center overflow-hidden rounded-lg border border-border px-4 py-3"
|
||||||
|
:class="darkPreview ? 'bg-slate-900' : 'bg-white'"
|
||||||
|
>
|
||||||
|
<img :src="previewSrc" alt="" class="max-h-12 w-auto max-w-full object-contain" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 flex flex-wrap gap-2">
|
||||||
|
<label
|
||||||
|
class="inline-flex cursor-pointer items-center gap-2 rounded-lg bg-brand-600 px-3 py-2 text-xs font-semibold text-white transition hover:bg-brand-700"
|
||||||
|
>
|
||||||
|
<span>{{ uploading ? 'در حال آپلود...' : 'آپلود' }}</span>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||||
|
class="hidden"
|
||||||
|
:disabled="uploading || removing"
|
||||||
|
@change="onPick"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="hasCustom"
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg border border-border px-3 py-2 text-xs text-muted transition hover:border-red-500/40 hover:text-red-600 disabled:opacity-50"
|
||||||
|
:disabled="uploading || removing"
|
||||||
|
@click="emit('remove')"
|
||||||
|
>
|
||||||
|
{{ removing ? 'در حال حذف...' : 'حذف' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
label: string;
|
||||||
|
previewSrc: string;
|
||||||
|
uploading?: boolean;
|
||||||
|
removing?: boolean;
|
||||||
|
hasCustom?: boolean;
|
||||||
|
darkPreview?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
pick: [file: File];
|
||||||
|
remove: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
function onPick(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
input.value = '';
|
||||||
|
if (file) {
|
||||||
|
emit('pick', file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-text">لوگوی کسبوکار</label>
|
||||||
|
<p class="mt-1 text-xs text-muted">
|
||||||
|
اختیاری — JPG، PNG یا WebP تا ۵ مگابایت. اگر فقط یکی را بارگذاری کنید، در هر دو حالت روشن و تاریک نمایش داده میشود.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
|
<LogoUploadField
|
||||||
|
label="حالت روشن"
|
||||||
|
:preview-src="lightPreview"
|
||||||
|
:uploading="uploadingLight"
|
||||||
|
:removing="removingLight"
|
||||||
|
:has-custom="!!lightUrl"
|
||||||
|
@pick="(file) => emit('upload-light', file)"
|
||||||
|
@remove="emit('remove-light')"
|
||||||
|
/>
|
||||||
|
<LogoUploadField
|
||||||
|
label="حالت تاریک"
|
||||||
|
:preview-src="darkPreview"
|
||||||
|
:uploading="uploadingDark"
|
||||||
|
:removing="removingDark"
|
||||||
|
:has-custom="!!darkUrl"
|
||||||
|
dark-preview
|
||||||
|
@pick="(file) => emit('upload-dark', file)"
|
||||||
|
@remove="emit('remove-dark')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import LogoUploadField from '@/components/shared/LogoUploadField.vue';
|
||||||
|
import { resolveLogoPair } from '@/utils/media';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
lightUrl?: string | null;
|
||||||
|
darkUrl?: string | null;
|
||||||
|
fallbackLight?: string;
|
||||||
|
fallbackDark?: string;
|
||||||
|
uploadingLight?: boolean;
|
||||||
|
uploadingDark?: boolean;
|
||||||
|
removingLight?: boolean;
|
||||||
|
removingDark?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'upload-light': [file: File];
|
||||||
|
'upload-dark': [file: File];
|
||||||
|
'remove-light': [];
|
||||||
|
'remove-dark': [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const resolved = computed(() =>
|
||||||
|
resolveLogoPair(
|
||||||
|
props.lightUrl,
|
||||||
|
props.darkUrl,
|
||||||
|
props.fallbackLight,
|
||||||
|
props.fallbackDark,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const lightPreview = computed(() => resolved.value.light);
|
||||||
|
const darkPreview = computed(() => resolved.value.dark);
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { computed, onMounted } from 'vue';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
import type { Business } from '@/api/types';
|
||||||
|
import { usePlatformSettingsStore } from '@/stores/platform-settings';
|
||||||
|
import { useTenantStore } from '@/stores/tenant';
|
||||||
|
import { resolveLogoPair } from '@/utils/media';
|
||||||
|
|
||||||
|
export function useLogoSources() {
|
||||||
|
const platform = usePlatformSettingsStore();
|
||||||
|
const tenant = useTenantStore();
|
||||||
|
const { landingBusiness } = storeToRefs(tenant);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void platform.load();
|
||||||
|
});
|
||||||
|
|
||||||
|
const platformLogos = computed(() =>
|
||||||
|
resolveLogoPair(
|
||||||
|
platform.logoLightUrl,
|
||||||
|
platform.logoDarkUrl,
|
||||||
|
platform.defaultLogoLight,
|
||||||
|
platform.defaultLogoDark,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const businessLogos = computed(() => {
|
||||||
|
const biz = landingBusiness.value;
|
||||||
|
if (!biz?.logoLightUrl && !biz?.logoDarkUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return resolveLogoPair(
|
||||||
|
biz.logoLightUrl,
|
||||||
|
biz.logoDarkUrl,
|
||||||
|
platformLogos.value.light,
|
||||||
|
platformLogos.value.dark,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeLogos = computed(() => businessLogos.value ?? platformLogos.value);
|
||||||
|
|
||||||
|
return {
|
||||||
|
landingBusiness,
|
||||||
|
platformLogos,
|
||||||
|
businessLogos,
|
||||||
|
activeLogos,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logosForBusiness(
|
||||||
|
business: Pick<Business, 'logoLightUrl' | 'logoDarkUrl'> | null | undefined,
|
||||||
|
platformLight?: string | null,
|
||||||
|
platformDark?: string | null,
|
||||||
|
) {
|
||||||
|
if (!business?.logoLightUrl && !business?.logoDarkUrl) {
|
||||||
|
return resolveLogoPair(platformLight, platformDark);
|
||||||
|
}
|
||||||
|
return resolveLogoPair(
|
||||||
|
business.logoLightUrl,
|
||||||
|
business.logoDarkUrl,
|
||||||
|
resolveLogoPair(platformLight, platformDark).light,
|
||||||
|
resolveLogoPair(platformLight, platformDark).dark,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,7 +4,13 @@
|
|||||||
class="z-20 flex shrink-0 items-center justify-between border-b border-border bg-surface/95 px-4 py-3 backdrop-blur supports-[backdrop-filter]:bg-surface/80 pt-[max(0.75rem,env(safe-area-inset-top))]"
|
class="z-20 flex shrink-0 items-center justify-between border-b border-border bg-surface/95 px-4 py-3 backdrop-blur supports-[backdrop-filter]:bg-surface/80 pt-[max(0.75rem,env(safe-area-inset-top))]"
|
||||||
>
|
>
|
||||||
<div class="flex min-w-0 items-center gap-3">
|
<div class="flex min-w-0 items-center gap-3">
|
||||||
<AppLogo variant="horizontal" class="h-9 shrink-0" />
|
<AppLogo
|
||||||
|
variant="horizontal"
|
||||||
|
class="h-9 shrink-0"
|
||||||
|
:light-src="activeLogos.light"
|
||||||
|
:dark-src="activeLogos.dark"
|
||||||
|
:alt="brand.name"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
@@ -50,16 +56,20 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted } from 'vue';
|
import { onMounted } from 'vue';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
import AppShell from '@/components/shell/AppShell.vue';
|
import AppShell from '@/components/shell/AppShell.vue';
|
||||||
import AppIcon from '@/components/ui/AppIcon.vue';
|
import AppIcon from '@/components/ui/AppIcon.vue';
|
||||||
import AppLogo from '@/components/brand/AppLogo.vue';
|
import AppLogo from '@/components/brand/AppLogo.vue';
|
||||||
import ThemeToggle from '@/components/ui/ThemeToggle.vue';
|
import ThemeToggle from '@/components/ui/ThemeToggle.vue';
|
||||||
import AuthHeaderAction from '@/components/shell/AuthHeaderAction.vue';
|
import AuthHeaderAction from '@/components/shell/AuthHeaderAction.vue';
|
||||||
import { useAppNav } from '@/composables/useAppNav';
|
import { useAppNav } from '@/composables/useAppNav';
|
||||||
|
import { useLogoSources } from '@/composables/useLogoSources';
|
||||||
import { useTenantStore } from '@/stores/tenant';
|
import { useTenantStore } from '@/stores/tenant';
|
||||||
|
|
||||||
const { mobileNavItems, mobileNavGridClass, navLinkClass, goToNavItem } = useAppNav();
|
const { mobileNavItems, mobileNavGridClass, navLinkClass, goToNavItem } = useAppNav();
|
||||||
const tenant = useTenantStore();
|
const tenant = useTenantStore();
|
||||||
|
const { brand } = storeToRefs(tenant);
|
||||||
|
const { activeLogos } = useLogoSources();
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void tenant.bootstrap();
|
void tenant.bootstrap();
|
||||||
|
|||||||
@@ -1,41 +1,64 @@
|
|||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';␍
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';␍
|
||||||
import { fetchPlatformSettings } from '@/api/platform-settings';
|
import { fetchPlatformSettings } from '@/api/platform-settings';␍
|
||||||
import { uploadUrl } from '@/utils/media';
|
import {␍
|
||||||
|
DEFAULT_LOGO_DARK,␍
|
||||||
const DEFAULT_LOGIN_HERO = '/landing/hero-car-wash.jpg';
|
DEFAULT_LOGO_LIGHT,␍
|
||||||
|
resolveLogoPair,␍
|
||||||
export const usePlatformSettingsStore = defineStore('platformSettings', () => {
|
uploadUrl,␍
|
||||||
const loginHeroImageUrl = ref<string | null>(null);
|
} from '@/utils/media';␍
|
||||||
const loaded = ref(false);
|
␍
|
||||||
const loading = ref(false);
|
const DEFAULT_LOGIN_HERO = '/landing/hero-car-wash.jpg';␍
|
||||||
|
␍
|
||||||
const loginHeroSrc = computed(
|
export const usePlatformSettingsStore = defineStore('platformSettings', () => {␍
|
||||||
() => uploadUrl(loginHeroImageUrl.value) || DEFAULT_LOGIN_HERO,
|
const loginHeroImageUrl = ref<string | null>(null);␍
|
||||||
);
|
const logoLightUrl = ref<string | null>(null);␍
|
||||||
|
const logoDarkUrl = ref<string | null>(null);␍
|
||||||
async function load(force = false) {
|
const loaded = ref(false);␍
|
||||||
if (loaded.value && !force) return;
|
const loading = ref(false);␍
|
||||||
if (loading.value) return;
|
␍
|
||||||
|
const defaultLogoLight = DEFAULT_LOGO_LIGHT;␍
|
||||||
loading.value = true;
|
const defaultLogoDark = DEFAULT_LOGO_DARK;␍
|
||||||
try {
|
␍
|
||||||
const data = await fetchPlatformSettings();
|
const loginHeroSrc = computed(␍
|
||||||
loginHeroImageUrl.value = data.loginHeroImageUrl;
|
() => uploadUrl(loginHeroImageUrl.value) || DEFAULT_LOGIN_HERO,␍
|
||||||
loaded.value = true;
|
);␍
|
||||||
} catch {
|
␍
|
||||||
loginHeroImageUrl.value = null;
|
const logoSources = computed(() =>␍
|
||||||
loaded.value = true;
|
resolveLogoPair(logoLightUrl.value, logoDarkUrl.value),␍
|
||||||
} finally {
|
);␍
|
||||||
loading.value = false;
|
␍
|
||||||
}
|
async function load(force = false) {␍
|
||||||
}
|
if (loaded.value && !force) return;␍
|
||||||
|
if (loading.value) return;␍
|
||||||
return {
|
␍
|
||||||
loginHeroImageUrl,
|
loading.value = true;␍
|
||||||
loginHeroSrc,
|
try {␍
|
||||||
loaded,
|
const data = await fetchPlatformSettings();␍
|
||||||
loading,
|
loginHeroImageUrl.value = data.loginHeroImageUrl;␍
|
||||||
load,
|
logoLightUrl.value = data.logoLightUrl;␍
|
||||||
};
|
logoDarkUrl.value = data.logoDarkUrl;␍
|
||||||
});
|
loaded.value = true;␍
|
||||||
|
} catch {␍
|
||||||
|
loginHeroImageUrl.value = null;␍
|
||||||
|
logoLightUrl.value = null;␍
|
||||||
|
logoDarkUrl.value = null;␍
|
||||||
|
loaded.value = true;␍
|
||||||
|
} finally {␍
|
||||||
|
loading.value = false;␍
|
||||||
|
}␍
|
||||||
|
}␍
|
||||||
|
␍
|
||||||
|
return {␍
|
||||||
|
loginHeroImageUrl,␍
|
||||||
|
logoLightUrl,␍
|
||||||
|
logoDarkUrl,␍
|
||||||
|
defaultLogoLight,␍
|
||||||
|
defaultLogoDark,␍
|
||||||
|
loginHeroSrc,␍
|
||||||
|
logoSources,␍
|
||||||
|
loaded,␍
|
||||||
|
loading,␍
|
||||||
|
load,␍
|
||||||
|
};␍
|
||||||
|
});␍
|
||||||
|
|||||||
@@ -37,8 +37,12 @@ export const useTenantStore = defineStore('tenant', () => {
|
|||||||
const singleBusinessId = computed(() => singleBusiness.value?.id ?? null);
|
const singleBusinessId = computed(() => singleBusiness.value?.id ?? null);
|
||||||
|
|
||||||
function syncSingleBusiness(business: Business) {
|
function syncSingleBusiness(business: Business) {
|
||||||
if (!isSingleBusiness.value) return;
|
if (isSingleBusiness.value) {
|
||||||
singleBusiness.value = business;
|
singleBusiness.value = business;
|
||||||
|
}
|
||||||
|
if (focusedBusiness.value?.id === business.id) {
|
||||||
|
focusedBusiness.value = business;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setFocusedBusiness(business: Business | null) {
|
function setFocusedBusiness(business: Business | null) {
|
||||||
|
|||||||
@@ -44,3 +44,32 @@ export function businessGalleryUrls(
|
|||||||
.map((image) => uploadUrl(image.url))
|
.map((image) => uploadUrl(image.url))
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_LOGO_LIGHT = '/brand/logo-lite.png';
|
||||||
|
export const DEFAULT_LOGO_DARK = '/brand/logo-dark.png';
|
||||||
|
|
||||||
|
export type LogoSourcePair = {
|
||||||
|
light: string;
|
||||||
|
dark: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function resolveLogoPair(
|
||||||
|
lightUrl: string | null | undefined,
|
||||||
|
darkUrl: string | null | undefined,
|
||||||
|
fallbackLight = DEFAULT_LOGO_LIGHT,
|
||||||
|
fallbackDark = DEFAULT_LOGO_DARK,
|
||||||
|
): LogoSourcePair {
|
||||||
|
const light = uploadUrl(lightUrl);
|
||||||
|
const dark = uploadUrl(darkUrl);
|
||||||
|
|
||||||
|
if (light && dark) {
|
||||||
|
return { light, dark };
|
||||||
|
}
|
||||||
|
if (light && !dark) {
|
||||||
|
return { light, dark: light };
|
||||||
|
}
|
||||||
|
if (!light && dark) {
|
||||||
|
return { light: dark, dark };
|
||||||
|
}
|
||||||
|
return { light: fallbackLight, dark: fallbackDark };
|
||||||
|
}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||||
import { RouterLink, useRoute, useRouter } from 'vue-router';
|
import { RouterLink, useRoute, useRouter } from 'vue-router';
|
||||||
import { fetchBusinessServices } from '@/api/business-services';
|
import { fetchBusinessServices } from '@/api/business-services';
|
||||||
import { fetchBusiness } from '@/api/businesses';
|
import { fetchBusiness } from '@/api/businesses';
|
||||||
@@ -56,10 +56,12 @@ import DesktopBookingFlow from '@/components/booking/DesktopBookingFlow.vue';
|
|||||||
import DetailPageSkeleton from '@/components/ui/DetailPageSkeleton.vue';
|
import DetailPageSkeleton from '@/components/ui/DetailPageSkeleton.vue';
|
||||||
import ResponsiveAppLayout from '@/layouts/ResponsiveAppLayout.vue';
|
import ResponsiveAppLayout from '@/layouts/ResponsiveAppLayout.vue';
|
||||||
import { useOwnedBusiness } from '@/composables/useOwnedBusiness';
|
import { useOwnedBusiness } from '@/composables/useOwnedBusiness';
|
||||||
|
import { useTenantStore } from '@/stores/tenant';
|
||||||
import { loginRouteWithRedirect } from '@/utils/auth-redirect';
|
import { loginRouteWithRedirect } from '@/utils/auth-redirect';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const tenant = useTenantStore();
|
||||||
const { ensureLoaded, isOwnBusiness } = useOwnedBusiness();
|
const { ensureLoaded, isOwnBusiness } = useOwnedBusiness();
|
||||||
|
|
||||||
const business = ref<Business | null>(null);
|
const business = ref<Business | null>(null);
|
||||||
@@ -83,7 +85,10 @@ async function loadBusiness() {
|
|||||||
error.value = '';
|
error.value = '';
|
||||||
try {
|
try {
|
||||||
business.value = await fetchBusiness(String(route.params.id));
|
business.value = await fetchBusiness(String(route.params.id));
|
||||||
|
tenant.setFocusedBusiness(business.value);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
business.value = null;
|
||||||
|
tenant.setFocusedBusiness(null);
|
||||||
error.value = e instanceof ApiError ? e.message : 'بارگذاری کسبوکار ناموفق بود.';
|
error.value = e instanceof ApiError ? e.message : 'بارگذاری کسبوکار ناموفق بود.';
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
@@ -114,4 +119,8 @@ onMounted(async () => {
|
|||||||
await loadServices();
|
await loadServices();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
tenant.setFocusedBusiness(null);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -73,6 +73,21 @@
|
|||||||
<AppButton type="submit" :loading="saving">ذخیره تغییرات</AppButton>
|
<AppButton type="submit" :loading="saving">ذخیره تغییرات</AppButton>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<section class="border-t border-border pt-6">
|
||||||
|
<LogoUploadPair
|
||||||
|
:light-url="business.logoLightUrl"
|
||||||
|
:dark-url="business.logoDarkUrl"
|
||||||
|
:uploading-light="uploadingLogoLight"
|
||||||
|
:uploading-dark="uploadingLogoDark"
|
||||||
|
:removing-light="removingLogoLight"
|
||||||
|
:removing-dark="removingLogoDark"
|
||||||
|
@upload-light="onUploadLogoLight"
|
||||||
|
@upload-dark="onUploadLogoDark"
|
||||||
|
@remove-light="onRemoveLogoLight"
|
||||||
|
@remove-dark="onRemoveLogoDark"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="border-t border-border pt-6">
|
<section class="border-t border-border pt-6">
|
||||||
<BusinessImageUpload
|
<BusinessImageUpload
|
||||||
:existing="business.images ?? []"
|
:existing="business.images ?? []"
|
||||||
@@ -556,7 +571,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import { fetchMyBusinesses, updateBusiness, uploadBusinessImages, deleteBusinessImage, setBusinessCoverImage } from '@/api/businesses';
|
import { fetchMyBusinesses, updateBusiness, uploadBusinessImages, deleteBusinessImage, setBusinessCoverImage, uploadBusinessLogoLight, uploadBusinessLogoDark, removeBusinessLogoLight, removeBusinessLogoDark } from '@/api/businesses';
|
||||||
import {
|
import {
|
||||||
createServiceRequest,
|
createServiceRequest,
|
||||||
fetchMyServiceRequests,
|
fetchMyServiceRequests,
|
||||||
@@ -580,6 +595,7 @@ import type {
|
|||||||
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 BusinessImageUpload from '@/components/owner/BusinessImageUpload.vue';
|
import BusinessImageUpload from '@/components/owner/BusinessImageUpload.vue';
|
||||||
|
import LogoUploadPair from '@/components/shared/LogoUploadPair.vue';
|
||||||
import ServiceClassManager from '@/components/owner/ServiceClassManager.vue';
|
import ServiceClassManager from '@/components/owner/ServiceClassManager.vue';
|
||||||
import FormPageSkeleton from '@/components/ui/FormPageSkeleton.vue';
|
import FormPageSkeleton from '@/components/ui/FormPageSkeleton.vue';
|
||||||
import ListCardSkeleton from '@/components/ui/ListCardSkeleton.vue';
|
import ListCardSkeleton from '@/components/ui/ListCardSkeleton.vue';
|
||||||
@@ -614,6 +630,10 @@ const saving = ref(false);
|
|||||||
const savingBooking = ref(false);
|
const savingBooking = ref(false);
|
||||||
const savingService = ref(false);
|
const savingService = ref(false);
|
||||||
const uploadingImages = ref(false);
|
const uploadingImages = ref(false);
|
||||||
|
const uploadingLogoLight = ref(false);
|
||||||
|
const uploadingLogoDark = ref(false);
|
||||||
|
const removingLogoLight = ref(false);
|
||||||
|
const removingLogoDark = ref(false);
|
||||||
const deletingImageId = ref<string | null>(null);
|
const deletingImageId = ref<string | null>(null);
|
||||||
const settingCoverId = ref<string | null>(null);
|
const settingCoverId = ref<string | null>(null);
|
||||||
const addingService = ref(false);
|
const addingService = ref(false);
|
||||||
@@ -819,15 +839,79 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function refreshBusinessImages() {
|
async function refreshBusinessFromMine() {
|
||||||
if (!business.value) return;
|
if (!business.value) return;
|
||||||
const list = await fetchMyBusinesses();
|
const list = await fetchMyBusinesses();
|
||||||
const fresh = list.find((item) => item.id === business.value?.id) ?? list[0];
|
const fresh = list.find((item) => item.id === business.value?.id) ?? list[0];
|
||||||
if (!fresh) return;
|
if (!fresh) return;
|
||||||
business.value = { ...business.value, images: fresh.images ?? [] };
|
business.value = { ...business.value, ...fresh };
|
||||||
tenant.syncSingleBusiness(business.value);
|
tenant.syncSingleBusiness(business.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshBusinessImages() {
|
||||||
|
await refreshBusinessFromMine();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onUploadLogoLight(file: File) {
|
||||||
|
if (!business.value) return;
|
||||||
|
uploadingLogoLight.value = true;
|
||||||
|
error.value = '';
|
||||||
|
try {
|
||||||
|
business.value = await uploadBusinessLogoLight(file);
|
||||||
|
tenant.syncSingleBusiness(business.value);
|
||||||
|
message.value = 'لوگوی حالت روشن بهروزرسانی شد.';
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof ApiError ? e.message : 'آپلود لوگو ناموفق بود';
|
||||||
|
} finally {
|
||||||
|
uploadingLogoLight.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onUploadLogoDark(file: File) {
|
||||||
|
if (!business.value) return;
|
||||||
|
uploadingLogoDark.value = true;
|
||||||
|
error.value = '';
|
||||||
|
try {
|
||||||
|
business.value = await uploadBusinessLogoDark(file);
|
||||||
|
tenant.syncSingleBusiness(business.value);
|
||||||
|
message.value = 'لوگوی حالت تاریک بهروزرسانی شد.';
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof ApiError ? e.message : 'آپلود لوگو ناموفق بود';
|
||||||
|
} finally {
|
||||||
|
uploadingLogoDark.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onRemoveLogoLight() {
|
||||||
|
if (!business.value?.logoLightUrl) return;
|
||||||
|
removingLogoLight.value = true;
|
||||||
|
error.value = '';
|
||||||
|
try {
|
||||||
|
business.value = await removeBusinessLogoLight();
|
||||||
|
tenant.syncSingleBusiness(business.value);
|
||||||
|
message.value = 'لوگوی حالت روشن حذف شد.';
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof ApiError ? e.message : 'حذف لوگو ناموفق بود';
|
||||||
|
} finally {
|
||||||
|
removingLogoLight.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onRemoveLogoDark() {
|
||||||
|
if (!business.value?.logoDarkUrl) return;
|
||||||
|
removingLogoDark.value = true;
|
||||||
|
error.value = '';
|
||||||
|
try {
|
||||||
|
business.value = await removeBusinessLogoDark();
|
||||||
|
tenant.syncSingleBusiness(business.value);
|
||||||
|
message.value = 'لوگوی حالت تاریک حذف شد.';
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof ApiError ? e.message : 'حذف لوگو ناموفق بود';
|
||||||
|
} finally {
|
||||||
|
removingLogoDark.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function onUploadImages(files: File[]) {
|
async function onUploadImages(files: File[]) {
|
||||||
if (!business.value || !files.length) return;
|
if (!business.value || !files.length) return;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user