diff --git a/.env.production b/.env.production index d693075..8f8dfd9 100644 --- a/.env.production +++ b/.env.production @@ -1 +1,2 @@ VITE_API_URL=https://api.montime.ir +VITE_ASSETS_URL=https://api.montime.ir diff --git a/src/api/businesses.ts b/src/api/businesses.ts index 8fd953a..7b434b7 100644 --- a/src/api/businesses.ts +++ b/src/api/businesses.ts @@ -1,7 +1,9 @@ import { api } from './client'; +import { apiFormData } from './client'; import type { AssignOwnerPayload, Business, + BusinessImage, CreateBusinessPayload, UpdateBusinessPayload, } from './types'; @@ -45,3 +47,17 @@ export function assignBusinessOwner(id: string, payload: AssignOwnerPayload) { body: JSON.stringify(payload), }); } + +export function uploadBusinessImages(businessId: string, files: File[]) { + const form = new FormData(); + for (const file of files) { + form.append('files', file); + } + return apiFormData(`/businesses/${businessId}/images`, form); +} + +export function deleteBusinessImage(businessId: string, imageId: string) { + return api<{ success: true }>(`/businesses/${businessId}/images/${imageId}`, { + method: 'DELETE', + }); +} diff --git a/src/api/client.ts b/src/api/client.ts index 0bcb554..00d538f 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -55,4 +55,28 @@ export async function api(path: string, options: ApiOptions = {}): Promise return res.json() as Promise; } +export async function apiFormData(path: string, form: FormData): Promise { + const token = getApiToken(); + const headers: Record = {}; + if (token) headers.Authorization = `Bearer ${token}`; + + const res = await fetch(`${baseUrl}${path}`, { + method: 'POST', + headers, + body: form, + }); + + if (!res.ok) { + const body = (await res.json().catch(() => null)) as + | { message?: string | string[] } + | null; + const msg = Array.isArray(body?.message) + ? body.message.join(', ') + : body?.message ?? res.statusText; + throw new ApiError(res.status, msg); + } + + return res.json() as Promise; +} + export { TOKEN_KEY }; diff --git a/src/api/types.ts b/src/api/types.ts index 92f9f15..a8a8023 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -19,6 +19,15 @@ export type BusinessCategory = { updatedAt?: string; }; +export type BusinessImage = { + id: string; + businessId: string; + url: string; + originalName: string | null; + sortOrder: number; + isCover: boolean; +}; + export type Business = { id: string; title: string; @@ -32,6 +41,7 @@ export type Business = { categoryId: string; ownerId: string | null; category?: BusinessCategory; + images?: BusinessImage[]; owner?: User | null; createdAt?: string; updatedAt?: string; diff --git a/src/components/admin/BusinessImageUpload.vue b/src/components/admin/BusinessImageUpload.vue new file mode 100644 index 0000000..68de020 --- /dev/null +++ b/src/components/admin/BusinessImageUpload.vue @@ -0,0 +1,133 @@ + + + diff --git a/src/components/admin/BusinessListCard.vue b/src/components/admin/BusinessListCard.vue new file mode 100644 index 0000000..465a891 --- /dev/null +++ b/src/components/admin/BusinessListCard.vue @@ -0,0 +1,58 @@ + + + diff --git a/src/components/admin/StatCard.vue b/src/components/admin/StatCard.vue new file mode 100644 index 0000000..d48a7db --- /dev/null +++ b/src/components/admin/StatCard.vue @@ -0,0 +1,59 @@ + + + diff --git a/src/utils/media.ts b/src/utils/media.ts new file mode 100644 index 0000000..5f2a7e2 --- /dev/null +++ b/src/utils/media.ts @@ -0,0 +1,21 @@ +/** Base URL for uploaded files (empty = same origin /uploads via Vite proxy). */ +const assetsBase = (import.meta.env.VITE_ASSETS_URL ?? '').replace(/\/$/, ''); + +export function uploadUrl(path: string | null | undefined): string { + if (!path) return ''; + if (path.startsWith('http://') || path.startsWith('https://')) return path; + const rel = path.startsWith('/uploads/') + ? path + : path.startsWith('/') + ? path + : `/uploads/${path}`; + return assetsBase ? `${assetsBase}${rel}` : rel; +} + +export function businessCoverUrl( + images?: { url: string; isCover?: boolean }[] | null, +): string | null { + if (!images?.length) return null; + const cover = images.find((i) => i.isCover) ?? images[0]; + return uploadUrl(cover?.url) || null; +} diff --git a/src/views/BusinessesView.vue b/src/views/BusinessesView.vue index 05ecdb5..67e511d 100644 --- a/src/views/BusinessesView.vue +++ b/src/views/BusinessesView.vue @@ -30,26 +30,14 @@ کسب‌وکاری یافت نشد. -
    -
  • + -
    -
    -

    {{ biz.title }}

    -

    {{ biz.slug }}

    -

    {{ biz.category.title }}

    -
    - - {{ activeLabel(biz.isActive) }} - - {{ ownerLabel(biz) }} -
    -
    -
    -
    + + +
    فعال + + + ذخیره @@ -126,13 +122,17 @@ import { assignBusinessOwner, createBusiness, deleteBusiness, + deleteBusinessImage, + fetchBusiness, fetchBusinesses, updateBusiness, + uploadBusinessImages, } from '@/api/businesses'; import { fetchUsers } from '@/api/users'; import type { Business, BusinessCategory, User } from '@/api/types'; import { ApiError } from '@/api/client'; -import AppBadge from '@/components/ui/AppBadge.vue'; +import BusinessImageUpload from '@/components/admin/BusinessImageUpload.vue'; +import BusinessListCard from '@/components/admin/BusinessListCard.vue'; import AppButton from '@/components/ui/AppButton.vue'; import AppInput from '@/components/ui/AppInput.vue'; import AppModal from '@/components/ui/AppModal.vue'; @@ -141,7 +141,6 @@ import AppTextarea from '@/components/ui/AppTextarea.vue'; import EmptyState from '@/components/ui/EmptyState.vue'; import PageHeader from '@/components/ui/PageHeader.vue'; import AdminLayout from '@/layouts/AdminLayout.vue'; -import { activeLabel } from '@/utils/format'; import { isValidIranMobile, normalizeIranMobile } from '@/utils/mobile'; import { isValidSlug, slugify } from '@/utils/slug'; @@ -161,6 +160,9 @@ const slugTouched = ref(false); const assignOpen = ref(false); const assignTarget = ref(null); +const editingBusiness = ref(null); +const imageUploadRef = ref | null>(null); +const deletingImageId = ref(null); const form = ref({ title: '', @@ -231,7 +233,9 @@ function openCreate() { return; } editingId.value = null; + editingBusiness.value = null; slugTouched.value = false; + imageUploadRef.value?.clearPending(); form.value = { title: '', slug: '', @@ -246,9 +250,15 @@ function openCreate() { formOpen.value = true; } -function openEdit(biz: Business) { +async function openEdit(biz: Business) { editingId.value = biz.id; slugTouched.value = true; + try { + editingBusiness.value = await fetchBusiness(biz.id); + } catch { + editingBusiness.value = biz; + } + imageUploadRef.value?.clearPending(); form.value = { title: biz.title, slug: biz.slug, @@ -265,6 +275,23 @@ function openEdit(biz: Business) { function closeForm() { formOpen.value = false; + editingBusiness.value = null; + imageUploadRef.value?.clearPending(); +} + +async function onRemoveImage(imageId: string) { + if (!editingId.value) return; + deletingImageId.value = imageId; + error.value = ''; + try { + await deleteBusinessImage(editingId.value, imageId); + editingBusiness.value = await fetchBusiness(editingId.value); + await load(); + } catch (e) { + error.value = e instanceof ApiError ? e.message : 'خطا در حذف تصویر'; + } finally { + deletingImageId.value = null; + } } function maybeSlug() { @@ -295,8 +322,9 @@ async function onSaveBusiness() { const lng = parseCoord(form.value.longitude); try { - if (editingId.value) { - await updateBusiness(editingId.value, { + let businessId = editingId.value; + if (businessId) { + await updateBusiness(businessId, { title: form.value.title.trim(), slug: form.value.slug.trim(), categoryId: form.value.categoryId, @@ -308,7 +336,7 @@ async function onSaveBusiness() { isActive: form.value.isActive, }); } else { - await createBusiness({ + const created = await createBusiness({ title: form.value.title.trim(), slug: form.value.slug.trim(), categoryId: form.value.categoryId, @@ -319,7 +347,14 @@ async function onSaveBusiness() { longitude: lng, isActive: form.value.isActive, }); + businessId = created.id; } + + const pending = imageUploadRef.value?.getPendingFiles() ?? []; + if (businessId && pending.length > 0) { + await uploadBusinessImages(businessId, pending); + } + closeForm(); await load(); } catch (e) { diff --git a/src/views/DashboardView.vue b/src/views/DashboardView.vue index c1dec3e..a82d7b0 100644 --- a/src/views/DashboardView.vue +++ b/src/views/DashboardView.vue @@ -1,6 +1,16 @@ @@ -34,14 +67,18 @@ import { fetchBookings } from '@/api/bookings'; import { fetchCategories } from '@/api/business-categories'; import { fetchBusinesses } from '@/api/businesses'; import { fetchUsers } from '@/api/users'; +import type { Business } from '@/api/types'; import { ApiError } from '@/api/client'; -import PageHeader from '@/components/ui/PageHeader.vue'; +import BusinessListCard from '@/components/admin/BusinessListCard.vue'; +import StatCard from '@/components/admin/StatCard.vue'; +import EmptyState from '@/components/ui/EmptyState.vue'; import AdminLayout from '@/layouts/AdminLayout.vue'; import { useAuthStore } from '@/stores/auth'; const auth = useAuthStore(); const loading = ref(true); const error = ref(''); +const businesses = ref([]); const counts = ref({ businesses: 0, activeBusinesses: 0, @@ -50,31 +87,34 @@ const counts = ref({ categories: 0, }); -const cards = computed(() => [ - { label: 'کسب‌وکارها', value: counts.value.businesses, to: '/businesses' }, - { label: 'کسب‌وکار فعال', value: counts.value.activeBusinesses, to: '/businesses' }, - { label: 'رزروها', value: counts.value.bookings, to: '/bookings' }, - { label: 'کاربران', value: counts.value.users, to: '/users' }, - { label: 'دسته‌ها', value: counts.value.categories, to: '/categories' }, +const statCards = computed(() => [ + { label: 'کسب‌وکار', value: counts.value.businesses, icon: '🏪', to: '/businesses', tone: 'amber' as const }, + { label: 'فعال', value: counts.value.activeBusinesses, icon: '✓', to: '/businesses', tone: 'emerald' as const }, + { label: 'رزرو', value: counts.value.bookings, icon: '📅', to: '/bookings', tone: 'sky' as const }, + { label: 'کاربر', value: counts.value.users, icon: '👥', to: '/users', tone: 'violet' as const }, + { label: 'دسته', value: counts.value.categories, icon: '📁', to: '/categories', tone: 'rose' as const }, ]); +const recentBusinesses = computed(() => businesses.value.slice(0, 6)); + onMounted(async () => { try { - const [businesses, users, categories, bookingList] = await Promise.all([ + const [bizList, users, categories, bookingList] = await Promise.all([ fetchBusinesses(), fetchUsers(), fetchCategories(), fetchBookings().catch(() => []), ]); + businesses.value = bizList; counts.value = { - businesses: businesses.length, - activeBusinesses: businesses.filter((b) => b.isActive).length, + businesses: bizList.length, + activeBusinesses: bizList.filter((b) => b.isActive).length, bookings: bookingList.length, users: users.length, categories: categories.length, }; } catch (e) { - error.value = e instanceof ApiError ? e.message : 'خطا در بارگذاری آمار'; + error.value = e instanceof ApiError ? e.message : 'خطا در بارگذاری'; } finally { loading.value = false; } diff --git a/vite.config.ts b/vite.config.ts index fa644dc..de6ec40 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -20,6 +20,11 @@ export default defineConfig({ secure: true, rewrite: (path) => path.replace(/^\/api/, ''), }, + '/uploads': { + target: API_TARGET, + changeOrigin: true, + secure: true, + }, }, }, });