feat: home page banner crud

This commit is contained in:
Kailasdevdas
2026-06-15 09:30:19 +05:30
parent 131cd46f8d
commit 5444db8336
12 changed files with 1068 additions and 1 deletions
+2
View File
@@ -24,6 +24,7 @@ import NewsPage from './pages/newsMedia';
import BlogDetail from './pages/BlogDetails';
import ImportData from './pages/ImportData';
import HealthPackagePage from './pages/HealthPackagePage';
import HomepageBanner from './pages/HomepageBannerPage';
export default function App() {
return (
@@ -53,6 +54,7 @@ export default function App() {
<Route path="/news" element={<NewsPage />} />
<Route path="/import" element={<ImportData />} />
<Route path="/health-check" element={<HealthPackagePage />} />
<Route path="/homepage-banner" element={<HomepageBanner />} />
</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,265 @@
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: 16:9 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: 9:16 Vertical Video'
: 'Recommended: 750 × 1000 Portrait'}
</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>
);
}
@@ -6,7 +6,15 @@ import axios from 'axios';
interface BytescaleUploaderProps {
value: string;
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) {
@@ -2,6 +2,8 @@ import { Link, useLocation } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { ChevronDown } from 'lucide-react';
export default function Sidebar() {
const location = useLocation();
@@ -51,6 +53,15 @@ export default function Sidebar() {
name: 'Blog',
path: '/blog',
},
{
name: 'Homepage Content',
children: [
{
name: 'Homepage Banner',
path: '/homepage-banner',
},
],
},
];
return (
@@ -63,6 +74,35 @@ export default function Sidebar() {
<nav className="p-4 space-y-2">
{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;
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>
);
}