freshyo/apps/admin-web/src/routes/dashboard.banners.tsx
2026-09-06 23:05:20 +05:30

432 lines
16 KiB
TypeScript

import React, { useState } from 'react'
import { createFileRoute, Outlet, useLocation, useNavigate } from '@tanstack/react-router'
import { AppContainer, p as MyText } from 'web-components'
import { trpc } from '@/lib/trpc-client'
import { ErrorToast } from '@/services/toaster'
import { useManualRefresh } from '@/lib/refresh-context'
import { Image as ImageIcon, Plus, Pencil, Trash2, CircleCheck, CircleDot, Hourglass, X } from 'lucide-react'
import type { Banner as SharedBanner } from '@packages/shared'
export const Route = createFileRoute('/dashboard/banners')({
component: DashboardBanners,
})
// Serialized banner row (API JSON carries string dates) anchored to the shared shape
type Banner = Omit<SharedBanner, 'createdAt' | 'lastUpdated'> & {
createdAt: string,
lastUpdated: string
}
function DashboardBanners() {
const location = useLocation()
const navigate = useNavigate()
const [refreshing, setRefreshing] = useState(false)
// Edit mode state
const [editMode, setEditMode] = useState(false)
const [selectedSlot, setSelectedSlot] = useState<number | null>(null)
const [slotAssignments, setSlotAssignments] = useState<{ [slot: number]: number | null }>({})
const [originalAssignments, setOriginalAssignments] = useState<{ [slot: number]: number | null }>({})
const [saving, setSaving] = useState(false)
// Real API calls
const { data: bannersData, isLoading, error, refetch } = trpc.admin.banner.getBanners.useQuery()
const deleteBannerMutation = trpc.admin.banner.deleteBanner.useMutation()
const updateBannerMutation = trpc.admin.banner.updateBanner.useMutation()
const emptySlotMutation = trpc.admin.banner.updateBanner.useMutation()
const banners = bannersData?.banners || []
// Initialize slot assignments when banners load
React.useEffect(() => {
const assignments: { [slot: number]: number | null } = { 1: null, 2: null, 3: null, 4: null }
banners.forEach((banner) => {
if (banner.serialNum && banner.serialNum >= 1 && banner.serialNum <= 4) {
assignments[banner.serialNum] = banner.id
}
})
setSlotAssignments(assignments)
setOriginalAssignments({ ...assignments })
}, [bannersData])
const onRefresh = async () => {
setRefreshing(true)
await refetch()
setRefreshing(false)
}
useManualRefresh(() => {
onRefresh()
})
if (location.pathname !== '/dashboard/banners') {
return <Outlet />
}
// Slot and edit mode handlers
const handleSlotClick = (slotNumber: number) => {
setSelectedSlot(slotNumber)
setEditMode(true)
}
const handleBannerSelect = (bannerId: number) => {
if (!editMode || selectedSlot === null) return
// Remove banner from any existing slot
const newAssignments = { ...slotAssignments }
Object.keys(newAssignments).forEach((slot) => {
if (newAssignments[parseInt(slot)] === bannerId) {
newAssignments[parseInt(slot)] = null
}
})
// Assign banner to selected slot
newAssignments[selectedSlot] = bannerId
setSlotAssignments(newAssignments)
}
const handleSave = async () => {
setSaving(true)
try {
// Get banners that need to be updated
const bannersToUpdate: { id: number, serialNum: number | null }[] = []
// Clear serial numbers for banners no longer in slots
banners.forEach((banner) => {
const currentSlot = banner.serialNum
const assignedSlot = Object.keys(slotAssignments).find((slot) =>
slotAssignments[parseInt(slot)] === banner.id
)
if (currentSlot !== (assignedSlot ? parseInt(assignedSlot) : null)) {
bannersToUpdate.push({
id: banner.id,
serialNum: assignedSlot ? parseInt(assignedSlot) : null
})
}
})
// Update banners that gained slots
Object.keys(slotAssignments).forEach((slot) => {
const slotNum = parseInt(slot)
const bannerId = slotAssignments[slotNum]
if (bannerId) {
const banner = banners.find((b) => b.id === bannerId)
if (banner && banner.serialNum !== slotNum) {
bannersToUpdate.push({
id: bannerId,
serialNum: slotNum
})
}
}
})
// Execute updates
await Promise.all(
bannersToUpdate.map(({ id, serialNum }) =>
updateBannerMutation.mutateAsync({ id, serialNum })
)
)
setOriginalAssignments({ ...slotAssignments })
setEditMode(false)
setSelectedSlot(null)
await refetch()
window.alert('Success: Slot assignments saved successfully')
} catch (err) {
ErrorToast('Failed to save slot assignments')
} finally {
setSaving(false)
}
}
const handleCancel = () => {
setSlotAssignments({ ...originalAssignments })
setEditMode(false)
setSelectedSlot(null)
}
const handleEmptySlot = async () => {
if (!selectedSlot || !slotAssignments[selectedSlot]) return
const bannerId = slotAssignments[selectedSlot]
const banner = banners.find((b) => b.id === bannerId)
if (!banner) return
try {
// Update banner's serialNum to null to empty the slot
await emptySlotMutation.mutateAsync({
id: banner.id,
serialNum: null
})
// Update local state
setSlotAssignments((prev) => ({
...prev,
[selectedSlot]: null
}))
// Update original assignments for cancel functionality
setOriginalAssignments((prev) => ({
...prev,
[selectedSlot]: null
}))
window.alert(`Success: Slot ${selectedSlot} has been emptied`)
} catch (err) {
ErrorToast('Failed to empty slot')
}
}
// Helper function to get banner name by ID
const getBannerNameById = (id: number | null) => {
if (!id) return null
const banner = banners.find((b) => b.id === id)
return banner ? banner.name : null
}
const handleEdit = (banner: Banner) => {
navigate({ to: '/dashboard/banners/$id/edit', params: { id: String(banner.id) } })
}
const handleDelete = (id: number) => {
if (!window.confirm('Delete Banner: Are you sure you want to delete this banner?')) return
;(async () => {
try {
await deleteBannerMutation.mutateAsync({ id })
refetch()
window.alert('Success: Banner deleted')
} catch (err) {
ErrorToast('Failed to delete banner')
}
})()
}
const handleCreate = () => {
navigate({ to: '/dashboard/banners/new' })
}
if (isLoading) {
return (
<AppContainer>
<div className="flex flex-1 flex-col items-center justify-center bg-white">
<MyText className="text-gray-600">Loading banners...</MyText>
</div>
</AppContainer>
)
}
if (error) {
return (
<AppContainer>
<div className="flex flex-1 flex-col items-center justify-center bg-white">
<MyText className="text-red-600">Error loading banners</MyText>
</div>
</AppContainer>
)
}
return (
<AppContainer>
<div className="relative flex-1 bg-gray-50">
<div className="max-h-[calc(100vh-160px)] flex-1 overflow-y-auto pb-24">
{/* Slots Row */}
<div className="mb-6 px-4">
<MyText className="mb-3 block text-sm font-medium text-gray-700">
{editMode ? `Select banner for Slot ${selectedSlot}` : 'Banner Slots'}
</MyText>
<div className="flex flex-row justify-between">
{[1, 2, 3, 4].map((slotNum) => {
const assignedBannerId = slotAssignments[slotNum]
const bannerName = getBannerNameById(assignedBannerId)
const isSelected = editMode && selectedSlot === slotNum
return (
<button
key={slotNum}
onClick={() => handleSlotClick(slotNum)}
className={`mx-1 flex-1 rounded-lg border-2 p-3 ${isSelected
? 'border-blue-500 bg-blue-50'
: assignedBannerId
? 'border-green-300 bg-green-50'
: 'border-gray-300 bg-white'
}`}
>
<span className="items-center">
<MyText className="mb-1 block text-xs text-gray-500">Slot {slotNum}</MyText>
<MyText
className={`block text-center text-xs font-medium ${assignedBannerId ? 'text-gray-900' : 'text-gray-400'
}`}
numberOfLines={2}
>
{bannerName || 'Empty'}
</MyText>
</span>
</button>
)
})}
</div>
{/* Action buttons in edit mode */}
{editMode && (
<div className="mt-4 gap-3">
{/* Save/Cancel buttons */}
<div className="flex flex-row gap-3">
<button
onClick={handleCancel}
disabled={saving}
className={`flex-1 items-center rounded-lg bg-gray-500 px-4 py-3 ${saving ? 'opacity-50' : ''
}`}
>
<MyText className="font-semibold text-white">Cancel</MyText>
</button>
<button
onClick={handleSave}
disabled={saving}
className={`flex-1 items-center rounded-lg bg-blue-600 px-4 py-3 ${saving ? 'opacity-50' : ''
}`}
>
<MyText className="font-semibold text-white">
{saving ? 'Saving...' : 'Save Changes'}
</MyText>
</button>
</div>
{/* Empty Slot button */}
<button
onClick={handleEmptySlot}
disabled={!slotAssignments[selectedSlot || 0] || emptySlotMutation.isPending}
className={`mt-3 w-full items-center rounded-lg bg-red-500 px-4 py-3 ${(!slotAssignments[selectedSlot || 0] || emptySlotMutation.isPending) ? 'opacity-50' : ''
}`}
>
<span className="flex flex-row items-center justify-center">
{emptySlotMutation.isPending ? (
<Hourglass size={16} color="white" />
) : (
<X size={16} color="white" />
)}
<MyText className="ml-2 font-semibold text-white">
{emptySlotMutation.isPending ? 'Emptying...' : 'Empty Slot'}
</MyText>
</span>
</button>
</div>
)}
</div>
{/* Banners List */}
<div className="px-4 pb-8">
{editMode && (
<div className="mb-4 rounded-lg border border-blue-200 bg-blue-50 p-3">
<MyText className="block text-center text-sm text-blue-800">
Tap a banner below to assign it to Slot {selectedSlot}
</MyText>
</div>
)}
{banners.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center py-8">
<span className="mb-6 flex h-24 w-24 items-center justify-center rounded-full bg-slate-50">
<ImageIcon size={48} color="#94A3B8" />
</span>
<MyText className="block text-xl font-black tracking-tight text-slate-900">No Banners Yet</MyText>
<MyText className="mt-2 block px-8 text-center font-medium text-slate-500">
Start by creating your first banner using the button above.
</MyText>
</div>
) : (
banners.map((banner) => {
const isAssignedToSlot = Object.values(slotAssignments).includes(banner.id)
const isAssignedToSelectedSlot = slotAssignments[selectedSlot || 0] === banner.id
const canInteract = editMode
return (
<div
key={banner.id}
onClick={canInteract ? () => handleBannerSelect(banner.id) : undefined}
role={canInteract ? 'button' : undefined}
tabIndex={canInteract ? 0 : undefined}
onKeyDown={canInteract ? (e) => { if (e.key === 'Enter') handleBannerSelect(banner.id) } : undefined}
className={`mb-3 w-full rounded-xl border bg-white p-4 text-left shadow-sm ${canInteract ? 'cursor-pointer' : ''} ${canInteract && isAssignedToSelectedSlot
? 'border-blue-500 bg-blue-50'
: canInteract && isAssignedToSlot
? 'border-green-300 bg-green-50'
: canInteract
? 'border-gray-200'
: 'border-gray-100'
}`}
>
<span className="flex flex-row items-center">
<img
src={banner.imageUrl}
className="mr-3 h-16 w-16 rounded-lg object-cover"
alt={banner.name}
/>
<span className="flex-1">
<span className="mb-1 flex flex-row items-center justify-between">
<MyText className="font-semibold text-gray-900" numberOfLines={1}>
{banner.name}
</MyText>
<span className="flex flex-row items-center">
{canInteract && isAssignedToSelectedSlot && (
<span className="mr-2">
<CircleCheck size={16} color="#3B82F6" />
</span>
)}
{canInteract && isAssignedToSlot && !isAssignedToSelectedSlot && (
<span className="mr-2">
<CircleDot size={16} color="#10B981" />
</span>
)}
<span className={`mr-2 h-2 w-2 rounded-full ${banner.isActive ? 'bg-green-500' : 'bg-gray-400'
}`} />
{!editMode && (
<>
<button onClick={(e) => { e.stopPropagation(), handleEdit(banner as Banner) }} className="mr-1 p-1" aria-label="Edit banner">
<Pencil size={16} color="#64748B" />
</button>
<button onClick={(e) => { e.stopPropagation(), handleDelete(banner.id) }} className="p-1" aria-label="Delete banner">
<Trash2 size={16} color="#EF4444" />
</button>
</>
)}
</span>
</span>
{banner.description && (
<MyText className="mt-1 block text-sm text-gray-500" numberOfLines={2}>
{banner.description}
</MyText>
)}
<span className="mt-2 flex flex-row items-center justify-between">
<MyText className="text-xs text-gray-400">
Created: {new Date(banner.createdAt).toLocaleDateString()}
</MyText>
{banner.serialNum && banner.serialNum >= 1 && banner.serialNum <= 4 && (
<MyText className="text-xs font-medium text-blue-600">
Slot {banner.serialNum}
</MyText>
)}
</span>
</span>
</span>
</div>
)
})
)}
</div>
</div>
{/* Floating Action Button */}
<button
onClick={handleCreate}
className="fixed bottom-6 right-6 flex h-14 w-14 items-center justify-center rounded-full bg-blue-600 shadow-lg"
aria-label="Add new banner"
>
<Plus size={24} color="white" />
</button>
</div>
</AppContainer>
)
}