feat: home page banner crud #46

Merged
kailasdevdas merged 2 commits from feat/homepage-banner into dev 2026-06-16 07:24:44 +00:00
12 changed files with 1093 additions and 13 deletions
@@ -0,0 +1,22 @@
-- CreateEnum
CREATE TYPE "BannerMediaType" AS ENUM ('IMAGE', 'VIDEO');
-- CreateTable
CREATE TABLE "HomepageBanner" (
"id" SERIAL NOT NULL,
"title" TEXT,
"subtitle" TEXT,
"mediaType" "BannerMediaType" NOT NULL,
"desktopMediaUrl" TEXT NOT NULL,
"mobileMediaUrl" TEXT,
"buttonText" TEXT,
"buttonLink" TEXT,
"openInNewTab" BOOLEAN NOT NULL DEFAULT false,
"textAlignment" TEXT DEFAULT 'left',
"sortOrder" INTEGER NOT NULL DEFAULT 1000,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "HomepageBanner_pkey" PRIMARY KEY ("id")
);
+30
View File
@@ -314,3 +314,33 @@ model Seo {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
model HomepageBanner {
id Int @id @default(autoincrement())
title String?
subtitle String?
mediaType BannerMediaType
desktopMediaUrl String
mobileMediaUrl String?
buttonText String?
buttonLink String?
openInNewTab Boolean @default(false)
textAlignment String? @default("left")
sortOrder Int @default(1000)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
enum BannerMediaType {
IMAGE
VIDEO
}
+2
View File
@@ -16,6 +16,7 @@ import emailConfigRoutes from './routes/emailConfig.routes.js';
import newsMediaRoutes from './routes/newsMedia.routes.js'; import newsMediaRoutes from './routes/newsMedia.routes.js';
import importRoutes from './routes/importRoutes.js'; import importRoutes from './routes/importRoutes.js';
import healthCheckRoutes from './routes/healthCheck.route.js'; import healthCheckRoutes from './routes/healthCheck.route.js';
import homepageBannerRoutes from './routes/homepageBanner.routes.js';
dotenv.config(); dotenv.config();
@@ -57,6 +58,7 @@ app.use('/api/email', emailConfigRoutes);
app.use('/api/newsMedia', newsMediaRoutes); app.use('/api/newsMedia', newsMediaRoutes);
app.use('/api/import', importRoutes); app.use('/api/import', importRoutes);
app.use('/api/health-check', healthCheckRoutes); app.use('/api/health-check', healthCheckRoutes);
app.use('/api/homepage-banners', homepageBannerRoutes);
const PORT = process.env.PORT || 5008; const PORT = process.env.PORT || 5008;
app.listen(PORT, () => { app.listen(PORT, () => {
@@ -0,0 +1,203 @@
import prisma from '../prisma/client.js';
export const createHomepageBanner = async (req, res) => {
try {
const {
title,
subtitle,
mediaType,
desktopMediaUrl,
mobileMediaUrl,
buttonText,
buttonLink,
openInNewTab,
textAlignment,
sortOrder,
isActive,
} = req.body;
if (!mediaType || !desktopMediaUrl) {
return res.status(400).json({
success: false,
message: 'Media type and desktop media URL are required',
});
}
const banner = await prisma.homepageBanner.create({
data: {
title,
subtitle,
mediaType,
desktopMediaUrl,
mobileMediaUrl,
buttonText,
buttonLink,
openInNewTab,
textAlignment,
sortOrder,
isActive,
},
});
res.status(201).json({
success: true,
data: banner,
message: 'Homepage banner created successfully',
});
} catch (error) {
console.error(error);
res.status(500).json({
success: false,
message: 'Failed to create homepage banner',
});
}
};
export const getHomepageBanners = async (req, res) => {
try {
const banners = await prisma.homepageBanner.findMany({
orderBy: {
sortOrder: 'asc',
},
});
res.json({
success: true,
data: banners,
});
} catch (error) {
console.error(error);
res.status(500).json({
success: false,
message: 'Failed to fetch homepage banners',
});
}
};
export const getActiveHomepageBanners = async (req, res) => {
try {
const banners = await prisma.homepageBanner.findMany({
where: {
isActive: true,
},
orderBy: {
sortOrder: 'asc',
},
});
res.json({
success: true,
data: banners,
});
} catch (error) {
console.error(error);
res.status(500).json({
success: false,
message: 'Failed to fetch active homepage banners',
});
}
};
export const getHomepageBanner = async (req, res) => {
try {
const { id } = req.params;
const banner = await prisma.homepageBanner.findUnique({
where: {
id: Number(id),
},
});
if (!banner) {
return res.status(404).json({
success: false,
message: 'Homepage banner not found',
});
}
res.json({
success: true,
data: banner,
});
} catch (error) {
console.error(error);
res.status(500).json({
success: false,
message: 'Failed to fetch homepage banner',
});
}
};
export const updateHomepageBanner = async (req, res) => {
try {
const { id } = req.params;
const {
title,
subtitle,
mediaType,
desktopMediaUrl,
mobileMediaUrl,
buttonText,
buttonLink,
openInNewTab,
textAlignment,
sortOrder,
isActive,
} = req.body;
const banner = await prisma.homepageBanner.update({
where: {
id: Number(id),
},
data: {
title,
subtitle,
mediaType,
desktopMediaUrl,
mobileMediaUrl,
buttonText,
buttonLink,
openInNewTab,
textAlignment,
sortOrder,
isActive,
},
});
res.json({
success: true,
data: banner,
message: 'Homepage banner updated successfully',
});
} catch (error) {
console.error(error);
res.status(500).json({
success: false,
message: 'Failed to update homepage banner',
});
}
};
export const deleteHomepageBanner = async (req, res) => {
try {
const { id } = req.params;
await prisma.homepageBanner.delete({
where: {
id: Number(id),
},
});
res.json({
success: true,
message: 'Homepage banner deleted successfully',
});
} catch (error) {
console.error(error);
res.status(500).json({
success: false,
message: 'Failed to delete homepage banner',
});
}
};
@@ -0,0 +1,27 @@
import express from 'express';
import {
createHomepageBanner,
getHomepageBanners,
getActiveHomepageBanners,
getHomepageBanner,
updateHomepageBanner,
deleteHomepageBanner,
} from '../controllers/homepageBanner.controller.js';
import jwtAuthMiddleware from '../middleware/auth.js';
const router = express.Router();
router.get('/active', getActiveHomepageBanners);
router.post('/', jwtAuthMiddleware, createHomepageBanner);
router.get('/getAll', jwtAuthMiddleware, getHomepageBanners);
router.get('/:id', jwtAuthMiddleware, getHomepageBanner);
router.put('/:id', jwtAuthMiddleware, updateHomepageBanner);
router.delete('/:id', jwtAuthMiddleware, deleteHomepageBanner);
export default router;
+2
View File
@@ -24,6 +24,7 @@ import NewsPage from './pages/newsMedia';
import BlogDetail from './pages/BlogDetails'; import BlogDetail from './pages/BlogDetails';
import ImportData from './pages/ImportData'; import ImportData from './pages/ImportData';
import HealthPackagePage from './pages/HealthPackagePage'; import HealthPackagePage from './pages/HealthPackagePage';
import HomepageBanner from './pages/HomepageBannerPage';
export default function App() { export default function App() {
return ( return (
@@ -53,6 +54,7 @@ export default function App() {
<Route path="/news" element={<NewsPage />} /> <Route path="/news" element={<NewsPage />} />
<Route path="/import" element={<ImportData />} /> <Route path="/import" element={<ImportData />} />
<Route path="/health-check" element={<HealthPackagePage />} /> <Route path="/health-check" element={<HealthPackagePage />} />
<Route path="/homepage-banner" element={<HomepageBanner />} />
</Route> </Route>
</Route> </Route>
+83
View File
@@ -0,0 +1,83 @@
import apiClient from '@/api/client';
import toast from 'react-hot-toast';
export type BannerMediaType = 'IMAGE' | 'VIDEO';
export interface HomepageBanner {
id?: number;
title?: string;
subtitle?: string;
mediaType: BannerMediaType;
desktopMediaUrl: string;
mobileMediaUrl?: string;
buttonText?: string;
buttonLink?: string;
openInNewTab: boolean;
textAlignment?: 'left' | 'center' | 'right';
sortOrder: number;
isActive: boolean;
createdAt?: string;
updatedAt?: string;
}
export const getHomepageBannersApi = async () => {
const res = await apiClient.get('/homepage-banners/getAll');
return res.data;
};
export const getHomepageBannerApi = async (id: number) => {
const res = await apiClient.get(`/homepage-banners/${id}`);
return res.data;
};
export const getActiveHomepageBannersApi = async () => {
const res = await apiClient.get('/homepage-banners/active');
return res.data;
};
export const createHomepageBannerApi = async (data: Partial<HomepageBanner>) => {
try {
const res = await apiClient.post('/homepage-banners', data);
toast.success('Banner created successfully');
return res.data;
} catch (error: any) {
toast.error(error?.response?.data?.message || 'Failed to create banner');
throw error;
}
};
export const updateHomepageBannerApi = async (id: number, data: Partial<HomepageBanner>) => {
try {
const res = await apiClient.put(`/homepage-banners/${id}`, data);
toast.success('Banner updated successfully');
return res.data;
} catch (error: any) {
toast.error(error?.response?.data?.message || 'Failed to update banner');
throw error;
}
};
export const deleteHomepageBannerApi = async (id: number) => {
try {
const res = await apiClient.delete(`/homepage-banners/${id}`);
toast.success('Banner deleted successfully');
return res.data;
} catch (error: any) {
toast.error(error?.response?.data?.message || 'Failed to delete banner');
throw error;
}
};
@@ -0,0 +1,263 @@
import { BytescaleUploader } from '@/components/BytescaleUploader/BytescaleUploader';
import { useEffect } from 'react';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
editingBanner: any;
bannerForm: any;
setBannerForm: any;
onSave: () => void;
}
export default function HomepageBannerModal({
open,
onOpenChange,
editingBanner,
bannerForm,
setBannerForm,
onSave,
}: Props) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-full !max-w-4xl h-[90vh] flex flex-col p-0 overflow-hidden">
<DialogHeader className="px-6 py-5 border-b bg-background sticky top-0 z-20">
<DialogTitle className="text-2xl font-bold">
{editingBanner ? 'Edit Homepage Banner' : 'Create Homepage Banner'}
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto p-6 space-y-8">
<div className="space-y-5">
<div className="border-b pb-2">
<h3 className="text-lg font-bold">Media Configuration</h3>
<p className="text-sm text-muted-foreground">Manage your banner files for desktop and mobile layouts</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label className="font-semibold">Media Type</Label>
<Select
value={bannerForm.mediaType}
onValueChange={(v) =>
setBannerForm({
...bannerForm,
mediaType: v,
})
}
>
<SelectTrigger>
<SelectValue placeholder="Select media type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="IMAGE">Image Asset</SelectItem>
<SelectItem value="VIDEO">Video Loop</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between border rounded-xl p-4 bg-muted/30">
<div>
<p className="font-semibold">Active Visibility</p>
<p className="text-sm text-muted-foreground">Publish this banner live on the homepage</p>
</div>
<Switch
checked={bannerForm.isActive}
onCheckedChange={(val) =>
setBannerForm({
...bannerForm,
isActive: val,
})
}
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label className="font-semibold">Desktop Media URL</Label>
<p className="text-xs text-muted-foreground">
{bannerForm.mediaType === 'VIDEO'
? 'Recommended: 1920 × 650 MP4 Format '
: 'Recommended: 1920 × 650 Widescreen'}
</p>
<BytescaleUploader
value={bannerForm.desktopMediaUrl || ''}
folderPath="/homepage-banners"
onChange={(url) =>
setBannerForm({
...bannerForm,
desktopMediaUrl: url,
})
}
/>
</div>
<div className="space-y-2">
<Label className="font-semibold">Mobile Media URL (Optional)</Label>
<p className="text-xs text-muted-foreground">
{bannerForm.mediaType === 'VIDEO' ? 'Recommended: 340 × 390 MP4 Format' : 'Recommended: 340 × 390 '}
</p>
<BytescaleUploader
value={bannerForm.mobileMediaUrl || ''}
folderPath="/homepage-banners"
onChange={(url) =>
setBannerForm({
...bannerForm,
mobileMediaUrl: url,
})
}
/>
</div>
</div>
</div>
<div className="space-y-5">
<div className="border-b pb-2">
<h3 className="text-lg font-bold">Banner Copy & Styles</h3>
<p className="text-sm text-muted-foreground">Modify text details and text alignment configurations</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="font-semibold">Main Heading / Title</Label>
<Input
value={bannerForm.title || ''}
placeholder="e.g., Advanced Healthcare, Exceptional Compassion"
onChange={(e) =>
setBannerForm({
...bannerForm,
title: e.target.value,
})
}
/>
</div>
<div className="space-y-2">
<Label className="font-semibold">Sub-Heading / Subtitle</Label>
<Input
value={bannerForm.subtitle || ''}
placeholder="e.g., Book appointments online with top multi-specialty doctors."
onChange={(e) =>
setBannerForm({
...bannerForm,
subtitle: e.target.value,
})
}
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="font-semibold">Text Alignment Alignment</Label>
<Select
value={bannerForm.textAlignment || 'left'}
onValueChange={(v) =>
setBannerForm({
...bannerForm,
textAlignment: v,
})
}
>
<SelectTrigger>
<SelectValue placeholder="Select text position" />
</SelectTrigger>
<SelectContent>
<SelectItem value="left">Left Aligned</SelectItem>
<SelectItem value="center">Center Aligned</SelectItem>
<SelectItem value="right">Right Aligned</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="font-semibold">Display Priority Order</Label>
<Input
type="number"
value={bannerForm.sortOrder}
onChange={(e) =>
setBannerForm({
...bannerForm,
sortOrder: Number(e.target.value),
})
}
/>
</div>
</div>
</div>
<div className="space-y-5">
<div className="border-b pb-2">
<h3 className="text-lg font-bold">Call To Action (CTA Button)</h3>
<p className="text-sm text-muted-foreground">Hyperlinks for optional button element overlays</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="font-semibold">Button Label</Label>
<Input
value={bannerForm.buttonText || ''}
placeholder="e.g., Find a Doctor"
onChange={(e) =>
setBannerForm({
...bannerForm,
buttonText: e.target.value,
})
}
/>
</div>
<div className="space-y-2">
<Label className="font-semibold">Button Redirect Link</Label>
<Input
value={bannerForm.buttonLink || ''}
placeholder="e.g., /doctors or https://..."
onChange={(e) =>
setBannerForm({
...bannerForm,
buttonLink: e.target.value,
})
}
/>
</div>
</div>
<div className="flex items-center justify-between border rounded-xl p-4 bg-muted/30">
<div>
<p className="font-semibold">Target Tab Redirection</p>
<p className="text-sm text-muted-foreground">
Force-launch the button link target into a completely new browser tab window
</p>
</div>
<Switch
checked={bannerForm.openInNewTab}
onCheckedChange={(val) =>
setBannerForm({
...bannerForm,
openInNewTab: val,
})
}
/>
</div>
</div>
</div>
<DialogFooter className="p-6 border-t bg-background sticky bottom-0 z-20">
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button className="px-10" onClick={onSave}>
{editingBanner ? 'Save Changes' : 'Create Banner'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -1,12 +1,20 @@
import { useState, useRef } from 'react'; import { useState, useRef } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { User, X, Loader2 } from 'lucide-react'; import { User, X, Loader2, Video } from 'lucide-react';
import axios from 'axios'; import axios from 'axios';
interface BytescaleUploaderProps { interface BytescaleUploaderProps {
value: string; value: string;
onChange: (url: string) => void; onChange: (url: string) => void;
folderPath: '/health-packages' | '/seo' | '/doctors' | '/departments' | '/news' | '/blog' | '/doctor-og'; folderPath:
| '/health-packages'
| '/seo'
| '/doctors'
| '/departments'
| '/news'
| '/blog'
| '/doctor-og'
| '/homepage-banners';
} }
export function BytescaleUploader({ value, onChange, folderPath }: BytescaleUploaderProps) { export function BytescaleUploader({ value, onChange, folderPath }: BytescaleUploaderProps) {
@@ -14,12 +22,19 @@ export function BytescaleUploader({ value, onChange, folderPath }: BytescaleUplo
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const isVideo = (url: string) => {
return /\.(mp4|webm|ogg)$/i.test(url);
};
const onFileSelected = async (event: React.ChangeEvent<HTMLInputElement>) => { const onFileSelected = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
if (!file) return; if (!file) return;
if (file.size > 5 * 1024 * 1024) { const maxSize = file.type.startsWith('video/') ? 10 * 1024 * 1024 : 5 * 1024 * 1024;
alert('File is too large (Max 5MB)');
if (file.size > maxSize) {
alert(file.type.startsWith('video/') ? 'Video is too large (Max 10MB)' : 'Image is too large (Max 5MB)');
return; return;
} }
@@ -44,7 +59,10 @@ export function BytescaleUploader({ value, onChange, folderPath }: BytescaleUplo
alert(`Upload Error: ${errorMessage}`); alert(`Upload Error: ${errorMessage}`);
} finally { } finally {
setIsUploading(false); setIsUploading(false);
if (fileInputRef.current) fileInputRef.current.value = '';
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
} }
}; };
@@ -54,11 +72,16 @@ export function BytescaleUploader({ value, onChange, folderPath }: BytescaleUplo
<div className="relative"> <div className="relative">
{value ? ( {value ? (
<> <>
<img {isVideo(value) ? (
src={value} <video src={value} className="w-20 h-20 rounded-md object-cover border-2 border-primary/20" controls />
className="w-16 h-16 rounded-full object-cover border-2 border-primary/20" ) : (
alt="Preview" <img
/> src={value}
className="w-16 h-16 rounded-full object-cover border-2 border-primary/20"
alt="Preview"
/>
)}
<button <button
type="button" type="button"
onClick={() => onChange('')} onClick={() => onChange('')}
@@ -82,7 +105,7 @@ export function BytescaleUploader({ value, onChange, folderPath }: BytescaleUplo
type="file" type="file"
ref={fileInputRef} ref={fileInputRef}
onChange={onFileSelected} onChange={onFileSelected}
accept="image/jpeg,image/png,image/webp" accept="image/jpeg,image/png,image/webp,video/mp4,video/webm,video/ogg"
className="hidden" className="hidden"
/> />
@@ -99,9 +122,9 @@ export function BytescaleUploader({ value, onChange, folderPath }: BytescaleUplo
Uploading... Uploading...
</> </>
) : value ? ( ) : value ? (
'Change Photo' 'Change File'
) : ( ) : (
'Upload Photo' 'Upload Image / Video'
)} )}
</Button> </Button>
</div> </div>
@@ -2,6 +2,8 @@ import { Link, useLocation } from 'react-router-dom';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { ChevronDown } from 'lucide-react';
export default function Sidebar() { export default function Sidebar() {
const location = useLocation(); const location = useLocation();
@@ -51,6 +53,15 @@ export default function Sidebar() {
name: 'Blog', name: 'Blog',
path: '/blog', path: '/blog',
}, },
{
name: 'Homepage Content',
children: [
{
name: 'Homepage Banner',
path: '/homepage-banner',
},
],
},
]; ];
return ( return (
@@ -63,6 +74,35 @@ export default function Sidebar() {
<nav className="p-4 space-y-2"> <nav className="p-4 space-y-2">
{navItems.map((item) => { {navItems.map((item) => {
if ('children' in item) {
const hasActiveChild = item.children.some((child) => location.pathname === child.path);
return (
<Collapsible key={item.name} defaultOpen={hasActiveChild}>
<CollapsibleTrigger asChild>
<Button variant={hasActiveChild ? 'secondary' : 'ghost'} className="w-full justify-between">
<span>{item.name}</span>
<ChevronDown className="h-4 w-4" />
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-1 space-y-1">
{item.children.map((child) => {
const active = location.pathname === child.path;
return (
<Link key={child.path} to={child.path}>
<Button variant={active ? 'secondary' : 'ghost'} className="w-full justify-start pl-8">
{child.name}
</Button>
</Link>
);
})}
</CollapsibleContent>
</Collapsible>
);
}
const active = location.pathname === item.path; const active = location.pathname === item.path;
return ( return (
@@ -0,0 +1,31 @@
import { Collapsible as CollapsiblePrimitive } from "radix-ui"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
+354
View File
@@ -0,0 +1,354 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import toast from 'react-hot-toast';
import { AxiosError } from 'axios';
import {
getHomepageBannersApi,
createHomepageBannerApi,
updateHomepageBannerApi,
deleteHomepageBannerApi,
HomepageBanner,
} from '@/api/homepageBanner';
import HomepageBannerModal from '@/components/BannerModal/HomepageBannerModal';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { Input } from '@/components/ui/input';
import { Loader2, RefreshCw, Plus, Pencil, Trash2, ExternalLink, Image, Video } from 'lucide-react';
export default function HomepageBannerPage() {
const [banners, setBanners] = useState<HomepageBanner[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [bannerModal, setBannerModal] = useState(false);
const [editingBanner, setEditingBanner] = useState<HomepageBanner | null>(null);
const [searchText, setSearchText] = useState('');
const [filterMediaType, setFilterMediaType] = useState('');
const [bannerForm, setBannerForm] = useState<Partial<HomepageBanner>>({
title: '',
subtitle: '',
mediaType: 'IMAGE',
desktopMediaUrl: '',
mobileMediaUrl: '',
buttonText: '',
buttonLink: '',
openInNewTab: false,
textAlignment: 'left',
sortOrder: 1000,
isActive: true,
});
const fetchData = useCallback(async () => {
setLoading(true);
setError('');
try {
const res = await getHomepageBannersApi();
setBanners(res.data || []);
} catch (err) {
if (err instanceof AxiosError) {
setError(err.response?.data?.message || 'Failed to sync banner directory records.');
} else {
setError('An unhandled database communication error occurred.');
}
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
const filteredBanners = useMemo(() => {
return banners.filter((banner) => {
const matchesSearch =
(banner.title?.toLowerCase().includes(searchText.toLowerCase()) ||
banner.subtitle?.toLowerCase().includes(searchText.toLowerCase()) ||
banner.buttonText?.toLowerCase().includes(searchText.toLowerCase())) ??
true;
const matchesType = filterMediaType ? banner.mediaType === filterMediaType : true;
return matchesSearch && matchesType;
});
}, [banners, searchText, filterMediaType]);
const handleToggleStatus = async (banner: HomepageBanner) => {
if (!banner.id) return;
try {
await updateHomepageBannerApi(banner.id, { isActive: !banner.isActive });
toast.success(`Banner display ${banner.isActive ? 'hidden from production' : 'activated live'}`);
fetchData();
} catch (err) {
console.error(err);
toast.error('Could not overwrite structural runtime configuration details.');
}
};
const handleDeleteBanner = async (id: number) => {
const confirmDelete = window.confirm(
'Are you absolutely sure you want to delete this home banner permanently? This cannot be undone.'
);
if (!confirmDelete) return;
try {
await deleteHomepageBannerApi(id);
fetchData();
} catch (err) {
console.error(err);
}
};
const openAddBanner = () => {
setEditingBanner(null);
setBannerForm({
title: '',
subtitle: '',
mediaType: 'IMAGE',
desktopMediaUrl: '',
mobileMediaUrl: '',
buttonText: '',
buttonLink: '',
openInNewTab: false,
textAlignment: 'left',
sortOrder: 1000,
isActive: true,
});
setBannerModal(true);
};
const openEditBanner = (banner: HomepageBanner) => {
setEditingBanner(banner);
setBannerForm({ ...banner });
setBannerModal(true);
};
const saveBanner = async () => {
if (!bannerForm.desktopMediaUrl) return toast.error('Desktop Media Asset is required.');
if (!bannerForm.mediaType) return toast.error('A valid media type assignment rule must be explicitly passed.');
try {
const finalData = { ...bannerForm };
if (editingBanner?.id) {
const changedFields: Record<string, any> = {};
Object.keys(finalData).forEach((key) => {
const k = key as keyof HomepageBanner;
if (JSON.stringify(finalData[k]) !== JSON.stringify(editingBanner[k])) {
changedFields[k] = finalData[k];
}
});
delete changedFields.id;
delete changedFields.createdAt;
delete changedFields.updatedAt;
if (Object.keys(changedFields).length === 0) {
setBannerModal(false);
return;
}
await updateHomepageBannerApi(editingBanner.id, changedFields);
} else {
await createHomepageBannerApi(finalData);
}
setBannerModal(false);
fetchData();
} catch (err) {
console.error(err);
toast.error('An unexpected runtime service error halted banner save operations.');
}
};
return (
<div className="p-6 space-y-6">
<div className="flex flex-col md:flex-row md:justify-between md:items-center gap-4">
<div>
<h1 className="text-3xl font-bold">Homepage Banners</h1>
<p className="text-sm text-muted-foreground">
Manage sliding heroes, background loops, video graphics, and dynamic contextual landing URLs.
</p>
</div>
<div className="flex flex-wrap gap-3">
<Input
placeholder="Search banners text contents..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="w-[260px] text-base"
/>
<select
value={filterMediaType}
onChange={(e) => setFilterMediaType(e.target.value)}
className="flex h-10 w-[180px] rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<option value="">All Media Types</option>
<option value="IMAGE">Images Only</option>
<option value="VIDEO">Videos Only</option>
</select>
<Button variant="outline" onClick={fetchData} disabled={loading} className="text-base">
<RefreshCw className="mr-2 h-5 w-5" />
Refresh
</Button>
<Button onClick={openAddBanner} className="text-base">
<Plus className="mr-2 h-5 w-5" />
Add Banner
</Button>
</div>
</div>
{error && <div className="p-4 text-red-600 bg-red-50 border rounded-md text-base">{error}</div>}
<Card>
<CardHeader>
<CardTitle className="text-xl">Active Slide Queue Sequence</CardTitle>
</CardHeader>
<CardContent className="p-0 sm:p-6">
<div className="rounded-md border overflow-x-auto overflow-y-auto max-h-[680px] relative">
<Table className="w-full min-w-[1000px] table-fixed border-separate border-spacing-0">
<TableHeader className="sticky top-0 z-20 bg-background shadow-sm">
<TableRow>
<TableHead className="w-[80px] bg-background text-sm font-bold">Priority</TableHead>
<TableHead className="w-[140px] bg-background text-sm font-bold">Media Preview</TableHead>
<TableHead className="w-[280px] bg-background text-sm font-bold">Banner Details</TableHead>
<TableHead className="w-[200px] bg-background text-sm font-bold">CTA Button Action</TableHead>
<TableHead className="w-[120px] bg-background text-sm font-bold">Status</TableHead>
<TableHead className="w-[120px] bg-background text-right text-sm font-bold">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-10">
<Loader2 className="h-8 w-8 animate-spin mx-auto" />
</TableCell>
</TableRow>
) : filteredBanners.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground py-10 text-base">
No homepage hero media elements configured inside database matching selection rules.
</TableCell>
</TableRow>
) : (
filteredBanners.map((banner) => (
<TableRow key={banner.id} className="hover:bg-muted/50">
<TableCell className="font-mono text-sm">{banner.sortOrder}</TableCell>
<TableCell>
<div className="w-24 h-14 rounded-md overflow-hidden bg-muted relative border flex items-center justify-center">
{banner.mediaType === 'IMAGE' ? (
<img
src={banner.desktopMediaUrl}
alt="Banner layout"
className="w-full h-full object-cover"
/>
) : (
<div className="flex flex-col items-center justify-center text-muted-foreground text-[10px]">
<Video className="h-4 w-4 mb-0.5 text-primary" />
Video Loop
</div>
)}
</div>
</TableCell>
<TableCell>
<div className="font-semibold text-base truncate" title={banner.title || 'Untitled Banner'}>
{banner.title || (
<span className="text-muted-foreground italic text-sm">No Heading text</span>
)}
</div>
<div className="text-xs text-muted-foreground truncate mt-0.5 max-w-[260px]">
{banner.subtitle || 'No body text description supplied.'}
</div>
<div className="flex items-center gap-2 mt-1.5">
<Badge variant="outline" className="text-[10px] uppercase tracking-wider px-1.5 py-0">
{banner.mediaType === 'IMAGE' ? (
<Image className="h-3 w-3 inline mr-1" />
) : (
<Video className="h-3 w-3 inline mr-1" />
)}
{banner.mediaType}
</Badge>
<Badge variant="secondary" className="text-[10px] capitalize px-1.5 py-0">
Align: {banner.textAlignment}
</Badge>
</div>
</TableCell>
<TableCell>
{banner.buttonText ? (
<div className="space-y-1">
<span className="inline-block text-xs font-semibold px-2 py-0.5 bg-primary/10 text-primary border rounded-md">
{banner.buttonText}
</span>
<div
className="text-xs text-muted-foreground font-mono truncate max-w-[180px]"
title={banner.buttonLink}
>
{banner.buttonLink}
</div>
{banner.openInNewTab && (
<span className="text-[10px] text-sky-600 flex items-center gap-0.5">
<ExternalLink className="h-2.5 w-2.5" /> Opens new window
</span>
)}
</div>
) : (
<span className="text-xs text-muted-foreground italic">Static Layout (No CTA link)</span>
)}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Switch checked={banner.isActive} onCheckedChange={() => handleToggleStatus(banner)} />
<Badge variant={banner.isActive ? 'default' : 'secondary'}>
{banner.isActive ? 'Active' : 'Hidden'}
</Badge>
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
size="icon"
variant="ghost"
className="h-9 w-9 text-muted-foreground hover:text-foreground"
onClick={() => openEditBanner(banner)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-9 w-9 text-red-500 hover:text-red-600 hover:bg-red-50"
onClick={() => banner.id && handleDeleteBanner(banner.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
<HomepageBannerModal
open={bannerModal}
onOpenChange={setBannerModal}
editingBanner={editingBanner}
bannerForm={bannerForm}
setBannerForm={setBannerForm}
onSave={saveBanner}
/>
</div>
);
}