fix signin signout and apply the new style
This commit is contained in:
@@ -0,0 +1,797 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import dayjs from 'dayjs'
|
||||
import { formatCurrency } 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 marketplace.',
|
||||
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',
|
||||
marketplace: 'Marketplace',
|
||||
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 marketplace',
|
||||
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 marketplace.',
|
||||
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',
|
||||
marketplace: 'Marketplace',
|
||||
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 marketplace',
|
||||
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: 'غير نشط',
|
||||
marketplace: 'السوق',
|
||||
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']): string {
|
||||
if (offer.type === 'PERCENTAGE') return `${offer.discountValue}%`
|
||||
if (offer.type === 'FIXED_AMOUNT') return formatCurrency(offer.discountValue, 'MAD')
|
||||
if (offer.type === 'FREE_DAY') return t.freeDay
|
||||
if (offer.type === 'SPECIAL_RATE') return `${offer.specialRate ?? offer.discountValue} MAD/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)}</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.marketplace}</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-[#07101e]/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-[#07101e]/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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user