All checks were successful
Deploy / deploy (push) Successful in 12s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
343 lines
13 KiB
JavaScript
343 lines
13 KiB
JavaScript
import { useState, useEffect, useContext, useRef } from 'react'
|
|
import { Tag, Plus, Edit2, Trash2, Upload, Image } from 'lucide-react'
|
|
import { api } from '../utils/api'
|
|
import { useLanguage } from '../i18n/LanguageContext'
|
|
import { useAuth } from '../contexts/AuthContext'
|
|
import { AppContext } from '../App'
|
|
import Modal from '../components/Modal'
|
|
import { SkeletonCard } from '../components/SkeletonLoader'
|
|
|
|
const API_BASE = '/api'
|
|
|
|
const EMPTY_BRAND = { name: '', name_ar: '', priority: 2, icon: '' }
|
|
|
|
export default function Brands() {
|
|
const { t, lang } = useLanguage()
|
|
const { user } = useAuth()
|
|
const { getBrandName } = useContext(AppContext)
|
|
const isSuperadminOrManager = user?.role === 'superadmin' || user?.role === 'manager'
|
|
|
|
const [brands, setBrands] = useState([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [showModal, setShowModal] = useState(false)
|
|
const [editingBrand, setEditingBrand] = useState(null)
|
|
const [brandForm, setBrandForm] = useState(EMPTY_BRAND)
|
|
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
|
const [brandToDelete, setBrandToDelete] = useState(null)
|
|
const [uploading, setUploading] = useState(false)
|
|
const fileInputRef = useRef(null)
|
|
|
|
useEffect(() => {
|
|
loadBrands()
|
|
}, [])
|
|
|
|
const loadBrands = async () => {
|
|
try {
|
|
const data = await api.get('/brands')
|
|
setBrands(Array.isArray(data) ? data : [])
|
|
} catch (err) {
|
|
console.error('Failed to load brands:', err)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
const openNewBrand = () => {
|
|
setEditingBrand(null)
|
|
setBrandForm(EMPTY_BRAND)
|
|
setShowModal(true)
|
|
}
|
|
|
|
const openEditBrand = (brand) => {
|
|
setEditingBrand(brand)
|
|
setBrandForm({
|
|
name: brand.name || '',
|
|
name_ar: brand.name_ar || '',
|
|
priority: brand.priority ?? 2,
|
|
icon: brand.icon || '',
|
|
})
|
|
setShowModal(true)
|
|
}
|
|
|
|
const saveBrand = async () => {
|
|
try {
|
|
const data = {
|
|
name: brandForm.name,
|
|
name_ar: brandForm.name_ar || null,
|
|
priority: brandForm.priority ? Number(brandForm.priority) : 2,
|
|
icon: brandForm.icon || null,
|
|
}
|
|
if (editingBrand) {
|
|
await api.patch(`/brands/${editingBrand.Id || editingBrand._id || editingBrand.id}`, data)
|
|
} else {
|
|
await api.post('/brands', data)
|
|
}
|
|
setShowModal(false)
|
|
setEditingBrand(null)
|
|
loadBrands()
|
|
} catch (err) {
|
|
console.error('Failed to save brand:', err)
|
|
}
|
|
}
|
|
|
|
const confirmDelete = async () => {
|
|
if (!brandToDelete) return
|
|
try {
|
|
await api.delete(`/brands/${brandToDelete.Id || brandToDelete._id || brandToDelete.id}`)
|
|
setBrandToDelete(null)
|
|
setShowDeleteModal(false)
|
|
loadBrands()
|
|
} catch (err) {
|
|
console.error('Failed to delete brand:', err)
|
|
}
|
|
}
|
|
|
|
const handleLogoUpload = async (brand, file) => {
|
|
const brandId = brand.Id || brand._id || brand.id
|
|
setUploading(true)
|
|
try {
|
|
const formData = new FormData()
|
|
formData.append('file', file)
|
|
const res = await fetch(`${API_BASE}/brands/${brandId}/logo`, {
|
|
method: 'POST',
|
|
body: formData,
|
|
credentials: 'include',
|
|
})
|
|
if (!res.ok) throw new Error('Upload failed')
|
|
loadBrands()
|
|
} catch (err) {
|
|
console.error('Failed to upload logo:', err)
|
|
} finally {
|
|
setUploading(false)
|
|
}
|
|
}
|
|
|
|
const getBrandId = (brand) => brand.Id || brand._id || brand.id
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="h-12 bg-surface-tertiary rounded-xl animate-pulse"></div>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{[...Array(3)].map((_, i) => <SkeletonCard key={i} />)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6 animate-fade-in">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-sm text-text-tertiary">{t('brands.manageBrands')}</p>
|
|
{isSuperadminOrManager && (
|
|
<button
|
|
onClick={openNewBrand}
|
|
className="flex items-center gap-2 px-4 py-2.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm transition-colors"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
{t('brands.addBrand')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Brand Cards Grid */}
|
|
{brands.length === 0 ? (
|
|
<div className="bg-white rounded-xl border border-border py-16 text-center">
|
|
<Tag className="w-12 h-12 text-text-quaternary mx-auto mb-3" />
|
|
<p className="text-sm text-text-tertiary">{t('brands.noBrands')}</p>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 stagger-children">
|
|
{brands.map(brand => {
|
|
const displayName = lang === 'ar' && brand.name_ar ? brand.name_ar : brand.name
|
|
return (
|
|
<div
|
|
key={getBrandId(brand)}
|
|
className={`bg-white rounded-xl border border-border overflow-hidden hover:shadow-md transition-all aspect-square flex flex-col ${isSuperadminOrManager ? 'cursor-pointer' : ''}`}
|
|
onClick={() => isSuperadminOrManager && openEditBrand(brand)}
|
|
>
|
|
{/* Logo area */}
|
|
<div className="flex-1 bg-surface-secondary flex items-center justify-center relative min-h-0">
|
|
{brand.logo ? (
|
|
<img
|
|
src={`${API_BASE}/uploads/${brand.logo}`}
|
|
alt={displayName}
|
|
className="w-full h-full object-contain p-4"
|
|
/>
|
|
) : (
|
|
<div className="text-3xl">
|
|
{brand.icon || <Image className="w-10 h-10 text-text-quaternary" />}
|
|
</div>
|
|
)}
|
|
{isSuperadminOrManager && (
|
|
<div className="absolute top-1.5 right-1.5 flex gap-1 opacity-0 group-hover:opacity-100" onClick={e => e.stopPropagation()}>
|
|
<button
|
|
onClick={() => openEditBrand(brand)}
|
|
className="p-1 bg-white/90 hover:bg-white rounded-md text-text-tertiary hover:text-text-primary shadow-sm"
|
|
title={t('common.edit')}
|
|
>
|
|
<Edit2 className="w-3 h-3" />
|
|
</button>
|
|
<button
|
|
onClick={() => { setBrandToDelete(brand); setShowDeleteModal(true) }}
|
|
className="p-1 bg-white/90 hover:bg-white rounded-md text-text-tertiary hover:text-red-500 shadow-sm"
|
|
title={t('common.delete')}
|
|
>
|
|
<Trash2 className="w-3 h-3" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Card body */}
|
|
<div className="p-3">
|
|
<div className="flex items-center gap-1.5 mb-0.5">
|
|
{brand.icon && <span className="text-sm">{brand.icon}</span>}
|
|
<h3 className="text-xs font-semibold text-text-primary truncate">{displayName}</h3>
|
|
</div>
|
|
<p className="text-[10px] text-text-tertiary truncate">
|
|
{brand.name_ar && lang !== 'ar' ? brand.name_ar : brand.name}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Create/Edit Modal */}
|
|
<Modal
|
|
isOpen={showModal}
|
|
onClose={() => { setShowModal(false); setEditingBrand(null) }}
|
|
title={editingBrand ? t('brands.editBrand') : t('brands.addBrand')}
|
|
>
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-text-primary mb-1">{t('brands.brandName')} *</label>
|
|
<input
|
|
type="text"
|
|
value={brandForm.name}
|
|
onChange={e => setBrandForm(f => ({ ...f, name: e.target.value }))}
|
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
|
placeholder="Brand name in English"
|
|
dir="ltr"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-text-primary mb-1">{t('brands.brandNameAr')}</label>
|
|
<input
|
|
type="text"
|
|
value={brandForm.name_ar}
|
|
onChange={e => setBrandForm(f => ({ ...f, name_ar: e.target.value }))}
|
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
|
placeholder="اسم العلامة بالعربي"
|
|
dir="rtl"
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-text-primary mb-1">{t('brands.brandPriority')}</label>
|
|
<input
|
|
type="number"
|
|
value={brandForm.priority}
|
|
onChange={e => setBrandForm(f => ({ ...f, priority: e.target.value }))}
|
|
min="1"
|
|
max="10"
|
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-text-primary mb-1">{t('brands.brandIcon')}</label>
|
|
<input
|
|
type="text"
|
|
value={brandForm.icon}
|
|
onChange={e => setBrandForm(f => ({ ...f, icon: e.target.value }))}
|
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
|
placeholder="emoji"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Logo upload — only for existing brands */}
|
|
{editingBrand && (
|
|
<div>
|
|
<label className="block text-sm font-medium text-text-primary mb-1">{t('brands.logo')}</label>
|
|
{editingBrand.logo && (
|
|
<div className="mb-2 p-2 bg-surface-secondary rounded-lg inline-block">
|
|
<img
|
|
src={`${API_BASE}/uploads/${editingBrand.logo}`}
|
|
alt="Logo"
|
|
className="h-16 object-contain"
|
|
/>
|
|
</div>
|
|
)}
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept="image/*"
|
|
className="hidden"
|
|
onChange={e => {
|
|
const file = e.target.files?.[0]
|
|
if (file) handleLogoUpload(editingBrand, file)
|
|
e.target.value = ''
|
|
}}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
disabled={uploading}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium border border-border rounded-lg hover:bg-surface-secondary transition-colors disabled:opacity-50"
|
|
>
|
|
<Upload className="w-3.5 h-3.5" />
|
|
{uploading ? t('common.loading') : editingBrand.logo ? t('brands.changeLogo') : t('brands.uploadLogo')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-center justify-between pt-4 border-t border-border">
|
|
{editingBrand && isSuperadminOrManager ? (
|
|
<button
|
|
onClick={() => { setShowModal(false); setBrandToDelete(editingBrand); setShowDeleteModal(true) }}
|
|
className="px-3 py-2 text-sm font-medium text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
|
>
|
|
{t('common.delete')}
|
|
</button>
|
|
) : <div />}
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
onClick={() => { setShowModal(false); setEditingBrand(null) }}
|
|
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
|
|
>
|
|
{t('common.cancel')}
|
|
</button>
|
|
<button
|
|
onClick={saveBrand}
|
|
disabled={!brandForm.name}
|
|
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
|
|
>
|
|
{t('common.save')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
|
|
{/* Delete Confirmation */}
|
|
<Modal
|
|
isOpen={showDeleteModal}
|
|
onClose={() => { setShowDeleteModal(false); setBrandToDelete(null) }}
|
|
title={t('brands.deleteBrand')}
|
|
isConfirm
|
|
danger
|
|
confirmText={t('common.delete')}
|
|
onConfirm={confirmDelete}
|
|
>
|
|
{t('brands.deleteBrandConfirm')}
|
|
</Modal>
|
|
</div>
|
|
)
|
|
}
|