From d7d20672d9e4ae4670112853d997b39a334c98ec Mon Sep 17 00:00:00 2001 From: Vugar Safarzada Date: Sat, 25 Apr 2026 05:42:08 +0400 Subject: [PATCH 01/53] feat(services): build service authoring workspace for USOs - Add Service types and services-api.ts with full CRUD + lifecycle - Replace ServicesStrategyPage with functional ServicesUsoPage - Service create/edit form with context selector (individual/branch) - Lifecycle actions: submit, pause, resume, archive, delete - Rejection reason display with resubmit capability Closes #9 --- src/app/(protected)/services/page.tsx | 35 +- src/components/organisms/index.ts | 1 + .../organisms/services-uso-page/index.ts | 1 + .../services-uso-page.module.css | 432 ++++++ .../services-uso-page/services-uso-page.tsx | 1176 +++++++++++++++++ src/lib/services-api.ts | 186 +++ src/types/service.ts | 27 + 7 files changed, 1849 insertions(+), 9 deletions(-) create mode 100644 src/components/organisms/services-uso-page/index.ts create mode 100644 src/components/organisms/services-uso-page/services-uso-page.module.css create mode 100644 src/components/organisms/services-uso-page/services-uso-page.tsx create mode 100644 src/lib/services-api.ts create mode 100644 src/types/service.ts diff --git a/src/app/(protected)/services/page.tsx b/src/app/(protected)/services/page.tsx index 25200c5..5732432 100644 --- a/src/app/(protected)/services/page.tsx +++ b/src/app/(protected)/services/page.tsx @@ -1,6 +1,8 @@ +import { redirect } from "next/navigation"; import { cookies } from "next/headers"; -import { ServicesStrategyPage } from "@/components/organisms/services-strategy-page"; -import { fetchBrandById, fetchMyBrands } from "@/lib/brands-api"; +import { fetchMyServices } from "@/lib/services-api"; +import { fetchMyBrands, fetchBrandById } from "@/lib/brands-api"; +import { ServicesUsoPage } from "@/components/organisms/services-uso-page"; import { requireProtectedRouteAccess } from "@/lib/protected-route"; type ServicesPageProps = { @@ -10,19 +12,34 @@ type ServicesPageProps = { export default async function ServicesPage({ searchParams, }: ServicesPageProps) { - await requireProtectedRouteAccess("/services", searchParams); + const resolvedParams = await (searchParams ?? Promise.resolve({})); + const user = await requireProtectedRouteAccess("/services", resolvedParams); + + if (user.type !== "uso") { + redirect("/brands"); + } const cookieStore = await cookies(); const accessToken = cookieStore.get("rzp_at")?.value ?? ""; - const brands = await fetchMyBrands(accessToken).catch(() => []); + + const [services, brands] = await Promise.all([ + fetchMyServices(accessToken).catch(() => []), + fetchMyBrands(accessToken).catch(() => []), + ]); + + // Fetch detailed brand info (with branches) for the service form branch selector const detailedBrands = await Promise.all( brands.map(async (brand) => { - const detailedBrand = await fetchBrandById(brand.id, accessToken).catch( - () => null, - ); - return detailedBrand ?? brand; + const detailed = await fetchBrandById(brand.id, accessToken).catch(() => null); + return detailed ?? brand; }), ); - return ; + return ( + + ); } diff --git a/src/components/organisms/index.ts b/src/components/organisms/index.ts index f31ce14..83ad09f 100644 --- a/src/components/organisms/index.ts +++ b/src/components/organisms/index.ts @@ -10,4 +10,5 @@ export { BrandsUsoPage } from "./brands-uso-page"; export { ComingSoonPanel } from "./coming-soon-panel"; export { HomeDashboardPanel } from "./home-dashboard-panel"; export { ProtectedComingSoonRoute } from "./protected-coming-soon-route"; +export { ServicesUsoPage } from "./services-uso-page"; export { UserProfilePanel } from "./user-profile-panel"; diff --git a/src/components/organisms/services-uso-page/index.ts b/src/components/organisms/services-uso-page/index.ts new file mode 100644 index 0000000..bab3844 --- /dev/null +++ b/src/components/organisms/services-uso-page/index.ts @@ -0,0 +1 @@ +export { ServicesUsoPage } from "./services-uso-page"; diff --git a/src/components/organisms/services-uso-page/services-uso-page.module.css b/src/components/organisms/services-uso-page/services-uso-page.module.css new file mode 100644 index 0000000..eb39c41 --- /dev/null +++ b/src/components/organisms/services-uso-page/services-uso-page.module.css @@ -0,0 +1,432 @@ +.wrapper { + display: flex; + flex-direction: column; + gap: 1.714rem; +} + +/* ─── Page Header ──────────────────────────────────────────────────────────── */ +.pageHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--app-border-soft); + flex-wrap: wrap; +} + +.title { + margin: 0; + color: var(--app-text-strong); + font-size: var(--font-size-large); + font-weight: 700; + letter-spacing: -0.025em; + line-height: 1.2; +} + +/* ─── Feedback Banner ──────────────────────────────────────────────────────── */ +.feedbackBanner { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.625rem 1rem; + border-radius: var(--general-border-radius-card); + font-size: var(--font-size-small); + font-weight: 500; +} + +.feedbackSuccess { + background: var(--app-success-faint, #f0fdf4); + color: var(--app-success, #16a34a); + border: 1px solid var(--app-success-border, #bbf7d0); +} + +.feedbackError { + background: var(--app-error-faint, #fef2f2); + color: var(--app-error, #dc2626); + border: 1px solid var(--app-error-border, #fecaca); +} + +/* ─── Empty State ──────────────────────────────────────────────────────────── */ +.empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1rem; + padding: 4rem 2rem; + border-radius: var(--general-border-radius-card); + background: var(--app-bg-surface-strong); + border: 1px dashed var(--app-border-soft); + text-align: center; +} + +.emptyIcon { + color: var(--app-text-muted); + opacity: 0.5; +} + +.emptyTitle { + margin: 0; + color: var(--app-text-strong); + font-size: var(--font-size-base); + font-weight: 600; +} + +.emptyDescription { + margin: 0; + color: var(--app-text-muted); + font-size: var(--font-size-small); + max-width: 22rem; +} + +/* ─── Service List ─────────────────────────────────────────────────────────── */ +.list { + display: flex; + flex-direction: column; + gap: 1rem; +} + +/* ─── Service Card ─────────────────────────────────────────────────────────── */ +.serviceCard { + display: flex; + align-items: flex-start; + gap: 1rem; + padding: 1rem 1.25rem; + border-radius: var(--general-border-radius-card); + background: var(--app-bg-surface); + border: 1px solid var(--app-border-soft); + transition: border-color 140ms ease; +} + +.serviceCard:hover { + border-color: var(--app-border-strong); +} + +.cardImageThumb { + position: relative; + width: 72px; + height: 72px; + flex-shrink: 0; + border-radius: var(--general-border-radius-card); + overflow: hidden; + background: var(--app-bg-surface-strong); +} + +.cardImage { + object-fit: cover; +} + +.cardImagePlaceholder { + width: 72px; + height: 72px; + flex-shrink: 0; + border-radius: var(--general-border-radius-card); + background: var(--app-bg-surface-strong); + display: flex; + align-items: center; + justify-content: center; + color: var(--app-text-muted); +} + +.cardContent { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.cardHeader { + display: flex; + align-items: center; + gap: 0.625rem; + flex-wrap: wrap; +} + +.cardTitle { + margin: 0; + font-size: var(--font-size-base); + font-weight: 600; + color: var(--app-text-strong); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.cardMeta { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.metaChip { + display: inline-flex; + padding: 0.125rem 0.5rem; + border-radius: 9999px; + background: var(--app-primary-faint); + color: var(--app-primary); + font-size: var(--font-size-xsmall, 0.75rem); + font-weight: 500; +} + +.metaItem { + font-size: var(--font-size-small); + color: var(--app-text-muted); +} + +.metaItem + .metaItem::before { + content: "·"; + margin-right: 0.5rem; +} + +.rejectionBanner { + display: flex; + align-items: flex-start; + gap: 0.375rem; + padding: 0.5rem 0.75rem; + border-radius: var(--general-border-radius-card); + background: var(--app-error-faint, #fef2f2); + color: var(--app-error, #dc2626); + font-size: var(--font-size-small); + border: 1px solid var(--app-error-border, #fecaca); +} + +.pendingNote { + margin: 0; + font-size: var(--font-size-small); + color: var(--app-text-muted); + font-style: italic; +} + +.cardActions { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.375rem; + flex-shrink: 0; +} + +@media (max-width: 640px) { + .serviceCard { + flex-direction: column; + } + + .cardActions { + flex-direction: row; + flex-wrap: wrap; + align-items: flex-start; + width: 100%; + } +} + +/* ─── Form Dialog ──────────────────────────────────────────────────────────── */ +.formDialog { + max-width: 40rem; + width: 100%; + max-height: 90vh; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 1.25rem; + padding: 1.5rem; +} + +.formDialogHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.formDialogTitle { + margin: 0; + font-size: var(--font-size-base); + font-weight: 700; + color: var(--app-text-strong); +} + +.formDialogClose { + display: flex; + align-items: center; + justify-content: center; + padding: 0.25rem; + background: none; + border: none; + border-radius: 50%; + cursor: pointer; + color: var(--app-text-muted); + transition: color 140ms ease, background 140ms ease; + flex-shrink: 0; +} + +.formDialogClose:hover { + color: var(--app-text-strong); + background: var(--app-bg-surface-strong); +} + +/* ─── Form ─────────────────────────────────────────────────────────────────── */ +.form { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.textarea { + width: 100%; + padding: 0.5rem 0.75rem; + border: 1px solid var(--app-border-soft); + border-radius: var(--general-border-radius-input, 0.5rem); + background: var(--app-bg-surface); + color: var(--app-text-strong); + font-size: var(--font-size-small); + font-family: inherit; + resize: vertical; + min-height: 80px; + transition: border-color 140ms ease; + box-sizing: border-box; +} + +.textarea:focus { + outline: none; + border-color: var(--app-primary); +} + +.textarea::placeholder { + color: var(--app-text-muted); +} + +.select { + width: 100%; + padding: 0.5rem 0.75rem; + border: 1px solid var(--app-border-soft); + border-radius: var(--general-border-radius-input, 0.5rem); + background: var(--app-bg-surface); + color: var(--app-text-strong); + font-size: var(--font-size-small); + font-family: inherit; + cursor: pointer; + transition: border-color 140ms ease; + box-sizing: border-box; +} + +.select:focus { + outline: none; + border-color: var(--app-primary); +} + +.select:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.radioGroup { + display: flex; + gap: 1rem; + flex-wrap: wrap; +} + +.radioLabel { + display: flex; + align-items: center; + gap: 0.375rem; + font-size: var(--font-size-small); + color: var(--app-text-strong); + cursor: pointer; + user-select: none; +} + +.radioInput { + accent-color: var(--app-primary); + cursor: pointer; +} + +.inlineRow { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.durationInput { + flex: 1; + max-width: 10rem; +} + +.inlineUnit { + font-size: var(--font-size-small); + color: var(--app-text-muted); + white-space: nowrap; +} + +.fieldHint { + margin: 0 0 0.5rem; + font-size: var(--font-size-xsmall, 0.75rem); + color: var(--app-text-muted); +} + +.imageGrid { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.imageTile { + position: relative; + width: 80px; + height: 80px; + flex-shrink: 0; +} + +.imagePreview { + position: relative; + width: 100%; + height: 100%; + border-radius: var(--general-border-radius-card); + overflow: hidden; + background: var(--app-bg-surface-strong); + border: 1px solid var(--app-border-soft); +} + +.imagePreviewImg { + object-fit: cover; +} + +.imageRemove { + position: absolute; + top: -0.375rem; + right: -0.375rem; + width: 1.25rem; + height: 1.25rem; + border-radius: 50%; + background: var(--app-error, #dc2626); + color: white; + display: flex; + align-items: center; + justify-content: center; + border: none; + cursor: pointer; + padding: 0; + z-index: 1; +} + +.fileInput { + display: block; + font-size: var(--font-size-small); + color: var(--app-text-muted); + cursor: pointer; +} + +.uploadingHint { + margin: 0.25rem 0 0; + font-size: var(--font-size-small); + color: var(--app-text-muted); + font-style: italic; +} + +.formActions { + display: flex; + gap: 0.75rem; + padding-top: 0.5rem; +} diff --git a/src/components/organisms/services-uso-page/services-uso-page.tsx b/src/components/organisms/services-uso-page/services-uso-page.tsx new file mode 100644 index 0000000..4cc571d --- /dev/null +++ b/src/components/organisms/services-uso-page/services-uso-page.tsx @@ -0,0 +1,1176 @@ +"use client"; + +import { useMemo, useRef, useState } from "react"; +import Image from "next/image"; +import { isAxiosError } from "axios"; +import { + AlertDialog, + AlertDialogContent, + Badge, + Button, + Field, + FieldLabel, + FieldContent, + Input, +} from "@/components/atoms"; +import { Icon } from "@/components/icon"; +import { useLocale } from "@/components/providers/locale-provider"; +import { + createService, + updateService, + deleteService, + submitService, + pauseService, + resumeService, + archiveService, + uploadServiceMedia, + type CreateServicePayload, + type UpdateServicePayload, +} from "@/lib/services-api"; +import { proxyMediaUrl } from "@/lib/media"; +import type { Brand, Branch } from "@/types/brand"; +import type { Service, ServiceStatus, PriceType } from "@/types/service"; +import styles from "./services-uso-page.module.css"; + +type ServicesUsoPageProps = { + services: Service[]; + brands: Brand[]; + accessToken: string; +}; + +type ServiceFormState = { + title: string; + description: string; + category: string; + contextType: "individual" | "branch"; + address: string; + brandId: string; + branchId: string; + duration: string; + price_type: PriceType; + price: string; + image_media_ids: string[]; + imagePreviews: string[]; +}; + +type PageCopy = { + pageTitle: string; + createService: string; + emptyTitle: string; + emptyDescription: string; + statusDraft: string; + statusPending: string; + statusActive: string; + statusRejected: string; + statusPaused: string; + statusArchived: string; + actionEdit: string; + actionSubmit: string; + actionDelete: string; + actionResubmit: string; + actionPause: string; + actionResume: string; + actionArchive: string; + formTitleCreate: string; + formTitleEdit: string; + fieldTitle: string; + fieldTitlePlaceholder: string; + fieldDescription: string; + fieldDescriptionPlaceholder: string; + fieldCategory: string; + fieldCategoryPlaceholder: string; + fieldContext: string; + contextIndividual: string; + contextBranch: string; + fieldAddress: string; + fieldAddressPlaceholder: string; + fieldBrand: string; + fieldBrandPlaceholder: string; + fieldBranch: string; + fieldBranchPlaceholder: string; + fieldDuration: string; + fieldDurationPlaceholder: string; + fieldDurationUnit: string; + fieldPriceType: string; + priceTypeFixed: string; + priceTypeStartingFrom: string; + priceTypeFree: string; + fieldPrice: string; + fieldPricePlaceholder: string; + fieldImages: string; + fieldImagesHint: string; + btnSave: string; + btnCancel: string; + labelRejectionReason: string; + labelCategory: string; + labelDuration: string; + labelPrice: string; + labelBranch: string; + labelIndividual: string; + successCreate: string; + successUpdate: string; + successDelete: string; + successSubmit: string; + successPause: string; + successResume: string; + successArchive: string; + errorGeneric: string; + confirmDelete: string; + pendingNote: string; + uploadingImage: string; + selectBrandFirst: string; +}; + +const EN_COPY: PageCopy = { + pageTitle: "My Services", + createService: "Create service", + emptyTitle: "No services yet", + emptyDescription: + "Create your first service to start offering it to customers.", + statusDraft: "Draft", + statusPending: "Pending", + statusActive: "Active", + statusRejected: "Rejected", + statusPaused: "Paused", + statusArchived: "Archived", + actionEdit: "Edit", + actionSubmit: "Submit for review", + actionDelete: "Delete", + actionResubmit: "Resubmit", + actionPause: "Pause", + actionResume: "Resume", + actionArchive: "Archive", + formTitleCreate: "Create service", + formTitleEdit: "Edit service", + fieldTitle: "Title", + fieldTitlePlaceholder: "e.g. Haircut & Styling", + fieldDescription: "Description", + fieldDescriptionPlaceholder: "Describe your service…", + fieldCategory: "Category", + fieldCategoryPlaceholder: "e.g. Hair, Nails, Massage", + fieldContext: "Context", + contextIndividual: "Individual service", + contextBranch: "Branch-based service", + fieldAddress: "Address", + fieldAddressPlaceholder: "Where the service is provided", + fieldBrand: "Brand", + fieldBrandPlaceholder: "Select a brand", + fieldBranch: "Branch", + fieldBranchPlaceholder: "Select a branch", + fieldDuration: "Duration", + fieldDurationPlaceholder: "e.g. 60", + fieldDurationUnit: "min", + fieldPriceType: "Price type", + priceTypeFixed: "Fixed price", + priceTypeStartingFrom: "Starting from", + priceTypeFree: "Free", + fieldPrice: "Price", + fieldPricePlaceholder: "0.00", + fieldImages: "Photos", + fieldImagesHint: "Add photos of your service (JPEG or PNG, max 5)", + btnSave: "Save", + btnCancel: "Cancel", + labelRejectionReason: "Rejection reason", + labelCategory: "Category", + labelDuration: "Duration", + labelPrice: "Price", + labelBranch: "Branch", + labelIndividual: "Individual", + successCreate: "Service created.", + successUpdate: "Service updated.", + successDelete: "Service deleted.", + successSubmit: "Submitted for review.", + successPause: "Service paused.", + successResume: "Service resumed.", + successArchive: "Service archived.", + errorGeneric: "Something went wrong. Please try again.", + confirmDelete: "Are you sure you want to delete this service?", + pendingNote: "Under review — no actions available.", + uploadingImage: "Uploading…", + selectBrandFirst: "Select a brand first", +}; + +const TR_COPY: PageCopy = { + ...EN_COPY, + pageTitle: "Hizmetlerim", + createService: "Hizmet oluştur", + emptyTitle: "Henüz hizmet yok", + emptyDescription: "Müşterilere sunum yapmak için ilk hizmetini oluştur.", + statusDraft: "Taslak", + statusPending: "Beklemede", + statusActive: "Aktif", + statusRejected: "Reddedildi", + statusPaused: "Durduruldu", + statusArchived: "Arşivlendi", + actionEdit: "Düzenle", + actionSubmit: "İncelemeye gönder", + actionDelete: "Sil", + actionResubmit: "Yeniden gönder", + actionPause: "Durdur", + actionResume: "Devam et", + actionArchive: "Arşivle", + formTitleCreate: "Hizmet oluştur", + formTitleEdit: "Hizmeti düzenle", + fieldTitle: "Başlık", + fieldTitlePlaceholder: "ör. Saç Kesimi & Şekillendirme", + fieldDescription: "Açıklama", + fieldDescriptionPlaceholder: "Hizmetini açıkla…", + fieldCategory: "Kategori", + fieldCategoryPlaceholder: "ör. Saç, Tırnak, Masaj", + fieldContext: "Bağlam", + contextIndividual: "Bireysel hizmet", + contextBranch: "Şube bazlı hizmet", + fieldAddress: "Adres", + fieldAddressPlaceholder: "Hizmetin verileceği yer", + fieldBrand: "Marka", + fieldBrandPlaceholder: "Marka seç", + fieldBranch: "Şube", + fieldBranchPlaceholder: "Şube seç", + fieldDuration: "Süre", + fieldDurationPlaceholder: "ör. 60", + fieldDurationUnit: "dk", + fieldPriceType: "Fiyat türü", + priceTypeFixed: "Sabit fiyat", + priceTypeStartingFrom: "Başlangıç fiyatı", + priceTypeFree: "Ücretsiz", + fieldPrice: "Fiyat", + fieldPricePlaceholder: "0.00", + fieldImages: "Fotoğraflar", + fieldImagesHint: "Hizmetinle ilgili fotoğraf ekle (JPEG veya PNG, max 5)", + btnSave: "Kaydet", + btnCancel: "İptal", + labelRejectionReason: "Reddedilme nedeni", + labelCategory: "Kategori", + labelDuration: "Süre", + labelPrice: "Fiyat", + labelBranch: "Şube", + labelIndividual: "Bireysel", + successCreate: "Hizmet oluşturuldu.", + successUpdate: "Hizmet güncellendi.", + successDelete: "Hizmet silindi.", + successSubmit: "İncelemeye gönderildi.", + successPause: "Hizmet durduruldu.", + successResume: "Hizmet devam ediyor.", + successArchive: "Hizmet arşivlendi.", + errorGeneric: "Bir şeyler ters gitti. Lütfen tekrar dene.", + confirmDelete: "Bu hizmeti silmek istediğinden emin misin?", + pendingNote: "İnceleniyor — işlem yapılamaz.", + uploadingImage: "Yükleniyor…", + selectBrandFirst: "Önce marka seç", +}; + +const AZ_COPY: PageCopy = { + ...EN_COPY, + pageTitle: "Xidmətlərim", + createService: "Xidmət yarat", + emptyTitle: "Hələ xidmət yoxdur", + emptyDescription: "Müştərilərə təklif etmək üçün ilk xidmətini yarat.", + statusDraft: "Qaralama", + statusPending: "Gözləyir", + statusActive: "Aktiv", + statusRejected: "Rədd edildi", + statusPaused: "Dayandırıldı", + statusArchived: "Arxivləşdirildi", + actionEdit: "Redaktə et", + actionSubmit: "İcazəyə göndər", + actionDelete: "Sil", + actionResubmit: "Yenidən göndər", + actionPause: "Dayandır", + actionResume: "Davam et", + actionArchive: "Arxivlə", + formTitleCreate: "Xidmət yarat", + formTitleEdit: "Xidməti redaktə et", + fieldTitle: "Başlıq", + fieldTitlePlaceholder: "məs. Saç kəsimi & düzəlişi", + fieldDescription: "Təsvir", + fieldDescriptionPlaceholder: "Xidmətini təsvir et…", + fieldCategory: "Kateqoriya", + fieldCategoryPlaceholder: "məs. Saç, Dırnaq, Masaj", + fieldContext: "Kontekst", + contextIndividual: "Fərdi xidmət", + contextBranch: "Filial əsaslı xidmət", + fieldAddress: "Ünvan", + fieldAddressPlaceholder: "Xidmətin göstəriləcəyi yer", + fieldBrand: "Brend", + fieldBrandPlaceholder: "Brend seç", + fieldBranch: "Filial", + fieldBranchPlaceholder: "Filial seç", + fieldDuration: "Müddət", + fieldDurationPlaceholder: "məs. 60", + fieldDurationUnit: "dəq", + fieldPriceType: "Qiymət növü", + priceTypeFixed: "Sabit qiymət", + priceTypeStartingFrom: "Başlangıc qiyməti", + priceTypeFree: "Pulsuz", + fieldPrice: "Qiymət", + fieldPricePlaceholder: "0.00", + fieldImages: "Fotolar", + fieldImagesHint: "Xidmətinlə bağlı foto əlavə et (JPEG və ya PNG, max 5)", + btnSave: "Saxla", + btnCancel: "Ləğv et", + labelRejectionReason: "Rədd səbəbi", + labelCategory: "Kateqoriya", + labelDuration: "Müddət", + labelPrice: "Qiymət", + labelBranch: "Filial", + labelIndividual: "Fərdi", + successCreate: "Xidmət yaradıldı.", + successUpdate: "Xidmət yeniləndi.", + successDelete: "Xidmət silindi.", + successSubmit: "İcazəyə göndərildi.", + successPause: "Xidmət dayandırıldı.", + successResume: "Xidmət davam edir.", + successArchive: "Xidmət arxivləndi.", + errorGeneric: "Nəsə xəta baş verdi. Yenidən cəhd et.", + confirmDelete: "Bu xidməti silmək istədiyindən əminsən?", + pendingNote: "İcazə gözlənilir — heç bir əməliyyat mümkün deyil.", + uploadingImage: "Yüklənir…", + selectBrandFirst: "Əvvəlcə brend seç", +}; + +function getCopy(locale: string): PageCopy { + if (locale.startsWith("az")) return AZ_COPY; + if (locale.startsWith("tr")) return TR_COPY; + return EN_COPY; +} + +const STATUS_BADGE_VARIANT: Record< + ServiceStatus, + "default" | "secondary" | "destructive" | "outline" +> = { + DRAFT: "secondary", + PENDING: "default", + ACTIVE: "outline", + REJECTED: "destructive", + PAUSED: "secondary", + ARCHIVED: "secondary", +}; + +function formatPrice(service: Service, copy: PageCopy): string { + if (service.price_type === "FREE") return copy.priceTypeFree; + if (service.price === null) return "—"; + const prefix = service.price_type === "STARTING_FROM" ? `${copy.priceTypeStartingFrom} ` : ""; + return `${prefix}${service.price}`; +} + +function formatDuration(minutes: number | null, unit: string): string { + if (!minutes) return "—"; + if (minutes < 60) return `${minutes} ${unit}`; + const h = Math.floor(minutes / 60); + const m = minutes % 60; + return m > 0 ? `${h}h ${m}${unit}` : `${h}h`; +} + +function getStatusLabel(status: ServiceStatus, copy: PageCopy): string { + const map: Record = { + DRAFT: copy.statusDraft, + PENDING: copy.statusPending, + ACTIVE: copy.statusActive, + REJECTED: copy.statusRejected, + PAUSED: copy.statusPaused, + ARCHIVED: copy.statusArchived, + }; + return map[status]; +} + +function getBranchById(brands: Brand[], branchId: string): Branch | undefined { + for (const brand of brands) { + const branch = brand.branches?.find((b) => b.id === branchId); + if (branch) return branch; + } + return undefined; +} + +function getBrandByBranchId(brands: Brand[], branchId: string): Brand | undefined { + return brands.find((brand) => brand.branches?.some((b) => b.id === branchId)); +} + +const DEFAULT_FORM: ServiceFormState = { + title: "", + description: "", + category: "", + contextType: "individual", + address: "", + brandId: "", + branchId: "", + duration: "", + price_type: "FIXED", + price: "", + image_media_ids: [], + imagePreviews: [], +}; + +function serviceToFormState(service: Service, brands: Brand[]): ServiceFormState { + const contextType = service.branch_id ? "branch" : "individual"; + const brand = service.branch_id ? getBrandByBranchId(brands, service.branch_id) : undefined; + + return { + title: service.title, + description: service.description ?? "", + category: service.category ?? "", + contextType, + address: service.address ?? "", + brandId: brand?.id ?? "", + branchId: service.branch_id ?? "", + duration: service.duration !== null ? String(service.duration) : "", + price_type: service.price_type, + price: service.price !== null ? String(service.price) : "", + image_media_ids: service.images.map((img) => img.media_id), + imagePreviews: service.images.map((img) => proxyMediaUrl(img.url) ?? img.url), + }; +} + +function buildPayload(form: ServiceFormState): CreateServicePayload { + const payload: CreateServicePayload = { + title: form.title.trim(), + description: form.description.trim() || undefined, + category: form.category.trim() || undefined, + price_type: form.price_type, + image_media_ids: form.image_media_ids.length > 0 ? form.image_media_ids : undefined, + }; + + if (form.contextType === "branch") { + payload.branch_id = form.branchId || null; + } else { + payload.branch_id = null; + payload.address = form.address.trim() || undefined; + } + + if (form.duration.trim()) { + const d = parseInt(form.duration, 10); + if (!isNaN(d) && d > 0) payload.duration = d; + } + + if (form.price_type !== "FREE" && form.price.trim()) { + const p = parseFloat(form.price); + if (!isNaN(p)) payload.price = p; + } + + return payload; +} + +// ─── Service Form ───────────────────────────────────────────────────────────── + +function ServiceForm({ + copy, + brands, + accessToken, + initialData, + isLoading, + onSave, + onCancel, +}: { + copy: PageCopy; + brands: Brand[]; + accessToken: string; + initialData: ServiceFormState; + isLoading: boolean; + onSave: (payload: CreateServicePayload | UpdateServicePayload) => Promise; + onCancel: () => void; +}) { + const [form, setForm] = useState(initialData); + const [imageUploading, setImageUploading] = useState(false); + const fileInputRef = useRef(null); + + const selectedBrand = brands.find((b) => b.id === form.brandId); + const availableBranches = selectedBrand?.branches ?? []; + + function setField(key: K, value: ServiceFormState[K]) { + setForm((prev) => ({ ...prev, [key]: value })); + } + + async function handleImageUpload(event: React.ChangeEvent) { + const files = Array.from(event.target.files ?? []); + if (files.length === 0) return; + + const remaining = 5 - form.image_media_ids.length; + const toUpload = files.slice(0, remaining); + if (toUpload.length === 0) return; + + setImageUploading(true); + try { + const results = await Promise.all( + toUpload.map((file) => uploadServiceMedia(file, accessToken)), + ); + + setForm((prev) => ({ + ...prev, + image_media_ids: [...prev.image_media_ids, ...results.map((r) => r.media_id)], + imagePreviews: [...prev.imagePreviews, ...results.map((r) => r.url)], + })); + } catch { + // silently ignore upload errors for now + } finally { + setImageUploading(false); + if (fileInputRef.current) fileInputRef.current.value = ""; + } + } + + function removeImage(index: number) { + setForm((prev) => ({ + ...prev, + image_media_ids: prev.image_media_ids.filter((_, i) => i !== index), + imagePreviews: prev.imagePreviews.filter((_, i) => i !== index), + })); + } + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + const payload = buildPayload(form); + await onSave(payload); + } + + const showPrice = form.price_type !== "FREE"; + + return ( +
+ + {copy.fieldTitle} + + setField("title", e.target.value)} + placeholder={copy.fieldTitlePlaceholder} + required + /> + + + + + {copy.fieldDescription} + +