8aab968e09
Build & Deploy / Build & Push Docker Image (push) Successful in 1m2s
Test / Type Check (all packages) (push) Failing after 28s
Test / API Unit Tests (push) Has been skipped
Test / Homepage Unit Tests (push) Has been skipped
Test / Storefront Unit Tests (push) Has been skipped
Test / Admin Unit Tests (push) Has been skipped
Test / Dashboard Unit Tests (push) Has been skipped
Test / API Integration Tests (push) Has been skipped
Build & Deploy / Deploy to VPS (push) Successful in 3s
Comprehensive rename of all marketplace references to storefront: - API module: apps/api/src/modules/marketplace/ → storefront/ - Components: MarketplaceHeader → StorefrontHeader, MarketplaceShell → StorefrontShell, MarketplaceFooter → StorefrontFooter - Types: marketplace-homepage.ts → storefront-homepage.ts - Test files: employee-marketplace-* → employee-storefront-* - All source code identifiers, imports, route paths, and strings - Documentation (docs/), CI config (.gitlab-ci.yml), scripts - Dashboard, admin, storefront workspace references - Prisma field names preserved (isListedOnMarketplace, marketplaceRating, marketplaceFunnelEvent) as they map to database schema Validation: - API type-check: 0 errors - Storefront type-check: 0 errors - Dashboard type-check: 0 errors - Full monorepo type-check: only pre-existing admin TS18046
798 lines
32 KiB
TypeScript
798 lines
32 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useState } from 'react'
|
|
import dayjs from 'dayjs'
|
|
import { formatCurrency, getCurrencyLabel } from '@rentaldrivego/types'
|
|
import { apiFetch } from '@/lib/api'
|
|
import { useDashboardI18n } from '@/components/I18nProvider'
|
|
|
|
const CATEGORIES = ['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK'] as const
|
|
type Category = typeof CATEGORIES[number]
|
|
|
|
interface Offer {
|
|
id: string
|
|
title: string
|
|
description?: string | null
|
|
termsAndConds?: string | null
|
|
type: 'PERCENTAGE' | 'FIXED_AMOUNT' | 'FREE_DAY' | 'SPECIAL_RATE'
|
|
discountValue: number
|
|
specialRate?: number | null
|
|
appliesToAll: boolean
|
|
categories: Category[]
|
|
minRentalDays?: number | null
|
|
maxRentalDays?: number | null
|
|
promoCode?: string | null
|
|
maxRedemptions?: number | null
|
|
validFrom: string
|
|
validUntil: string
|
|
isActive: boolean
|
|
isPublic: boolean
|
|
isFeatured: boolean
|
|
redemptionCount?: number
|
|
vehicles?: Array<{ vehicleId: string }>
|
|
}
|
|
|
|
interface FleetVehicle {
|
|
id: string
|
|
make: string
|
|
model: string
|
|
year: number
|
|
category: string
|
|
licensePlate: string
|
|
}
|
|
|
|
interface FormState {
|
|
title: string
|
|
description: string
|
|
termsAndConds: string
|
|
type: Offer['type']
|
|
discountValue: string
|
|
specialRate: string
|
|
appliesToAll: boolean
|
|
categories: Category[]
|
|
vehicleIds: string[]
|
|
minRentalDays: string
|
|
maxRentalDays: string
|
|
promoCode: string
|
|
maxRedemptions: string
|
|
validFrom: string
|
|
validUntil: string
|
|
isActive: boolean
|
|
isPublic: boolean
|
|
isFeatured: boolean
|
|
}
|
|
|
|
function emptyForm(): FormState {
|
|
const today = dayjs().format('YYYY-MM-DD')
|
|
const nextMonth = dayjs().add(30, 'day').format('YYYY-MM-DD')
|
|
return {
|
|
title: '',
|
|
description: '',
|
|
termsAndConds: '',
|
|
type: 'PERCENTAGE',
|
|
discountValue: '',
|
|
specialRate: '',
|
|
appliesToAll: true,
|
|
categories: [],
|
|
vehicleIds: [],
|
|
minRentalDays: '',
|
|
maxRentalDays: '',
|
|
promoCode: '',
|
|
maxRedemptions: '',
|
|
validFrom: today,
|
|
validUntil: nextMonth,
|
|
isActive: true,
|
|
isPublic: true,
|
|
isFeatured: false,
|
|
}
|
|
}
|
|
|
|
function offerToForm(o: Offer): FormState {
|
|
return {
|
|
title: o.title,
|
|
description: o.description ?? '',
|
|
termsAndConds: o.termsAndConds ?? '',
|
|
type: o.type,
|
|
discountValue: String(o.discountValue),
|
|
specialRate: o.specialRate != null ? String(o.specialRate) : '',
|
|
appliesToAll: o.appliesToAll,
|
|
categories: o.categories ?? [],
|
|
vehicleIds: o.vehicles?.map((v) => v.vehicleId) ?? [],
|
|
minRentalDays: o.minRentalDays != null ? String(o.minRentalDays) : '',
|
|
maxRentalDays: o.maxRentalDays != null ? String(o.maxRentalDays) : '',
|
|
promoCode: o.promoCode ?? '',
|
|
maxRedemptions: o.maxRedemptions != null ? String(o.maxRedemptions) : '',
|
|
validFrom: dayjs(o.validFrom).format('YYYY-MM-DD'),
|
|
validUntil: dayjs(o.validUntil).format('YYYY-MM-DD'),
|
|
isActive: o.isActive,
|
|
isPublic: o.isPublic,
|
|
isFeatured: o.isFeatured,
|
|
}
|
|
}
|
|
|
|
const copy = {
|
|
en: {
|
|
title: 'Offers',
|
|
subtitle: 'Promotions shown on your site and optionally on the storefront.',
|
|
createOffer: 'Create Offer',
|
|
editOffer: 'Edit Offer',
|
|
deleteOffer: 'Delete Offer',
|
|
confirmDelete: 'Are you sure you want to delete this offer? This action cannot be undone.',
|
|
cancel: 'Cancel',
|
|
save: 'Save',
|
|
saving: 'Saving…',
|
|
delete: 'Delete',
|
|
deleting: 'Deleting…',
|
|
ends: 'ends',
|
|
active: 'Active',
|
|
inactive: 'Inactive',
|
|
storefront: 'Storefront',
|
|
featured: 'Featured',
|
|
empty: 'No offers yet. Create your first offer to attract customers.',
|
|
labelTitle: 'Title *',
|
|
labelDescription: 'Description',
|
|
labelTerms: 'Terms & Conditions',
|
|
labelType: 'Discount Type *',
|
|
labelDiscount: 'Discount Value *',
|
|
labelSpecialRate: 'Special Rate (MAD/day) *',
|
|
labelAppliesToAll: 'Applies to all vehicles',
|
|
labelCategories: 'Vehicle Categories',
|
|
labelVehicles: 'Specific Vehicles',
|
|
labelVehicleSearch: 'Search vehicles…',
|
|
labelMinDays: 'Min Rental Days',
|
|
labelMaxDays: 'Max Rental Days',
|
|
labelPromoCode: 'Promo Code',
|
|
labelMaxRedemptions: 'Max Redemptions',
|
|
labelValidFrom: 'Valid From *',
|
|
labelValidUntil: 'Valid Until *',
|
|
labelIsActive: 'Active',
|
|
labelIsPublic: 'Show on storefront',
|
|
labelIsFeatured: 'Featured',
|
|
typePERCENTAGE: 'Percentage (%)',
|
|
typeFIXED_AMOUNT: 'Fixed Amount (MAD)',
|
|
typeFREE_DAY: 'Free Day',
|
|
typeSPECIAL_RATE: 'Special Daily Rate',
|
|
placeholderTitle: 'e.g. Summer Promotion',
|
|
placeholderDescription: 'Optional description…',
|
|
placeholderTerms: 'Terms and conditions…',
|
|
placeholderPromoCode: 'e.g. SUMMER20',
|
|
errorTitle: 'Title is required.',
|
|
errorDiscount: 'Please enter a valid discount value.',
|
|
errorDates: 'Valid From and Valid Until are required.',
|
|
errorDateOrder: 'Valid Until must be after Valid From.',
|
|
failedSave: 'Failed to save offer.',
|
|
failedDelete: 'Failed to delete offer.',
|
|
redemptions: 'redemptions',
|
|
freeDay: 'Free day',
|
|
loadingVehicles: 'Loading fleet…',
|
|
noVehiclesFound: 'No vehicles found.',
|
|
selected: (n: number) => `${n} selected`,
|
|
categoryLabels: {
|
|
ECONOMY: 'Economy', COMPACT: 'Compact', MIDSIZE: 'Midsize', FULLSIZE: 'Full-size',
|
|
SUV: 'SUV', LUXURY: 'Luxury', VAN: 'Van', TRUCK: 'Truck',
|
|
},
|
|
},
|
|
fr: {
|
|
title: 'Offres',
|
|
subtitle: 'Promotions affichées sur votre site et éventuellement sur la storefront.',
|
|
createOffer: 'Créer une offre',
|
|
editOffer: "Modifier l'offre",
|
|
deleteOffer: "Supprimer l'offre",
|
|
confirmDelete: 'Êtes-vous sûr de vouloir supprimer cette offre ? Cette action est irréversible.',
|
|
cancel: 'Annuler',
|
|
save: 'Enregistrer',
|
|
saving: 'Enregistrement…',
|
|
delete: 'Supprimer',
|
|
deleting: 'Suppression…',
|
|
ends: 'expire le',
|
|
active: 'Actif',
|
|
inactive: 'Inactif',
|
|
storefront: 'Storefront',
|
|
featured: 'Mise en avant',
|
|
empty: 'Aucune offre. Créez votre première offre pour attirer des clients.',
|
|
labelTitle: 'Titre *',
|
|
labelDescription: 'Description',
|
|
labelTerms: 'Conditions générales',
|
|
labelType: 'Type de remise *',
|
|
labelDiscount: 'Valeur de la remise *',
|
|
labelSpecialRate: 'Tarif spécial (MAD/jour) *',
|
|
labelAppliesToAll: 'Applicable à tous les véhicules',
|
|
labelCategories: 'Catégories de véhicules',
|
|
labelVehicles: 'Véhicules spécifiques',
|
|
labelVehicleSearch: 'Rechercher des véhicules…',
|
|
labelMinDays: 'Jours de location minimum',
|
|
labelMaxDays: 'Jours de location maximum',
|
|
labelPromoCode: 'Code promo',
|
|
labelMaxRedemptions: 'Nombre max de rédemptions',
|
|
labelValidFrom: 'Valable du *',
|
|
labelValidUntil: "Valable jusqu'au *",
|
|
labelIsActive: 'Actif',
|
|
labelIsPublic: 'Afficher sur la storefront',
|
|
labelIsFeatured: 'Mise en avant',
|
|
typePERCENTAGE: 'Pourcentage (%)',
|
|
typeFIXED_AMOUNT: 'Montant fixe (MAD)',
|
|
typeFREE_DAY: 'Jour gratuit',
|
|
typeSPECIAL_RATE: 'Tarif journalier spécial',
|
|
placeholderTitle: 'ex. Promotion estivale',
|
|
placeholderDescription: 'Description optionnelle…',
|
|
placeholderTerms: 'Conditions générales…',
|
|
placeholderPromoCode: 'ex. ETE20',
|
|
errorTitle: 'Le titre est requis.',
|
|
errorDiscount: 'Veuillez saisir une valeur de remise valide.',
|
|
errorDates: 'Les dates de début et de fin sont requises.',
|
|
errorDateOrder: 'La date de fin doit être après la date de début.',
|
|
failedSave: "Échec de l'enregistrement de l'offre.",
|
|
failedDelete: "Échec de la suppression de l'offre.",
|
|
redemptions: 'rédemptions',
|
|
freeDay: 'Jour gratuit',
|
|
loadingVehicles: 'Chargement de la flotte…',
|
|
noVehiclesFound: 'Aucun véhicule trouvé.',
|
|
selected: (n: number) => `${n} sélectionné${n > 1 ? 's' : ''}`,
|
|
categoryLabels: {
|
|
ECONOMY: 'Économique', COMPACT: 'Compacte', MIDSIZE: 'Intermédiaire', FULLSIZE: 'Grande berline',
|
|
SUV: 'SUV', LUXURY: 'Luxe', VAN: 'Van', TRUCK: 'Camionnette',
|
|
},
|
|
},
|
|
ar: {
|
|
title: 'العروض',
|
|
subtitle: 'عروض ترويجية تظهر في موقعك ويمكن عرضها أيضاً في السوق.',
|
|
createOffer: 'إنشاء عرض',
|
|
editOffer: 'تعديل العرض',
|
|
deleteOffer: 'حذف العرض',
|
|
confirmDelete: 'هل أنت متأكد من حذف هذا العرض؟ لا يمكن التراجع عن هذا الإجراء.',
|
|
cancel: 'إلغاء',
|
|
save: 'حفظ',
|
|
saving: 'جارٍ الحفظ…',
|
|
delete: 'حذف',
|
|
deleting: 'جارٍ الحذف…',
|
|
ends: 'تنتهي في',
|
|
active: 'نشط',
|
|
inactive: 'غير نشط',
|
|
storefront: 'السوق',
|
|
featured: 'مميز',
|
|
empty: 'لا توجد عروض بعد. أنشئ أول عرض لجذب العملاء.',
|
|
labelTitle: 'العنوان *',
|
|
labelDescription: 'الوصف',
|
|
labelTerms: 'الشروط والأحكام',
|
|
labelType: 'نوع الخصم *',
|
|
labelDiscount: 'قيمة الخصم *',
|
|
labelSpecialRate: 'السعر الخاص (درهم/يوم) *',
|
|
labelAppliesToAll: 'ينطبق على جميع المركبات',
|
|
labelCategories: 'فئات المركبات',
|
|
labelVehicles: 'مركبات محددة',
|
|
labelVehicleSearch: 'ابحث عن مركبة…',
|
|
labelMinDays: 'الحد الأدنى لأيام الإيجار',
|
|
labelMaxDays: 'الحد الأقصى لأيام الإيجار',
|
|
labelPromoCode: 'رمز الترويج',
|
|
labelMaxRedemptions: 'الحد الأقصى للاسترداد',
|
|
labelValidFrom: 'صالح من *',
|
|
labelValidUntil: 'صالح حتى *',
|
|
labelIsActive: 'نشط',
|
|
labelIsPublic: 'عرض في السوق',
|
|
labelIsFeatured: 'مميز',
|
|
typePERCENTAGE: 'نسبة مئوية (%)',
|
|
typeFIXED_AMOUNT: 'مبلغ ثابت (درهم)',
|
|
typeFREE_DAY: 'يوم مجاني',
|
|
typeSPECIAL_RATE: 'سعر يومي خاص',
|
|
placeholderTitle: 'مثال: عرض الصيف',
|
|
placeholderDescription: 'وصف اختياري…',
|
|
placeholderTerms: 'الشروط والأحكام…',
|
|
placeholderPromoCode: 'مثال: SUMMER20',
|
|
errorTitle: 'العنوان مطلوب.',
|
|
errorDiscount: 'يرجى إدخال قيمة خصم صحيحة.',
|
|
errorDates: 'تاريخ البداية والنهاية مطلوبان.',
|
|
errorDateOrder: 'يجب أن يكون تاريخ الانتهاء بعد تاريخ البداية.',
|
|
failedSave: 'فشل حفظ العرض.',
|
|
failedDelete: 'فشل حذف العرض.',
|
|
redemptions: 'استرداد',
|
|
freeDay: 'يوم مجاني',
|
|
loadingVehicles: 'جارٍ تحميل الأسطول…',
|
|
noVehiclesFound: 'لم يتم العثور على مركبات.',
|
|
selected: (n: number) => `${n} محدد`,
|
|
categoryLabels: {
|
|
ECONOMY: 'اقتصادية', COMPACT: 'مدمجة', MIDSIZE: 'متوسطة', FULLSIZE: 'كبيرة',
|
|
SUV: 'دفع رباعي', LUXURY: 'فاخرة', VAN: 'فان', TRUCK: 'شاحنة',
|
|
},
|
|
},
|
|
}
|
|
|
|
function discountLabel(offer: Offer, t: typeof copy['en'], language: 'en' | 'fr' | 'ar'): string {
|
|
if (offer.type === 'PERCENTAGE') return `${offer.discountValue}%`
|
|
if (offer.type === 'FIXED_AMOUNT') return formatCurrency(offer.discountValue, 'MAD', language)
|
|
if (offer.type === 'FREE_DAY') return t.freeDay
|
|
if (offer.type === 'SPECIAL_RATE') return `${offer.specialRate ?? offer.discountValue} ${getCurrencyLabel(language)}${language === 'fr' ? '/jour' : language === 'ar' ? '/يوم' : '/day'}`
|
|
return String(offer.discountValue)
|
|
}
|
|
|
|
export default function OffersPage() {
|
|
const { language } = useDashboardI18n()
|
|
const t = copy[language]
|
|
|
|
const [offers, setOffers] = useState<Offer[]>([])
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [modalOpen, setModalOpen] = useState(false)
|
|
const [editing, setEditing] = useState<Offer | null>(null)
|
|
const [form, setForm] = useState<FormState>(emptyForm)
|
|
const [formError, setFormError] = useState<string | null>(null)
|
|
const [saving, setSaving] = useState(false)
|
|
const [deleteTarget, setDeleteTarget] = useState<Offer | null>(null)
|
|
const [deleting, setDeleting] = useState(false)
|
|
|
|
const [fleet, setFleet] = useState<FleetVehicle[]>([])
|
|
const [loadingFleet, setLoadingFleet] = useState(false)
|
|
const [vehicleSearch, setVehicleSearch] = useState('')
|
|
|
|
function load() {
|
|
apiFetch<Offer[]>('/offers')
|
|
.then((data) => {
|
|
setOffers(Array.isArray(data) ? data : [])
|
|
setError(null)
|
|
})
|
|
.catch((err) => setError(err.message))
|
|
}
|
|
|
|
useEffect(() => { load() }, [])
|
|
|
|
async function loadFleet() {
|
|
setLoadingFleet(true)
|
|
try {
|
|
const data = await apiFetch<FleetVehicle[]>('/vehicles?pageSize=200')
|
|
setFleet(Array.isArray(data) ? data : [])
|
|
} catch {
|
|
// non-fatal; vehicle picker just stays empty
|
|
} finally {
|
|
setLoadingFleet(false)
|
|
}
|
|
}
|
|
|
|
function openCreate() {
|
|
setEditing(null)
|
|
setForm(emptyForm())
|
|
setFormError(null)
|
|
setVehicleSearch('')
|
|
setModalOpen(true)
|
|
loadFleet()
|
|
}
|
|
|
|
async function openEdit(offer: Offer) {
|
|
setEditing(offer)
|
|
setForm(offerToForm(offer))
|
|
setFormError(null)
|
|
setVehicleSearch('')
|
|
setModalOpen(true)
|
|
// Load fleet and the offer's current vehicle IDs in parallel
|
|
loadFleet()
|
|
try {
|
|
const detail = await apiFetch<Offer>(`/offers/${offer.id}`)
|
|
setForm((prev) => ({
|
|
...prev,
|
|
vehicleIds: Array.isArray(detail?.vehicles) ? detail.vehicles.map((v) => v.vehicleId) : [],
|
|
}))
|
|
} catch {
|
|
// leave vehicleIds as empty if fetch fails
|
|
}
|
|
}
|
|
|
|
function closeModal() {
|
|
setModalOpen(false)
|
|
setEditing(null)
|
|
setFormError(null)
|
|
}
|
|
|
|
function toggleCategory(cat: Category) {
|
|
setForm((f) => ({
|
|
...f,
|
|
categories: f.categories.includes(cat)
|
|
? f.categories.filter((c) => c !== cat)
|
|
: [...f.categories, cat],
|
|
}))
|
|
}
|
|
|
|
function toggleVehicle(id: string) {
|
|
setForm((f) => ({
|
|
...f,
|
|
vehicleIds: f.vehicleIds.includes(id)
|
|
? f.vehicleIds.filter((v) => v !== id)
|
|
: [...f.vehicleIds, id],
|
|
}))
|
|
}
|
|
|
|
async function handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault()
|
|
setFormError(null)
|
|
|
|
if (!form.title.trim()) return setFormError(t.errorTitle)
|
|
if (!form.validFrom || !form.validUntil) return setFormError(t.errorDates)
|
|
if (form.validUntil <= form.validFrom) return setFormError(t.errorDateOrder)
|
|
if (form.type !== 'FREE_DAY') {
|
|
const dv = Number(form.discountValue)
|
|
if (isNaN(dv) || dv < 0) return setFormError(t.errorDiscount)
|
|
}
|
|
|
|
setSaving(true)
|
|
try {
|
|
const payload: Record<string, unknown> = {
|
|
title: form.title.trim(),
|
|
description: form.description.trim() || undefined,
|
|
termsAndConds: form.termsAndConds.trim() || undefined,
|
|
type: form.type,
|
|
discountValue: form.type === 'FREE_DAY' ? 0 : Number(form.discountValue),
|
|
specialRate: form.specialRate ? Number(form.specialRate) : undefined,
|
|
appliesToAll: form.appliesToAll,
|
|
categories: form.appliesToAll ? [] : form.categories,
|
|
minRentalDays: form.minRentalDays ? Number(form.minRentalDays) : undefined,
|
|
maxRentalDays: form.maxRentalDays ? Number(form.maxRentalDays) : undefined,
|
|
promoCode: form.promoCode.trim() || undefined,
|
|
maxRedemptions: form.maxRedemptions ? Number(form.maxRedemptions) : undefined,
|
|
validFrom: new Date(form.validFrom).toISOString(),
|
|
validUntil: new Date(form.validUntil).toISOString(),
|
|
isActive: form.isActive,
|
|
isPublic: form.isPublic,
|
|
isFeatured: form.isFeatured,
|
|
vehicleIds: form.appliesToAll ? [] : form.vehicleIds,
|
|
}
|
|
|
|
if (editing) {
|
|
await apiFetch(`/offers/${editing.id}`, { method: 'PATCH', body: JSON.stringify(payload) })
|
|
} else {
|
|
await apiFetch('/offers', { method: 'POST', body: JSON.stringify(payload) })
|
|
}
|
|
closeModal()
|
|
load()
|
|
} catch (err: any) {
|
|
setFormError(err.message ?? t.failedSave)
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
async function handleDelete() {
|
|
if (!deleteTarget) return
|
|
setDeleting(true)
|
|
try {
|
|
await apiFetch(`/offers/${deleteTarget.id}`, { method: 'DELETE' })
|
|
setDeleteTarget(null)
|
|
load()
|
|
} catch (err: any) {
|
|
setFormError(err.message ?? t.failedDelete)
|
|
setDeleteTarget(null)
|
|
} finally {
|
|
setDeleting(false)
|
|
}
|
|
}
|
|
|
|
const isRtl = language === 'ar'
|
|
|
|
const filteredFleet = fleet.filter((v) => {
|
|
const q = vehicleSearch.toLowerCase()
|
|
return (
|
|
!q ||
|
|
v.make.toLowerCase().includes(q) ||
|
|
v.model.toLowerCase().includes(q) ||
|
|
v.licensePlate.toLowerCase().includes(q)
|
|
)
|
|
})
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div>
|
|
<h2 className="text-xl font-semibold text-slate-900">{t.title}</h2>
|
|
<p className="text-sm text-slate-500 mt-1">{t.subtitle}</p>
|
|
</div>
|
|
<button type="button" onClick={openCreate} className="btn-primary shrink-0">
|
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
|
</svg>
|
|
{t.createOffer}
|
|
</button>
|
|
</div>
|
|
|
|
{error && <div className="card p-4 text-sm text-red-600">{error}</div>}
|
|
|
|
{/* Offer cards */}
|
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
|
{offers.map((offer) => (
|
|
<div key={offer.id} className="card p-5 flex flex-col gap-3">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<h3 className="text-base font-semibold text-slate-900 truncate">{offer.title}</h3>
|
|
<p className="text-sm text-slate-500 mt-0.5">
|
|
{t[`type${offer.type}` as keyof typeof t] as string} · {t.ends} {dayjs(offer.validUntil).format('MMM D, YYYY')}
|
|
</p>
|
|
</div>
|
|
<span className={`shrink-0 ${offer.isActive ? 'badge-green' : 'badge-gray'}`}>
|
|
{offer.isActive ? t.active : t.inactive}
|
|
</span>
|
|
</div>
|
|
|
|
<p className="text-2xl font-bold text-slate-900">{discountLabel(offer, t, language)}</p>
|
|
|
|
{offer.description && (
|
|
<p className="text-sm text-slate-500 line-clamp-2">{offer.description}</p>
|
|
)}
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
{offer.isPublic && <span className="badge-blue">{t.storefront}</span>}
|
|
{offer.isFeatured && <span className="badge-purple">{t.featured}</span>}
|
|
{offer.promoCode && <span className="badge-amber">{offer.promoCode}</span>}
|
|
{typeof offer.redemptionCount === 'number' && (
|
|
<span className="badge-gray">{offer.redemptionCount} {t.redemptions}</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className={`flex gap-2 mt-auto pt-2 border-t border-slate-100 ${isRtl ? 'flex-row-reverse' : ''}`}>
|
|
<button type="button" onClick={() => openEdit(offer)} className="btn-secondary text-xs px-3 py-1.5">
|
|
{t.editOffer}
|
|
</button>
|
|
<button type="button" onClick={() => setDeleteTarget(offer)} className="btn-danger text-xs px-3 py-1.5">
|
|
{t.deleteOffer}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{offers.length === 0 && !error && (
|
|
<div className="col-span-full card p-10 text-center text-sm text-slate-400">{t.empty}</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Create / Edit Modal */}
|
|
{modalOpen && (
|
|
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-blue-950/50 p-4">
|
|
<div className="relative w-full max-w-2xl my-8 card p-6" dir={isRtl ? 'rtl' : 'ltr'}>
|
|
<h3 className="text-lg font-semibold text-slate-900 mb-5">
|
|
{editing ? t.editOffer : t.createOffer}
|
|
</h3>
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
{/* Title */}
|
|
<div>
|
|
<label className="block text-xs font-medium text-slate-600 mb-1">{t.labelTitle}</label>
|
|
<input
|
|
className="input-field"
|
|
value={form.title}
|
|
onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
|
|
placeholder={t.placeholderTitle}
|
|
/>
|
|
</div>
|
|
|
|
{/* Description */}
|
|
<div>
|
|
<label className="block text-xs font-medium text-slate-600 mb-1">{t.labelDescription}</label>
|
|
<textarea
|
|
className="input-field resize-none"
|
|
rows={2}
|
|
value={form.description}
|
|
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
|
|
placeholder={t.placeholderDescription}
|
|
/>
|
|
</div>
|
|
|
|
{/* Type + Discount/Rate */}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-xs font-medium text-slate-600 mb-1">{t.labelType}</label>
|
|
<select
|
|
className="input-field"
|
|
value={form.type}
|
|
onChange={(e) => setForm((f) => ({ ...f, type: e.target.value as Offer['type'] }))}
|
|
>
|
|
<option value="PERCENTAGE">{t.typePERCENTAGE}</option>
|
|
<option value="FIXED_AMOUNT">{t.typeFIXED_AMOUNT}</option>
|
|
<option value="FREE_DAY">{t.typeFREE_DAY}</option>
|
|
<option value="SPECIAL_RATE">{t.typeSPECIAL_RATE}</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
{form.type === 'SPECIAL_RATE' ? (
|
|
<>
|
|
<label className="block text-xs font-medium text-slate-600 mb-1">{t.labelSpecialRate}</label>
|
|
<input type="number" min={0} className="input-field" value={form.specialRate} onChange={(e) => setForm((f) => ({ ...f, specialRate: e.target.value }))} />
|
|
</>
|
|
) : form.type !== 'FREE_DAY' ? (
|
|
<>
|
|
<label className="block text-xs font-medium text-slate-600 mb-1">{t.labelDiscount}</label>
|
|
<input type="number" min={0} className="input-field" value={form.discountValue} onChange={(e) => setForm((f) => ({ ...f, discountValue: e.target.value }))} />
|
|
</>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Dates */}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-xs font-medium text-slate-600 mb-1">{t.labelValidFrom}</label>
|
|
<input type="date" className="input-field" value={form.validFrom} onChange={(e) => setForm((f) => ({ ...f, validFrom: e.target.value }))} />
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-slate-600 mb-1">{t.labelValidUntil}</label>
|
|
<input type="date" className="input-field" value={form.validUntil} onChange={(e) => setForm((f) => ({ ...f, validUntil: e.target.value }))} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Promo code + Max Redemptions */}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-xs font-medium text-slate-600 mb-1">{t.labelPromoCode}</label>
|
|
<input className="input-field" value={form.promoCode} onChange={(e) => setForm((f) => ({ ...f, promoCode: e.target.value }))} placeholder={t.placeholderPromoCode} />
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-slate-600 mb-1">{t.labelMaxRedemptions}</label>
|
|
<input type="number" min={1} className="input-field" value={form.maxRedemptions} onChange={(e) => setForm((f) => ({ ...f, maxRedemptions: e.target.value }))} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Min / Max Rental Days */}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-xs font-medium text-slate-600 mb-1">{t.labelMinDays}</label>
|
|
<input type="number" min={1} className="input-field" value={form.minRentalDays} onChange={(e) => setForm((f) => ({ ...f, minRentalDays: e.target.value }))} />
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-slate-600 mb-1">{t.labelMaxDays}</label>
|
|
<input type="number" min={1} className="input-field" value={form.maxRentalDays} onChange={(e) => setForm((f) => ({ ...f, maxRentalDays: e.target.value }))} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Applies-to-all toggle */}
|
|
<div>
|
|
<label className="flex items-center gap-2 text-sm text-slate-700 cursor-pointer select-none">
|
|
<input
|
|
type="checkbox"
|
|
className="h-4 w-4 rounded border-slate-300 accent-blue-600"
|
|
checked={form.appliesToAll}
|
|
onChange={(e) => setForm((f) => ({ ...f, appliesToAll: e.target.checked }))}
|
|
/>
|
|
{t.labelAppliesToAll}
|
|
</label>
|
|
</div>
|
|
|
|
{/* Scoped selection — only shown when not appliesToAll */}
|
|
{!form.appliesToAll && (
|
|
<div className="space-y-4 rounded-xl border border-slate-200 p-4">
|
|
{/* Category chips */}
|
|
<div>
|
|
<p className="text-xs font-medium text-slate-600 mb-2">{t.labelCategories}</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
{CATEGORIES.map((cat) => (
|
|
<button
|
|
key={cat}
|
|
type="button"
|
|
onClick={() => toggleCategory(cat)}
|
|
className={`px-3 py-1 text-xs rounded-full border transition-colors ${
|
|
form.categories.includes(cat)
|
|
? 'bg-blue-600 text-white border-blue-600'
|
|
: 'border-slate-200 text-slate-600 hover:bg-slate-50'
|
|
}`}
|
|
>
|
|
{t.categoryLabels[cat]}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Vehicle picker */}
|
|
<div>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<p className="text-xs font-medium text-slate-600">{t.labelVehicles}</p>
|
|
{form.vehicleIds.length > 0 && (
|
|
<span className="badge-blue">{t.selected(form.vehicleIds.length)}</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Search input */}
|
|
<div className="relative mb-2">
|
|
<svg className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-slate-400 pointer-events-none" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-4.35-4.35M17 11A6 6 0 111 11a6 6 0 0116 0z" />
|
|
</svg>
|
|
<input
|
|
className="input-field pl-8 text-xs py-1.5"
|
|
value={vehicleSearch}
|
|
onChange={(e) => setVehicleSearch(e.target.value)}
|
|
placeholder={t.labelVehicleSearch}
|
|
/>
|
|
</div>
|
|
|
|
{/* Vehicle list */}
|
|
<div className="max-h-52 overflow-y-auto rounded-lg border border-slate-200 divide-y divide-slate-100">
|
|
{loadingFleet ? (
|
|
<p className="px-4 py-6 text-center text-xs text-slate-400">{t.loadingVehicles}</p>
|
|
) : filteredFleet.length === 0 ? (
|
|
<p className="px-4 py-6 text-center text-xs text-slate-400">{t.noVehiclesFound}</p>
|
|
) : (
|
|
filteredFleet.map((v) => {
|
|
const checked = form.vehicleIds.includes(v.id)
|
|
return (
|
|
<label
|
|
key={v.id}
|
|
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${
|
|
checked ? 'bg-blue-50' : 'hover:bg-slate-50'
|
|
}`}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
className="h-4 w-4 shrink-0 rounded border-slate-300 accent-blue-600"
|
|
checked={checked}
|
|
onChange={() => toggleVehicle(v.id)}
|
|
/>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-sm font-medium text-slate-800 truncate">
|
|
{v.year} {v.make} {v.model}
|
|
</p>
|
|
<p className="text-xs text-slate-400">{v.licensePlate} · {t.categoryLabels[v.category as Category] ?? v.category}</p>
|
|
</div>
|
|
</label>
|
|
)
|
|
})
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Terms */}
|
|
<div>
|
|
<label className="block text-xs font-medium text-slate-600 mb-1">{t.labelTerms}</label>
|
|
<textarea
|
|
className="input-field resize-none"
|
|
rows={2}
|
|
value={form.termsAndConds}
|
|
onChange={(e) => setForm((f) => ({ ...f, termsAndConds: e.target.value }))}
|
|
placeholder={t.placeholderTerms}
|
|
/>
|
|
</div>
|
|
|
|
{/* Toggles */}
|
|
<div className="flex flex-wrap gap-x-6 gap-y-2">
|
|
{([
|
|
{ key: 'isActive', label: t.labelIsActive },
|
|
{ key: 'isPublic', label: t.labelIsPublic },
|
|
{ key: 'isFeatured', label: t.labelIsFeatured },
|
|
] as const).map(({ key, label }) => (
|
|
<label key={key} className="flex items-center gap-2 text-sm text-slate-700 cursor-pointer select-none">
|
|
<input
|
|
type="checkbox"
|
|
className="h-4 w-4 rounded border-slate-300 accent-blue-600"
|
|
checked={form[key] as boolean}
|
|
onChange={(e) => setForm((f) => ({ ...f, [key]: e.target.checked }))}
|
|
/>
|
|
{label}
|
|
</label>
|
|
))}
|
|
</div>
|
|
|
|
{formError && <p className="text-sm text-red-600">{formError}</p>}
|
|
|
|
<div className={`flex gap-3 pt-2 ${isRtl ? 'flex-row-reverse' : 'justify-end'}`}>
|
|
<button type="button" onClick={closeModal} className="btn-secondary">{t.cancel}</button>
|
|
<button type="submit" disabled={saving} className="btn-primary">
|
|
{saving ? t.saving : t.save}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Delete confirmation */}
|
|
{deleteTarget && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-blue-950/50 p-4">
|
|
<div className="card p-6 w-full max-w-sm" dir={isRtl ? 'rtl' : 'ltr'}>
|
|
<h3 className="text-base font-semibold text-slate-900 mb-2">{t.deleteOffer}</h3>
|
|
<p className="text-sm text-slate-600 mb-5">{t.confirmDelete}</p>
|
|
{formError && <p className="text-sm text-red-600 mb-3">{formError}</p>}
|
|
<div className={`flex gap-3 ${isRtl ? 'flex-row-reverse' : 'justify-end'}`}>
|
|
<button type="button" onClick={() => setDeleteTarget(null)} className="btn-secondary">{t.cancel}</button>
|
|
<button type="button" onClick={handleDelete} disabled={deleting} className="btn-danger">
|
|
{deleting ? t.deleting : t.delete}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|