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
+2
View File
@@ -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
View File
@@ -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',
});
}
+39
View File
@@ -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>
+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>
+2
View File
@@ -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
View File
@@ -38,7 +38,9 @@ export type AppIconName =
| 'car'
| 'cog'
| 'spray'
| 'zap';
| 'zap'
| 'gift'
| 'info';
export type NavItem = {
to: string;
+7 -2
View File
@@ -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] });
+10
View File
@@ -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 -26
View File
@@ -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',
+84 -1
View File
@@ -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
View File
@@ -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,
},
},
},
};
});