diff --git a/backend/prisma/migrations/20260611055958_home_page_banner/migration.sql b/backend/prisma/migrations/20260611055958_home_page_banner/migration.sql new file mode 100644 index 0000000..bfeeffa --- /dev/null +++ b/backend/prisma/migrations/20260611055958_home_page_banner/migration.sql @@ -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") +); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 5b326b5..9bbc928 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -313,4 +313,34 @@ model Seo { createdAt DateTime @default(now()) 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 } \ No newline at end of file diff --git a/backend/src/app.js b/backend/src/app.js index 7e348fe..e343468 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -16,6 +16,7 @@ import emailConfigRoutes from './routes/emailConfig.routes.js'; import newsMediaRoutes from './routes/newsMedia.routes.js'; import importRoutes from './routes/importRoutes.js'; import healthCheckRoutes from './routes/healthCheck.route.js'; +import homepageBannerRoutes from './routes/homepageBanner.routes.js'; dotenv.config(); @@ -57,6 +58,7 @@ app.use('/api/email', emailConfigRoutes); app.use('/api/newsMedia', newsMediaRoutes); app.use('/api/import', importRoutes); app.use('/api/health-check', healthCheckRoutes); +app.use('/api/homepage-banners', homepageBannerRoutes); const PORT = process.env.PORT || 5008; app.listen(PORT, () => { diff --git a/backend/src/controllers/homepageBanner.controller.js b/backend/src/controllers/homepageBanner.controller.js new file mode 100644 index 0000000..0abca01 --- /dev/null +++ b/backend/src/controllers/homepageBanner.controller.js @@ -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', + }); + } +}; diff --git a/backend/src/routes/homepageBanner.routes.js b/backend/src/routes/homepageBanner.routes.js new file mode 100644 index 0000000..23eacb9 --- /dev/null +++ b/backend/src/routes/homepageBanner.routes.js @@ -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; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 649db16..aaa2f45 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> } /> + } /> diff --git a/frontend/src/api/homepageBanner.ts b/frontend/src/api/homepageBanner.ts new file mode 100644 index 0000000..9374b87 --- /dev/null +++ b/frontend/src/api/homepageBanner.ts @@ -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) => { + 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) => { + 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; + } +}; diff --git a/frontend/src/components/BannerModal/HomepageBannerModal.tsx b/frontend/src/components/BannerModal/HomepageBannerModal.tsx new file mode 100644 index 0000000..f61f2da --- /dev/null +++ b/frontend/src/components/BannerModal/HomepageBannerModal.tsx @@ -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 ( + + + + + {editingBanner ? 'Edit Homepage Banner' : 'Create Homepage Banner'} + + + +
+
+
+

Media Configuration

+

Manage your banner files for desktop and mobile layouts

+
+ +
+
+ + +
+ +
+
+

Active Visibility

+

Publish this banner live on the homepage

+
+ + setBannerForm({ + ...bannerForm, + isActive: val, + }) + } + /> +
+
+ +
+
+ +

+ {bannerForm.mediaType === 'VIDEO' + ? 'Recommended: 16:9 MP4 Format' + : 'Recommended: 1920 × 650 Widescreen'} +

+ + setBannerForm({ + ...bannerForm, + desktopMediaUrl: url, + }) + } + /> +
+ +
+ +

+ {bannerForm.mediaType === 'VIDEO' + ? 'Recommended: 9:16 Vertical Video' + : 'Recommended: 750 × 1000 Portrait'} +

+ + setBannerForm({ + ...bannerForm, + mobileMediaUrl: url, + }) + } + /> +
+
+
+ +
+
+

Banner Copy & Styles

+

Modify text details and text alignment configurations

+
+ +
+
+ + + setBannerForm({ + ...bannerForm, + title: e.target.value, + }) + } + /> +
+ +
+ + + setBannerForm({ + ...bannerForm, + subtitle: e.target.value, + }) + } + /> +
+
+ +
+
+ + +
+ +
+ + + setBannerForm({ + ...bannerForm, + sortOrder: Number(e.target.value), + }) + } + /> +
+
+
+ +
+
+

Call To Action (CTA Button)

+

Hyperlinks for optional button element overlays

+
+ +
+
+ + + setBannerForm({ + ...bannerForm, + buttonText: e.target.value, + }) + } + /> +
+ +
+ + + setBannerForm({ + ...bannerForm, + buttonLink: e.target.value, + }) + } + /> +
+
+ +
+
+

Target Tab Redirection

+

+ Force-launch the button link target into a completely new browser tab window +

+
+ + setBannerForm({ + ...bannerForm, + openInNewTab: val, + }) + } + /> +
+
+
+ + + + + +
+
+ ); +} diff --git a/frontend/src/components/BytescaleUploader/BytescaleUploader.tsx b/frontend/src/components/BytescaleUploader/BytescaleUploader.tsx index 4f4e9b8..8f7db2a 100644 --- a/frontend/src/components/BytescaleUploader/BytescaleUploader.tsx +++ b/frontend/src/components/BytescaleUploader/BytescaleUploader.tsx @@ -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) { diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 04e7bd6..ee8c80b 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -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() {