This commit is contained in:
2026-07-16 13:33:29 +03:30
parent f456760d5d
commit c8ef69a453
11 changed files with 505 additions and 31 deletions
+20
View File
@@ -61,3 +61,23 @@ export function deleteBusinessImage(businessId: string, imageId: string) {
method: 'DELETE', method: 'DELETE',
}); });
} }
export function uploadBusinessLogoLight(businessId: string, file: File) {
const form = new FormData();
form.append('file', file);
return apiFormData<Business>(`/businesses/${businessId}/logo-light`, form);
}
export function uploadBusinessLogoDark(businessId: string, file: File) {
const form = new FormData();
form.append('file', file);
return apiFormData<Business>(`/businesses/${businessId}/logo-dark`, form);
}
export function removeBusinessLogoLight(businessId: string) {
return api<Business>(`/businesses/${businessId}/logo-light`, { method: 'DELETE' });
}
export function removeBusinessLogoDark(businessId: string) {
return api<Business>(`/businesses/${businessId}/logo-dark`, { method: 'DELETE' });
}
+43 -21
View File
@@ -1,21 +1,43 @@
import { api, apiFormData } from './client'; import { api, apiFormData } from './client';␍
␍
export type PlatformSettings = { export type PlatformSettings = {␍
loginHeroImageUrl: string | null; loginHeroImageUrl: string | null;␍
}; logoLightUrl: string | null;␍
logoDarkUrl: string | null;␍
export function fetchPlatformSettings() { };␍
return api<PlatformSettings>('/platform-settings'); ␍
} export function fetchPlatformSettings() {␍
return api<PlatformSettings>('/platform-settings');␍
export function uploadLoginHeroImage(file: File) { }␍
const form = new FormData(); ␍
form.append('file', file); export function uploadLoginHeroImage(file: File) {␍
return apiFormData<PlatformSettings>('/platform-settings/login-hero-image', form); const form = new FormData();␍
} form.append('file', file);␍
return apiFormData<PlatformSettings>('/platform-settings/login-hero-image', form);␍
export function removeLoginHeroImage() { }␍
return api<PlatformSettings>('/platform-settings/login-hero-image', { ␍
method: 'DELETE', export function removeLoginHeroImage() {␍
}); return api<PlatformSettings>('/platform-settings/login-hero-image', {␍
} method: 'DELETE',␍
});␍
}␍
␍
export function uploadPlatformLogoLight(file: File) {␍
const form = new FormData();␍
form.append('file', file);␍
return apiFormData<PlatformSettings>('/platform-settings/logo-light', form);␍
}␍
␍
export function uploadPlatformLogoDark(file: File) {␍
const form = new FormData();␍
form.append('file', file);␍
return apiFormData<PlatformSettings>('/platform-settings/logo-dark', form);␍
}␍
␍
export function removePlatformLogoLight() {␍
return api<PlatformSettings>('/platform-settings/logo-light', { method: 'DELETE' });␍
}␍
␍
export function removePlatformLogoDark() {␍
return api<PlatformSettings>('/platform-settings/logo-dark', { method: 'DELETE' });␍
}␍
+2
View File
@@ -73,6 +73,8 @@ export type Business = {
ownerId?: string | null; ownerId?: string | null;
category?: BusinessCategory; category?: BusinessCategory;
images?: BusinessImage[]; images?: BusinessImage[];
logoLightUrl?: string | null;
logoDarkUrl?: string | null;
concurrentCapacity?: number; concurrentCapacity?: number;
slotStepMinutes?: number; slotStepMinutes?: number;
serviceClassesEnabled?: boolean; serviceClassesEnabled?: boolean;
+15 -9
View File
@@ -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>
+32
View File
@@ -0,0 +1,32 @@
import { computed, onMounted } from 'vue';
import { resolveLogoPair } 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'),
};
}
+62
View File
@@ -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>
+68
View File
@@ -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>
+53
View File
@@ -0,0 +1,53 @@
import { defineStore } from 'pinia';
import { computed, ref } from 'vue';
import { fetchPlatformSettings } from '@/api/platform-settings';
import {
DEFAULT_LOGO_DARK,
DEFAULT_LOGO_LIGHT,
resolveLogoPair,
} from '@/utils/media';
export const usePlatformSettingsStore = defineStore('platformSettings', () => {
const loginHeroImageUrl = ref<string | null>(null);
const logoLightUrl = ref<string | null>(null);
const logoDarkUrl = ref<string | null>(null);
const loaded = ref(false);
const loading = ref(false);
const logoSources = computed(() =>
resolveLogoPair(logoLightUrl.value, logoDarkUrl.value),
);
async function load(force = false) {
if (loaded.value && !force) return;
if (loading.value) return;
loading.value = true;
try {
const data = await fetchPlatformSettings();
loginHeroImageUrl.value = data.loginHeroImageUrl;
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: DEFAULT_LOGO_LIGHT,
defaultLogoDark: DEFAULT_LOGO_DARK,
logoSources,
loaded,
loading,
load,
};
});
+29
View File
@@ -19,3 +19,32 @@ export function businessCoverUrl(
const cover = images.find((i) => i.isCover) ?? images[0]; const cover = images.find((i) => i.isCover) ?? images[0];
return uploadUrl(cover?.url) || null; return uploadUrl(cover?.url) || null;
} }
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 };
}
+85
View File
@@ -131,6 +131,20 @@
@remove-existing="onRemoveImage" @remove-existing="onRemoveImage"
/> />
<LogoUploadPair
v-if="editingId && detailBusiness"
:light-url="detailBusiness.logoLightUrl"
:dark-url="detailBusiness.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"
/>
<BusinessServicesSection <BusinessServicesSection
v-if="editingId && form.categoryId" v-if="editingId && form.categoryId"
:business-id="editingId" :business-id="editingId"
@@ -221,13 +235,18 @@ import {
deleteBusinessImage, deleteBusinessImage,
fetchBusiness, fetchBusiness,
fetchBusinesses, fetchBusinesses,
removeBusinessLogoDark,
removeBusinessLogoLight,
updateBusiness, updateBusiness,
uploadBusinessImages, uploadBusinessImages,
uploadBusinessLogoDark,
uploadBusinessLogoLight,
} from '@/api/businesses'; } from '@/api/businesses';
import { fetchUser, fetchUsers } from '@/api/users'; import { fetchUser, fetchUsers } from '@/api/users';
import type { Business, BusinessCategory, User } from '@/api/types'; import type { Business, BusinessCategory, User } from '@/api/types';
import { ApiError } from '@/api/client'; import { ApiError } from '@/api/client';
import BusinessImageUpload from '@/components/admin/BusinessImageUpload.vue'; import BusinessImageUpload from '@/components/admin/BusinessImageUpload.vue';
import LogoUploadPair from '@/components/shared/LogoUploadPair.vue';
import BusinessListCard from '@/components/admin/BusinessListCard.vue'; import BusinessListCard from '@/components/admin/BusinessListCard.vue';
import BusinessServicesSection from '@/components/admin/BusinessServicesSection.vue'; import BusinessServicesSection from '@/components/admin/BusinessServicesSection.vue';
import AppBadge from '@/components/ui/AppBadge.vue'; import AppBadge from '@/components/ui/AppBadge.vue';
@@ -265,6 +284,10 @@ const isEditMode = ref(false);
const detailBusiness = ref<Business | null>(null); const detailBusiness = ref<Business | null>(null);
const imageUploadRef = ref<InstanceType<typeof BusinessImageUpload> | null>(null); const imageUploadRef = ref<InstanceType<typeof BusinessImageUpload> | null>(null);
const deletingImageId = ref<string | null>(null); const deletingImageId = ref<string | null>(null);
const uploadingLogoLight = ref(false);
const uploadingLogoDark = ref(false);
const removingLogoLight = ref(false);
const removingLogoDark = ref(false);
const assignOpen = ref(false); const assignOpen = ref(false);
const assignTarget = ref<Business | null>(null); const assignTarget = ref<Business | null>(null);
@@ -460,6 +483,68 @@ async function onRemoveImage(imageId: string) {
} }
} }
async function refreshDetailBusiness() {
if (!editingId.value) return;
detailBusiness.value = await fetchBusiness(editingId.value);
await load();
}
async function onUploadLogoLight(file: File) {
if (!editingId.value) return;
uploadingLogoLight.value = true;
error.value = '';
try {
await uploadBusinessLogoLight(editingId.value, file);
await refreshDetailBusiness();
} catch (e) {
error.value = e instanceof ApiError ? e.message : 'آپلود لوگو ناموفق بود';
} finally {
uploadingLogoLight.value = false;
}
}
async function onUploadLogoDark(file: File) {
if (!editingId.value) return;
uploadingLogoDark.value = true;
error.value = '';
try {
await uploadBusinessLogoDark(editingId.value, file);
await refreshDetailBusiness();
} catch (e) {
error.value = e instanceof ApiError ? e.message : 'آپلود لوگو ناموفق بود';
} finally {
uploadingLogoDark.value = false;
}
}
async function onRemoveLogoLight() {
if (!editingId.value) return;
removingLogoLight.value = true;
error.value = '';
try {
await removeBusinessLogoLight(editingId.value);
await refreshDetailBusiness();
} catch (e) {
error.value = e instanceof ApiError ? e.message : 'حذف لوگو ناموفق بود';
} finally {
removingLogoLight.value = false;
}
}
async function onRemoveLogoDark() {
if (!editingId.value) return;
removingLogoDark.value = true;
error.value = '';
try {
await removeBusinessLogoDark(editingId.value);
await refreshDetailBusiness();
} catch (e) {
error.value = e instanceof ApiError ? e.message : 'حذف لوگو ناموفق بود';
} finally {
removingLogoDark.value = false;
}
}
function onTitleBlur() { function onTitleBlur() {
if (!isEditMode.value && !editingId.value) { if (!isEditMode.value && !editingId.value) {
form.value.slug = slugify(form.value.title); form.value.slug = slugify(form.value.title);
+96 -1
View File
@@ -3,7 +3,7 @@
<section class="space-y-2"> <section class="space-y-2">
<h1 class="text-xl font-bold text-text">تنظیمات پلتفرم</h1> <h1 class="text-xl font-bold text-text">تنظیمات پلتفرم</h1>
<p class="text-sm text-muted"> <p class="text-sm text-muted">
تصویر پس‌زمینه صفحه ورود دسکتاپ را از اینجا مدیریت کنید. لوگوی اصلی پلتفرم و تصویر صفحه ورود دسکتاپ را از اینجا مدیریت کنید.
</p> </p>
</section> </section>
@@ -21,6 +21,29 @@
{{ success }} {{ success }}
</p> </p>
<section class="mt-6 rounded-2xl border border-border bg-surface-2 p-4 lg:p-6">
<h2 class="text-base font-semibold text-text">لوگوی اصلی منتایم</h2>
<p class="mt-1 text-xs text-muted">
در صفحه اصلی و جاهایی که لوگوی کسب‌وکار تنظیم نشده نمایش داده می‌شود.
</p>
<div v-if="loading" class="mt-4 text-sm text-muted">در حال بارگذاری...</div>
<LogoUploadPair
v-else
class="mt-4"
:light-url="settings?.logoLightUrl"
:dark-url="settings?.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="mt-6 rounded-2xl border border-border bg-surface-2 p-4 lg:p-6"> <section class="mt-6 rounded-2xl border border-border bg-surface-2 p-4 lg:p-6">
<h2 class="text-base font-semibold text-text">تصویر صفحه ورود (دسکتاپ)</h2> <h2 class="text-base font-semibold text-text">تصویر صفحه ورود (دسکتاپ)</h2>
<p class="mt-1 text-xs text-muted"> <p class="mt-1 text-xs text-muted">
@@ -85,21 +108,33 @@ import { computed, onMounted, ref } from 'vue';
import { import {
fetchPlatformSettings, fetchPlatformSettings,
removeLoginHeroImage, removeLoginHeroImage,
removePlatformLogoDark,
removePlatformLogoLight,
uploadLoginHeroImage, uploadLoginHeroImage,
uploadPlatformLogoDark,
uploadPlatformLogoLight,
type PlatformSettings, type PlatformSettings,
} from '@/api/platform-settings'; } from '@/api/platform-settings';
import { ApiError } from '@/api/client'; import { ApiError } from '@/api/client';
import AppButton from '@/components/ui/AppButton.vue'; import AppButton from '@/components/ui/AppButton.vue';
import AppIcon from '@/components/ui/AppIcon.vue'; import AppIcon from '@/components/ui/AppIcon.vue';
import LogoUploadPair from '@/components/shared/LogoUploadPair.vue';
import AdminLayout from '@/layouts/AdminLayout.vue'; import AdminLayout from '@/layouts/AdminLayout.vue';
import { usePlatformSettingsStore } from '@/stores/platform-settings';
import { uploadUrl } from '@/utils/media'; import { uploadUrl } from '@/utils/media';
const platformStore = usePlatformSettingsStore();
const DEFAULT_PREVIEW = '/landing/hero-car-wash.jpg'; const DEFAULT_PREVIEW = '/landing/hero-car-wash.jpg';
const settings = ref<PlatformSettings | null>(null); const settings = ref<PlatformSettings | null>(null);
const loading = ref(true); const loading = ref(true);
const uploading = ref(false); const uploading = ref(false);
const removing = ref(false); const removing = ref(false);
const uploadingLogoLight = ref(false);
const uploadingLogoDark = ref(false);
const removingLogoLight = ref(false);
const removingLogoDark = ref(false);
const error = ref(''); const error = ref('');
const success = ref(''); const success = ref('');
@@ -157,6 +192,66 @@ async function onRemove() {
} }
} }
async function onUploadLogoLight(file: File) {
uploadingLogoLight.value = true;
error.value = '';
success.value = '';
try {
settings.value = await uploadPlatformLogoLight(file);
await platformStore.load(true);
success.value = 'لوگوی حالت روشن به‌روزرسانی شد.';
} catch (e) {
error.value = e instanceof ApiError ? e.message : 'آپلود لوگو ناموفق بود.';
} finally {
uploadingLogoLight.value = false;
}
}
async function onUploadLogoDark(file: File) {
uploadingLogoDark.value = true;
error.value = '';
success.value = '';
try {
settings.value = await uploadPlatformLogoDark(file);
await platformStore.load(true);
success.value = 'لوگوی حالت تاریک به‌روزرسانی شد.';
} catch (e) {
error.value = e instanceof ApiError ? e.message : 'آپلود لوگو ناموفق بود.';
} finally {
uploadingLogoDark.value = false;
}
}
async function onRemoveLogoLight() {
removingLogoLight.value = true;
error.value = '';
success.value = '';
try {
settings.value = await removePlatformLogoLight();
await platformStore.load(true);
success.value = 'لوگوی حالت روشن حذف شد.';
} catch (e) {
error.value = e instanceof ApiError ? e.message : 'حذف لوگو ناموفق بود.';
} finally {
removingLogoLight.value = false;
}
}
async function onRemoveLogoDark() {
removingLogoDark.value = true;
error.value = '';
success.value = '';
try {
settings.value = await removePlatformLogoDark();
await platformStore.load(true);
success.value = 'لوگوی حالت تاریک حذف شد.';
} catch (e) {
error.value = e instanceof ApiError ? e.message : 'حذف لوگو ناموفق بود.';
} finally {
removingLogoDark.value = false;
}
}
onMounted(() => { onMounted(() => {
void load(); void load();
}); });