From 99601f9f0df5a4222b26fe5620631d201b0bb5ff Mon Sep 17 00:00:00 2001 From: Kailasdevdas Date: Thu, 25 Jun 2026 16:59:11 +0530 Subject: [PATCH] feat: facility and google review crud --- .../20260624123200_facilities/migration.sql | 49 ++ .../migration.sql | 17 + backend/prisma/schema.prisma | 60 ++- backend/src/app.js | 4 + .../src/controllers/facility.controller.js | 421 ++++++++++++++++++ .../controllers/googleReview.controller.js | 224 ++++++++++ backend/src/routes/facility.routes.js | 23 + backend/src/routes/googleReview.routes.js | 25 ++ frontend/src/App.tsx | 4 + frontend/src/api/facility.ts | 93 ++++ frontend/src/api/googleReview.ts | 58 +++ .../BytescaleUploader/BytescaleUploader.tsx | 4 +- .../FacilityModal/FacilityModal.tsx | 370 +++++++++++++++ .../GoogleReviewModal/GoogleReviewModal.tsx | 209 +++++++++ frontend/src/components/layout/Sidebar.tsx | 2 + frontend/src/pages/FacilityPage.tsx | 406 +++++++++++++++++ frontend/src/pages/GoogleReviewPage.tsx | 326 ++++++++++++++ 17 files changed, 2293 insertions(+), 2 deletions(-) create mode 100644 backend/prisma/migrations/20260624123200_facilities/migration.sql create mode 100644 backend/prisma/migrations/20260625051712_google_review/migration.sql create mode 100644 backend/src/controllers/facility.controller.js create mode 100644 backend/src/controllers/googleReview.controller.js create mode 100644 backend/src/routes/facility.routes.js create mode 100644 backend/src/routes/googleReview.routes.js create mode 100644 frontend/src/api/facility.ts create mode 100644 frontend/src/api/googleReview.ts create mode 100644 frontend/src/components/FacilityModal/FacilityModal.tsx create mode 100644 frontend/src/components/GoogleReviewModal/GoogleReviewModal.tsx create mode 100644 frontend/src/pages/FacilityPage.tsx create mode 100644 frontend/src/pages/GoogleReviewPage.tsx diff --git a/backend/prisma/migrations/20260624123200_facilities/migration.sql b/backend/prisma/migrations/20260624123200_facilities/migration.sql new file mode 100644 index 0000000..d824a07 --- /dev/null +++ b/backend/prisma/migrations/20260624123200_facilities/migration.sql @@ -0,0 +1,49 @@ +-- CreateTable +CREATE TABLE "Facility" ( + "id" SERIAL NOT NULL, + "facilityId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "shortDescription" TEXT, + "description" TEXT, + "videoUrl" TEXT, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "isFeatured" BOOLEAN NOT NULL DEFAULT false, + "sortOrder" INTEGER NOT NULL DEFAULT 1000, + "departmentId" INTEGER, + "seoId" INTEGER, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Facility_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "FacilityImage" ( + "id" SERIAL NOT NULL, + "url" TEXT NOT NULL, + "altText" TEXT, + "description" TEXT, + "facilityId" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "FacilityImage_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Facility_facilityId_key" ON "Facility"("facilityId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Facility_slug_key" ON "Facility"("slug"); + +-- CreateIndex +CREATE UNIQUE INDEX "Facility_seoId_key" ON "Facility"("seoId"); + +-- AddForeignKey +ALTER TABLE "Facility" ADD CONSTRAINT "Facility_departmentId_fkey" FOREIGN KEY ("departmentId") REFERENCES "Department"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Facility" ADD CONSTRAINT "Facility_seoId_fkey" FOREIGN KEY ("seoId") REFERENCES "Seo"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FacilityImage" ADD CONSTRAINT "FacilityImage_facilityId_fkey" FOREIGN KEY ("facilityId") REFERENCES "Facility"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/20260625051712_google_review/migration.sql b/backend/prisma/migrations/20260625051712_google_review/migration.sql new file mode 100644 index 0000000..5e42ced --- /dev/null +++ b/backend/prisma/migrations/20260625051712_google_review/migration.sql @@ -0,0 +1,17 @@ +-- CreateTable +CREATE TABLE "GoogleReview" ( + "id" SERIAL NOT NULL, + "reviewerName" TEXT NOT NULL, + "reviewerImage" TEXT, + "rating" INTEGER NOT NULL, + "review" TEXT NOT NULL, + "reviewDate" TIMESTAMP(3), + "googleReviewUrl" TEXT, + "isFeatured" BOOLEAN NOT NULL DEFAULT false, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "sortOrder" INTEGER NOT NULL DEFAULT 1000, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "GoogleReview_pkey" PRIMARY KEY ("id") +); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 2833572..d511c76 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -46,12 +46,12 @@ model Department { name String image String? - para1 String? para2 String? para3 String? facilities String? services String? + facilitiesList Facility[] isActive Boolean @default(true) sortOrder Int @default(1000) @@ -300,6 +300,7 @@ model Seo { id Int @id @default(autoincrement()) doctor Doctor? healthPackage HealthPackage? + facility Facility? seoTitle String? @@ -378,4 +379,61 @@ model Accreditation { enum AccreditationType { ACCREDITATION CERTIFICATION +} + +model Facility { + id Int @id @default(autoincrement()) + facilityId String @unique + name String + slug String @unique + + shortDescription String? @db.Text + description String? @db.Text + videoUrl String? + + isActive Boolean @default(true) + isFeatured Boolean @default(false) + sortOrder Int @default(1000) + + images FacilityImage[] + + departmentId Int? + department Department? @relation(fields: [departmentId], references: [id], onDelete: SetNull) + + seoId Int? @unique + seo Seo? @relation(fields: [seoId], references: [id], onDelete: SetNull) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model FacilityImage { + id Int @id @default(autoincrement()) + url String + altText String? + description String? + + facilityId Int + facility Facility @relation(fields: [facilityId], references: [id], onDelete: Cascade) + + createdAt DateTime @default(now()) +} + +model GoogleReview { + id Int @id @default(autoincrement()) + + reviewerName String + reviewerImage String? + rating Int + review String @db.Text + reviewDate DateTime? + googleReviewUrl String? + + isFeatured Boolean @default(false) + isActive Boolean @default(true) + + sortOrder Int @default(1000) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } \ No newline at end of file diff --git a/backend/src/app.js b/backend/src/app.js index 98da273..a987911 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -19,6 +19,8 @@ import healthCheckRoutes from './routes/healthCheck.route.js'; import homepageBannerRoutes from './routes/homepageBanner.routes.js'; import insurancePartnerRoutes from './routes/insurancePartner.routes.js'; import accreditationRoutes from './routes/accreditation.routes.js'; +import facilityRoutes from './routes/facility.routes.js'; +import gReviewRoutes from './routes/googleReview.routes.js'; dotenv.config(); @@ -63,6 +65,8 @@ app.use('/api/health-check', healthCheckRoutes); app.use('/api/homepage-banners', homepageBannerRoutes); app.use('/api/insurance-partners', insurancePartnerRoutes); app.use('/api/accreditation', accreditationRoutes); +app.use('/api/facilities', facilityRoutes); +app.use('/api/google-reviews', gReviewRoutes); const PORT = process.env.PORT || 5008; app.listen(PORT, () => { diff --git a/backend/src/controllers/facility.controller.js b/backend/src/controllers/facility.controller.js new file mode 100644 index 0000000..4686b3d --- /dev/null +++ b/backend/src/controllers/facility.controller.js @@ -0,0 +1,421 @@ +import prisma from '../prisma/client.js'; + +export const getAllFacilities = async (req, res) => { + try { + const { admin } = req.query; + + const facilities = await prisma.facility.findMany({ + where: admin === 'true' ? {} : { isActive: true }, + include: { + seo: true, + department: true, + images: true, + }, + orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }], + }); + + const formatted = facilities.map((fac, index) => ({ + SL_NO: String(index + 1), + id: fac.id, + facilityId: fac.facilityId, + name: fac.name, + slug: fac.slug, + shortDescription: fac.shortDescription, + description: fac.description, + videoUrl: fac.videoUrl ?? '', + isActive: fac.isActive, + isFeatured: fac.isFeatured, + sortOrder: fac.sortOrder, + department: fac.department + ? { + id: fac.department.id, + departmentId: fac.department.departmentId, + name: fac.department.name, + } + : null, + images: fac.images.map((img) => ({ + id: img.id, + url: img.url, + altText: img.altText ?? '', + description: img.description ?? '', + })), + seo: { + seoTitle: fac.seo?.seoTitle ?? '', + metaDescription: fac.seo?.metaDescription ?? '', + focusKeyphrase: fac.seo?.focusKeyphrase ?? '', + slug: fac.seo?.slug ?? '', + tags: fac.seo?.tags ?? [], + ogTitle: fac.seo?.ogTitle ?? '', + ogDescription: fac.seo?.ogDescription ?? '', + ogImage: fac.seo?.ogImage ?? '', + }, + })); + + res.status(200).json({ + success: true, + data: formatted, + }); + } catch (error) { + console.error(error); + res.status(500).json({ + success: false, + message: 'Failed to fetch facilities', + }); + } +}; + +export const getFacilityByFacilityId = async (req, res) => { + try { + const { facilityId } = req.params; + const { admin } = req.query; + + const facility = await prisma.facility.findFirst({ + where: { + facilityId, + ...(admin === 'true' ? {} : { isActive: true }), + }, + include: { + seo: true, + department: true, + images: true, + }, + }); + + if (!facility) { + return res.status(404).json({ + success: false, + message: 'Facility not found', + }); + } + + const response = { + facilityId: facility.facilityId, + name: facility.name, + slug: facility.slug, + shortDescription: facility.shortDescription, + description: facility.description, + videoUrl: facility.videoUrl ?? '', + isActive: facility.isActive, + isFeatured: facility.isFeatured, + sortOrder: facility.sortOrder, + department: facility.department + ? { + departmentId: facility.department.departmentId, + name: facility.department.name, + } + : null, + images: facility.images.map((img) => ({ + id: img.id, + url: img.url, + altText: img.altText ?? '', + description: img.description ?? '', + })), + seo: { + seoTitle: facility.seo?.seoTitle ?? '', + metaDescription: facility.seo?.metaDescription ?? '', + focusKeyphrase: facility.seo?.focusKeyphrase ?? '', + slug: facility.seo?.slug ?? '', + tags: facility.seo?.tags ?? [], + ogTitle: facility.seo?.ogTitle ?? '', + ogDescription: facility.seo?.ogDescription ?? '', + ogImage: facility.seo?.ogImage ?? '', + }, + }; + + res.status(200).json({ + success: true, + data: response, + }); + } catch (error) { + console.error(error); + res.status(500).json({ + success: false, + message: 'Failed to fetch facility', + }); + } +}; + +export const createFacility = async (req, res) => { + try { + const { + facilityId, + name, + slug, + shortDescription, + description, + videoUrl, + isActive, + isFeatured, + sortOrder, + departmentId, + images, + seoTitle, + metaDescription, + focusKeyphrase, + tags, + ogTitle, + ogDescription, + ogImage, + } = req.body; + + const messages = []; + if (!facilityId) messages.push('Facility ID is required'); + if (!name?.trim()) messages.push('Facility name is required'); + if (!slug?.trim()) messages.push('Slug is required'); + + if (messages.length > 0) { + return res.status(400).json({ + success: false, + message: messages.join(', '), + }); + } + + let dbDepartmentId = null; + if (departmentId) { + const targetDept = await prisma.department.findUnique({ + where: { departmentId: departmentId }, + }); + if (targetDept) { + dbDepartmentId = targetDept.id; + } + } + + const seo = await prisma.seo.create({ + data: { + seoTitle, + metaDescription, + focusKeyphrase, + slug: slug ? slug : null, + tags: tags || [], + ogTitle, + ogDescription, + ogImage, + }, + }); + + const facility = await prisma.facility.create({ + data: { + facilityId, + name, + slug, + shortDescription, + description, + videoUrl, + isActive: isActive !== undefined ? isActive : true, + isFeatured: isFeatured !== undefined ? isFeatured : false, + sortOrder: sortOrder !== undefined ? Number(sortOrder) : 1000, + departmentId: dbDepartmentId, + seoId: seo.id, + }, + }); + + if (images && images.length > 0) { + await prisma.facilityImage.createMany({ + data: images.map((img) => ({ + url: img.url, + altText: img.altText || null, + description: img.description || null, + facilityId: facility.id, + })), + }); + } + + res.status(201).json({ + success: true, + message: 'Facility created successfully', + }); + } catch (error) { + console.error(error); + res.status(500).json({ + success: false, + message: 'Failed to create facility', + }); + } +}; + +export const updateFacility = async (req, res) => { + try { + const { facilityId, action } = req.params; + const { + name, + slug, + shortDescription, + description, + videoUrl, + isActive, + isFeatured, + sortOrder, + departmentId, + images, + seoTitle, + metaDescription, + focusKeyphrase, + tags, + ogTitle, + ogDescription, + ogImage, + } = req.body; + + if (!facilityId) { + return res.status(400).json({ success: false, message: 'Facility ID is required' }); + } + + const facility = await prisma.facility.findUnique({ where: { facilityId } }); + if (!facility) return res.status(404).json({ success: false, message: 'Facility not found' }); + + if (action === 'toggleStatus') { + await prisma.facility.update({ + where: { id: facility.id }, + data: { isActive: !facility.isActive }, + }); + return res.status(200).json({ + success: true, + message: `Facility has been ${facility.isActive ? 'deactivated' : 'activated'} successfully`, + }); + } + + if (action === 'toggleFeatured') { + await prisma.facility.update({ + where: { id: facility.id }, + data: { isFeatured: !facility.isFeatured }, + }); + return res.status(200).json({ + success: true, + message: `Facility has been ${facility.isFeatured ? 'removed from featured' : 'marked as featured'} successfully`, + }); + } + + const messages = []; + if (!name?.trim()) messages.push('Facility name is required'); + if (!slug?.trim()) messages.push('Slug is required'); + + if (messages.length > 0) { + return res.status(400).json({ success: false, message: messages.join(', ') }); + } + + let dbDepartmentId = undefined; + if (departmentId !== undefined) { + if (departmentId) { + const targetDept = await prisma.department.findUnique({ + where: { departmentId: departmentId }, + }); + dbDepartmentId = targetDept ? targetDept.id : null; + } else { + dbDepartmentId = null; + } + } + + await prisma.facility.update({ + where: { id: facility.id }, + data: { + name, + slug, + shortDescription, + description, + videoUrl, + isActive: isActive !== undefined ? isActive : undefined, + isFeatured: isFeatured !== undefined ? isFeatured : undefined, + sortOrder: sortOrder !== undefined ? Number(sortOrder) : undefined, + departmentId: dbDepartmentId, + }, + }); + + if (facility.seoId) { + await prisma.seo.update({ + where: { id: facility.seoId }, + data: { + seoTitle, + metaDescription, + focusKeyphrase, + slug: slug ? slug : null, + tags: tags || [], + ogTitle, + ogDescription, + ogImage, + }, + }); + } else { + const seo = await prisma.seo.create({ + data: { + seoTitle, + metaDescription, + focusKeyphrase, + slug: slug ? slug : null, + tags: tags || [], + ogTitle, + ogDescription, + ogImage, + }, + }); + await prisma.facility.update({ where: { id: facility.id }, data: { seoId: seo.id } }); + } + + if (Array.isArray(images)) { + await prisma.facilityImage.deleteMany({ where: { facilityId: facility.id } }); + if (images.length > 0) { + await prisma.facilityImage.createMany({ + data: images.map((img) => ({ + url: img.url, + altText: img.altText || null, + description: img.description || null, + facilityId: facility.id, + })), + }); + } + } + + res.status(200).json({ success: true, message: 'Facility updated successfully' }); + } catch (error) { + console.error('Update Error:', error); + res.status(500).json({ success: false, message: 'Failed to update facility' }); + } +}; + +export const deleteFacility = async (req, res) => { + try { + const { facilityId } = req.params; + + const facility = await prisma.facility.findUnique({ where: { facilityId } }); + if (!facility) { + return res.status(404).json({ success: false, message: 'Facility not found' }); + } + + await prisma.facilityImage.deleteMany({ where: { facilityId: facility.id } }); + await prisma.facility.delete({ where: { id: facility.id } }); + + res.status(200).json({ + success: true, + message: 'Facility deleted successfully', + }); + } catch (error) { + console.error(error); + res.status(500).json({ success: false, message: 'Failed to delete facility' }); + } +}; + +export const getFeaturedFacilities = async (req, res) => { + try { + const facilities = await prisma.facility.findMany({ + where: { isActive: true, isFeatured: true }, + include: { + images: true, + department: true, + }, + orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }], + }); + + const data = facilities.map((fac) => ({ + facilityId: fac.facilityId, + name: fac.name, + slug: fac.slug, + shortDescription: fac.shortDescription, + image: fac.images[0]?.url ?? '', + departmentName: fac.department?.name ?? '', + })); + + res.status(200).json({ success: true, data }); + } catch (error) { + console.error(error); + res.status(500).json({ success: false, message: 'Failed to fetch featured facilities' }); + } +}; diff --git a/backend/src/controllers/googleReview.controller.js b/backend/src/controllers/googleReview.controller.js new file mode 100644 index 0000000..7144544 --- /dev/null +++ b/backend/src/controllers/googleReview.controller.js @@ -0,0 +1,224 @@ +import prisma from '../prisma/client.js'; + +export const getFeaturedGoogleReviews = async (req, res) => { + try { + const reviews = await prisma.googleReview.findMany({ + where: { + isActive: true, + isFeatured: true, + }, + orderBy: { + sortOrder: 'asc', + }, + }); + + res.json({ + success: true, + data: reviews, + }); + } catch (error) { + console.error(error); + res.status(500).json({ + success: false, + message: 'Failed to fetch featured Google reviews', + }); + } +}; + +export const createGoogleReview = async (req, res) => { + try { + const { + reviewerName, + reviewerImage, + rating, + review, + reviewDate, + googleReviewUrl, + isFeatured, + isActive, + sortOrder, + } = req.body; + + if (!reviewerName || !rating || !review) { + return res.status(400).json({ + success: false, + message: 'Reviewer name, rating and review are required.', + }); + } + + const googleReview = await prisma.googleReview.create({ + data: { + reviewerName, + reviewerImage, + rating: Number(rating), + review, + reviewDate: reviewDate ? new Date(reviewDate) : null, + googleReviewUrl, + isFeatured, + isActive, + sortOrder, + }, + }); + + res.status(201).json({ + success: true, + data: googleReview, + message: 'Google review created successfully', + }); + } catch (error) { + console.error(error); + res.status(500).json({ + success: false, + message: 'Failed to create Google review', + }); + } +}; + +export const getGoogleReviews = async (req, res) => { + try { + const reviews = await prisma.googleReview.findMany({ + orderBy: { + sortOrder: 'asc', + }, + }); + + res.json({ + success: true, + data: reviews, + }); + } catch (error) { + console.error(error); + res.status(500).json({ + success: false, + message: 'Failed to fetch Google reviews', + }); + } +}; + +export const getActiveGoogleReviews = async (req, res) => { + try { + const reviews = await prisma.googleReview.findMany({ + where: { + isActive: true, + }, + orderBy: { + sortOrder: 'asc', + }, + }); + + res.json({ + success: true, + data: reviews, + }); + } catch (error) { + console.error(error); + res.status(500).json({ + success: false, + message: 'Failed to fetch active Google reviews', + }); + } +}; + +export const getGoogleReview = async (req, res) => { + try { + const { id } = req.params; + + const review = await prisma.googleReview.findUnique({ + where: { + id: Number(id), + }, + }); + + if (!review) { + return res.status(404).json({ + success: false, + message: 'Google review not found', + }); + } + + res.json({ + success: true, + data: review, + }); + } catch (error) { + console.error(error); + res.status(500).json({ + success: false, + message: 'Failed to fetch Google review', + }); + } +}; + +export const updateGoogleReview = async (req, res) => { + try { + const { id } = req.params; + + const { + reviewerName, + reviewerImage, + rating, + review, + reviewDate, + googleReviewUrl, + isFeatured, + isActive, + sortOrder, + } = req.body; + + const dataToUpdate = {}; + + if (reviewerName !== undefined) dataToUpdate.reviewerName = reviewerName; + if (reviewerImage !== undefined) dataToUpdate.reviewerImage = reviewerImage; + if (rating !== undefined) dataToUpdate.rating = Number(rating); + if (review !== undefined) dataToUpdate.review = review; + if (reviewDate !== undefined) { + dataToUpdate.reviewDate = reviewDate ? new Date(reviewDate) : null; + } + if (googleReviewUrl !== undefined) dataToUpdate.googleReviewUrl = googleReviewUrl; + if (isFeatured !== undefined) dataToUpdate.isFeatured = isFeatured; + if (isActive !== undefined) dataToUpdate.isActive = isActive; + if (sortOrder !== undefined) dataToUpdate.sortOrder = sortOrder; + + const googleReview = await prisma.googleReview.update({ + where: { + id: Number(id), + }, + data: dataToUpdate, + }); + + res.json({ + success: true, + data: googleReview, + message: 'Google review updated successfully', + }); + } catch (error) { + console.error(error); + res.status(500).json({ + success: false, + message: 'Failed to update Google review', + }); + } +}; + +export const deleteGoogleReview = async (req, res) => { + try { + const { id } = req.params; + + await prisma.googleReview.delete({ + where: { + id: Number(id), + }, + }); + + res.json({ + success: true, + message: 'Google review deleted successfully', + }); + } catch (error) { + console.error(error); + res.status(500).json({ + success: false, + message: 'Failed to delete Google review', + }); + } +}; diff --git a/backend/src/routes/facility.routes.js b/backend/src/routes/facility.routes.js new file mode 100644 index 0000000..2997099 --- /dev/null +++ b/backend/src/routes/facility.routes.js @@ -0,0 +1,23 @@ +import express from 'express'; +import { + getAllFacilities, + getFacilityByFacilityId, + createFacility, + updateFacility, + deleteFacility, + getFeaturedFacilities, +} from '../controllers/facility.controller.js'; + +import jwtAuthMiddleware from '../middleware/auth.js'; + +const router = express.Router(); + +router.get('/getAll', getAllFacilities); +router.get('/featured', getFeaturedFacilities); +router.get('/:facilityId', getFacilityByFacilityId); + +router.post('/', jwtAuthMiddleware, createFacility); +router.patch('/:facilityId/:action', jwtAuthMiddleware, updateFacility); +router.delete('/:facilityId', jwtAuthMiddleware, deleteFacility); + +export default router; diff --git a/backend/src/routes/googleReview.routes.js b/backend/src/routes/googleReview.routes.js new file mode 100644 index 0000000..f2c345e --- /dev/null +++ b/backend/src/routes/googleReview.routes.js @@ -0,0 +1,25 @@ +import express from 'express'; +import jwtAuthMiddleware from '../middleware/auth.js'; + +import { + createGoogleReview, + getGoogleReviews, + getActiveGoogleReviews, + getFeaturedGoogleReviews, + getGoogleReview, + updateGoogleReview, + deleteGoogleReview, +} from '../controllers/googleReview.controller.js'; + +const router = express.Router(); + +router.get('/active', getActiveGoogleReviews); +router.get('/featured', getFeaturedGoogleReviews); + +router.post('/', jwtAuthMiddleware, createGoogleReview); +router.get('/getAll', jwtAuthMiddleware, getGoogleReviews); +router.get('/:id', jwtAuthMiddleware, getGoogleReview); +router.put('/:id', jwtAuthMiddleware, updateGoogleReview); +router.delete('/:id', jwtAuthMiddleware, deleteGoogleReview); + +export default router; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 71001d3..f69f913 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -27,6 +27,8 @@ import HealthPackagePage from './pages/HealthPackagePage'; import HomepageBanner from './pages/HomepageBannerPage'; import InsurancePartnerPage from './pages/InsurancePartner'; import AccreditationPage from './pages/Accreditation'; +import FacilityPage from './pages/FacilityPage'; +import GoogleReviewPage from './pages/GoogleReviewPage'; export default function App() { return ( @@ -59,6 +61,8 @@ export default function App() { } /> } /> } /> + } /> + } /> diff --git a/frontend/src/api/facility.ts b/frontend/src/api/facility.ts new file mode 100644 index 0000000..85201af --- /dev/null +++ b/frontend/src/api/facility.ts @@ -0,0 +1,93 @@ +import apiClient from '@/api/client'; +import toast from 'react-hot-toast'; + +export interface SeoData { + seoTitle?: string; + metaDescription?: string; + focusKeyphrase?: string; + slug?: string; + tags?: string[]; + ogTitle?: string; + ogDescription?: string; + ogImage?: string; +} + +export interface FacilityImage { + id?: number; + url: string; + altText?: string; + description?: string; +} + +export interface Facility { + id?: number; + facilityId: string; + name: string; + slug: string; + shortDescription?: string; + description?: string; + videoUrl?: string; + isActive: boolean; + isFeatured: boolean; + sortOrder: number; + departmentId?: number | null; + department?: { + id: number; + departmentId: string; + name: string; + } | null; + images?: FacilityImage[]; + seo?: SeoData | null; +} + +export const getAllFacilitiesApi = async () => { + const res = await apiClient.get('/facilities/getAll?admin=true'); + return res.data; +}; + +export const getFacilityByIdApi = async (facilityId: string) => { + const res = await apiClient.get(`/facilities/${facilityId}`); + return res.data; +}; + +export const getFeaturedFacilitiesApi = async () => { + const res = await apiClient.get('/facilities/featured'); + return res.data; +}; + +export const createFacilityApi = async (data: Partial & SeoData) => { + try { + const res = await apiClient.post('/facilities', data); + toast.success('Facility created successfully'); + return res.data; + } catch (error: any) { + toast.error(error?.response?.data?.message || 'Failed to create facility'); + throw error; + } +}; + +export const updateFacilityApi = async ( + facilityId: string, + data: Partial & SeoData, + action: 'toggleStatus' | 'toggleFeatured' | 'updateDetails' = 'updateDetails' +) => { + try { + const res = await apiClient.patch(`/facilities/${facilityId}/${action}`, data); + toast.success('Facility updated successfully'); + return res.data; + } catch (error: any) { + toast.error(error?.response?.data?.message || 'Failed to update facility'); + throw error; + } +}; + +export const deleteFacilityApi = async (facilityId: string) => { + try { + const res = await apiClient.delete(`/facilities/${facilityId}`); + toast.success('Facility deleted successfully'); + return res.data; + } catch (error: any) { + toast.error(error?.response?.data?.message || 'Failed to delete facility'); + throw error; + } +}; diff --git a/frontend/src/api/googleReview.ts b/frontend/src/api/googleReview.ts new file mode 100644 index 0000000..1256cb1 --- /dev/null +++ b/frontend/src/api/googleReview.ts @@ -0,0 +1,58 @@ +import apiClient from '@/api/client'; +import toast from 'react-hot-toast'; + +export interface GoogleReview { + id?: number; + reviewerName: string; + reviewerImage?: string; + rating: number; + review: string; + reviewDate?: string | null; + googleReviewUrl?: string; + isFeatured: boolean; + isActive: boolean; + sortOrder: number; +} + +export const getGoogleReviewsApi = async () => { + const res = await apiClient.get('/google-reviews/getAll'); + return res.data; +}; + +export const getActiveGoogleReviewsApi = async () => { + const res = await apiClient.get('/google-reviews/active'); + return res.data; +}; + +export const createGoogleReviewApi = async (data: GoogleReview) => { + try { + const res = await apiClient.post('/google-reviews', data); + toast.success('Google review created successfully'); + return res.data; + } catch (error: any) { + toast.error(error?.response?.data?.message || 'Failed to create review'); + throw error; + } +}; + +export const updateGoogleReviewApi = async (id: number, data: Partial) => { + try { + const res = await apiClient.put(`/google-reviews/${id}`, data); + toast.success('Google review updated successfully'); + return res.data; + } catch (error: any) { + toast.error(error?.response?.data?.message || 'Failed to update review'); + throw error; + } +}; + +export const deleteGoogleReviewApi = async (id: number) => { + try { + const res = await apiClient.delete(`/google-reviews/${id}`); + toast.success('Google review deleted successfully'); + return res.data; + } catch (error: any) { + toast.error(error?.response?.data?.message || 'Failed to delete review'); + throw error; + } +}; diff --git a/frontend/src/components/BytescaleUploader/BytescaleUploader.tsx b/frontend/src/components/BytescaleUploader/BytescaleUploader.tsx index 0c446d8..79181e0 100644 --- a/frontend/src/components/BytescaleUploader/BytescaleUploader.tsx +++ b/frontend/src/components/BytescaleUploader/BytescaleUploader.tsx @@ -16,7 +16,9 @@ interface BytescaleUploaderProps { | '/doctor-og' | '/homepage-banners' | '/insurance-partners' - | '/accreditations'; + | '/accreditations' + | '/facilities' + | '/reviews'; } export function BytescaleUploader({ value, onChange, folderPath }: BytescaleUploaderProps) { diff --git a/frontend/src/components/FacilityModal/FacilityModal.tsx b/frontend/src/components/FacilityModal/FacilityModal.tsx new file mode 100644 index 0000000..4123ac7 --- /dev/null +++ b/frontend/src/components/FacilityModal/FacilityModal.tsx @@ -0,0 +1,370 @@ +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Switch } from '@/components/ui/switch'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { BytescaleUploader } from '@/components/BytescaleUploader/BytescaleUploader'; +import { Department } from '@/api/department'; +import { FacilityImage, Facility } from '@/api/facility'; + +interface FacilityModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + editing: Facility | null; + form: any; + setForm: any; + departments: Department[]; + onSubmit: () => void; +} + +export default function FacilityModal({ + open, + onOpenChange, + editing, + form, + setForm, + departments, + onSubmit, +}: FacilityModalProps) { + const handleChange = (e: React.ChangeEvent) => { + const { name, value, type } = e.target; + let finalValue: any = type === 'number' ? Number(value) : value; + + if (name === 'slug') { + finalValue = finalValue + .toLowerCase() + .replace(/\s+/g, '-') + .replace(/[^\w-]+/g, '') + .replace(/--+/g, '-'); + } + + setForm((prev: any) => ({ ...prev, [name]: finalValue })); + }; + + const handleAddImageField = () => { + setForm((prev: any) => ({ + ...prev, + images: [...prev.images, { url: '', altText: '', description: '' }], + })); + }; + + const handleRemoveImageField = (index: number) => { + setForm((prev: any) => ({ + ...prev, + images: prev.images.filter((_: any, i: number) => i !== index), + })); + }; + + const handleImageChange = (index: number, field: keyof FacilityImage, value: string) => { + setForm((prev: any) => { + const updatedImages = [...prev.images]; + updatedImages[index] = { ...updatedImages[index], [field]: value }; + return { ...prev, images: updatedImages }; + }); + }; + + return ( + + + + {editing ? 'Edit Facility Record' : 'Add New Facility Asset'} + + +
+
+ {/* Left Side: Profile & Structure */} +
+

Profile & Structure

+ +
+
+
+ + setForm({ ...form, isActive: val })} + /> +
+
+ + setForm({ ...form, isFeatured: val })} + /> +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +