Compare commits

..

5 Commits

26 changed files with 2490 additions and 21 deletions
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Doctor" ADD COLUMN "isFeatured" BOOLEAN NOT NULL DEFAULT false;
@@ -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;
@@ -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")
);
+60 -1
View File
@@ -27,6 +27,7 @@ model Doctor {
workingStatus String?
qualification String?
isActive Boolean @default(true)
isFeatured Boolean @default(false)
globalSortOrder Int @default(1000)
specializations DoctorSpecialization[]
professionalSummary String? @db.Text
@@ -45,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)
@@ -299,6 +300,7 @@ model Seo {
id Int @id @default(autoincrement())
doctor Doctor?
healthPackage HealthPackage?
facility Facility?
seoTitle String?
@@ -377,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
}
+4
View File
@@ -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, () => {
+69 -1
View File
@@ -34,6 +34,7 @@ export const getAllDoctors = async (req, res) => {
workingStatus: doc.workingStatus,
qualification: doc.qualification,
isActive: doc.isActive,
isFeatured: doc.isFeatured,
experience: doc.experience,
professionalSummary: doc.professionalSummary,
globalSortOrder: doc.globalSortOrder,
@@ -129,6 +130,7 @@ export const getDoctorByDoctorId = async (req, res) => {
experience: doctor.experience,
professionalSummary: doctor.professionalSummary,
isActive: doctor.isActive,
isFeatured: doctor.isFeatured,
seo: {
seoTitle: doctor.seo?.seoTitle ?? '',
metaDescription: doctor.seo?.metaDescription ?? '',
@@ -240,6 +242,7 @@ export const createDoctor = async (req, res) => {
workingStatus,
qualification,
isActive,
isFeatured,
globalSortOrder,
departments,
experience,
@@ -297,6 +300,7 @@ export const createDoctor = async (req, res) => {
professionalSummary,
seoId: seo.id,
isActive: isActive !== undefined ? isActive : true,
isFeatured: isFeatured !== undefined ? isFeatured : false,
globalSortOrder: globalSortOrder !== undefined ? Number(globalSortOrder) : 0,
},
});
@@ -361,6 +365,7 @@ export const updateDoctor = async (req, res) => {
workingStatus,
qualification,
isActive,
isFeatured,
globalSortOrder,
departments,
experience,
@@ -397,6 +402,19 @@ export const updateDoctor = async (req, res) => {
message: `Doctor has been ${doctor.isActive ? 'deactivated' : 'activated'} successfully`,
});
}
if (action === 'toggleFeatured') {
await prisma.doctor.update({
where: { id: doctor.id },
data: {
isFeatured: !doctor.isFeatured,
},
});
return res.status(200).json({
success: true,
message: `Doctor has been ${doctor.isFeatured ? 'removed from featured' : 'marked as featured'} successfully`,
});
}
const messages = [];
if (!doctorId) messages.push('Doctor ID is required');
@@ -423,7 +441,8 @@ export const updateDoctor = async (req, res) => {
image,
workingStatus,
qualification,
isActive,
isActive: isActive !== undefined ? isActive : undefined,
isFeatured: isFeatured !== undefined ? isFeatured : undefined,
experience: experience ? Number(experience) : null,
professionalSummary,
globalSortOrder: globalSortOrder !== undefined ? Number(globalSortOrder) : undefined,
@@ -700,3 +719,52 @@ export const getDoctorTimingById = async (req, res) => {
});
}
};
export const getFeaturedDoctors = async (req, res) => {
try {
const doctors = await prisma.doctor.findMany({
where: {
isActive: true,
isFeatured: true,
},
include: {
seo: {
select: {
slug: true,
},
},
departments: {
include: {
department: true,
},
},
},
orderBy: [{ globalSortOrder: 'asc' }, { name: 'asc' }],
});
const data = doctors.map((doc) => ({
doctorId: doc.doctorId,
name: doc.name,
image: doc.image ?? '',
designation: doc.designation,
qualification: doc.qualification,
experience: doc.experience,
slug: doc.seo?.slug ?? '',
departments: doc.departments.map((d) => ({
departmentId: d.department.departmentId,
departmentName: d.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 doctors',
});
}
};
@@ -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' });
}
};
@@ -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',
});
}
};
@@ -486,3 +486,33 @@ export const getAllInquiries = async (req, res) => {
return res.status(500).json({ success: false, message: 'Failed to fetch inquiries' });
}
};
export const getFeaturedPackages = async (req, res) => {
try {
const packages = await prisma.healthPackage.findMany({
where: {
isActive: true,
isFeatured: true,
category: {
isActive: true,
},
},
include: {
category: true,
seo: true,
},
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
});
return res.status(200).json({
success: true,
data: packages,
});
} catch (error) {
console.error(error);
return res.status(500).json({
success: false,
message: 'Failed to fetch featured packages',
});
}
};
+2
View File
@@ -8,6 +8,7 @@ import {
getDoctorTimingById,
getDoctorByDoctorId,
getDoctorsByDepartmentId,
getFeaturedDoctors,
} from '../controllers/doctor.controller.js';
import jwtAuthMiddleware from '../middleware/auth.js';
@@ -18,6 +19,7 @@ router.get('/getAll', getAllDoctors);
router.get('/search', getDoctorsByDepartmentId);
router.get('/getTimings', getDoctorTimings);
router.get('/getTimings/:doctorId', getDoctorTimingById);
router.get('/featured', getFeaturedDoctors);
router.get('/:doctorId', getDoctorByDoctorId);
router.post('/', jwtAuthMiddleware, createDoctor);
+23
View File
@@ -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;
+25
View File
@@ -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;
+2
View File
@@ -12,6 +12,7 @@ import {
createPackage,
updatePackage,
deletePackage,
getFeaturedPackages,
// Inquiries
createPackageInquiry,
@@ -26,6 +27,7 @@ router.get('/packages', getAllPackages);
router.get('/packages/:slug', getPackageBySlug);
router.get('/categories', getAllCategories);
router.post('/inquiry', createPackageInquiry);
router.get('/featured', getFeaturedPackages);
router.get('/inquiries', jwtAuthMiddleware, getAllInquiries);
router.post('/', jwtAuthMiddleware, createPackage);
+4
View File
@@ -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() {
<Route path="/homepage-banner" element={<HomepageBanner />} />
<Route path="/insurance-partner" element={<InsurancePartnerPage />} />
<Route path="/accreditation" element={<AccreditationPage />} />
<Route path="/facility" element={<FacilityPage />} />
<Route path="/reviews" element={<GoogleReviewPage />} />
</Route>
</Route>
+2 -1
View File
@@ -9,6 +9,7 @@ export interface Doctor {
workingStatus?: string;
qualification?: string;
isActive: boolean;
isFeatured: boolean;
globalSortOrder: number;
departments?: {
@@ -53,7 +54,7 @@ export const createDoctorApi = async (data: Doctor) => {
export const updateDoctorApi = async (
doctorId: string,
data: Partial<Doctor>,
action: 'toggleStatus' | 'updateDetails' = 'updateDetails'
action: 'toggleStatus' | 'toggleFeatured' | 'updateDetails' = 'updateDetails'
) => {
try {
const res = await apiClient.patch(`/doctors/${doctorId}/${action}`, data);
+93
View File
@@ -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<Facility> & 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<Facility> & 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;
}
};
+58
View File
@@ -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<GoogleReview>) => {
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;
}
};
@@ -16,7 +16,9 @@ interface BytescaleUploaderProps {
| '/doctor-og'
| '/homepage-banners'
| '/insurance-partners'
| '/accreditations';
| '/accreditations'
| '/facilities'
| '/reviews';
}
export function BytescaleUploader({ value, onChange, folderPath }: BytescaleUploaderProps) {
@@ -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<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-full !max-w-5xl h-[90vh] flex flex-col p-0 overflow-hidden">
<DialogHeader className="p-6 border-b bg-background z-10">
<DialogTitle className="text-2xl">{editing ? 'Edit Facility Record' : 'Add New Facility Asset'}</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto p-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{/* Left Side: Profile & Structure */}
<div className="space-y-6">
<h3 className="font-bold text-base border-b pb-2">Profile & Structure</h3>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="flex items-center justify-between p-3 border rounded-md bg-muted/30">
<Label htmlFor="isActive" className="text-base font-semibold cursor-pointer">
Active
</Label>
<Switch
id="isActive"
checked={form.isActive}
onCheckedChange={(val) => setForm({ ...form, isActive: val })}
/>
</div>
<div className="flex items-center justify-between p-3 border rounded-md bg-muted/30">
<Label htmlFor="isFeatured" className="text-base font-semibold cursor-pointer">
Featured
</Label>
<Switch
id="isFeatured"
checked={form.isFeatured}
onCheckedChange={(val) => setForm({ ...form, isFeatured: val })}
/>
</div>
</div>
<div className="space-y-1">
<Label htmlFor="sortOrder" className="text-sm font-semibold">
Priority
</Label>
<Input
id="sortOrder"
name="sortOrder"
type="number"
value={form.sortOrder}
onChange={handleChange}
className="text-base"
/>
</div>
<div className="space-y-1">
<Label className="text-sm font-semibold">Facility ID</Label>
<Input
name="facilityId"
placeholder="FAC-MRI-3T"
value={form.facilityId}
onChange={handleChange}
disabled={!!editing}
className="text-base"
/>
</div>
<div className="space-y-1">
<Label className="text-sm font-semibold">Name</Label>
<Input
name="name"
placeholder="3T Digital MRI Center"
value={form.name}
onChange={handleChange}
className="text-base"
/>
</div>
<div className="space-y-1">
<Label className="text-sm font-semibold">Connected Department</Label>
<select
name="departmentId"
value={form.departmentId || ''}
onChange={handleChange}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<option value="">Select Target Department (Optional)</option>
{departments.map((d) => (
<option key={d.departmentId} value={d.departmentId}>
{d.name}
</option>
))}
</select>
</div>
<div className="space-y-1">
<Label className="text-sm font-semibold">Video URL</Label>
<Input
name="videoUrl"
placeholder="https://youtube.com/watch?v=..."
value={form.videoUrl}
onChange={handleChange}
className="text-base"
/>
</div>
<div className="space-y-1">
<Label className="text-sm font-semibold">Brief Summary</Label>
<Textarea
name="shortDescription"
placeholder="Provide a concise introductory snippet statement description..."
value={form.shortDescription}
onChange={handleChange}
className="min-h-[80px] text-base"
/>
</div>
<div className="space-y-1">
<Label className="text-sm font-semibold">Specifications</Label>
<Textarea
name="description"
placeholder="Write full specifications text information description details here..."
value={form.description}
onChange={handleChange}
className="min-h-[140px] text-base"
/>
</div>
</div>
</div>
<div className="space-y-6">
<h3 className="font-bold text-base border-b pb-2">Image Gallery & Marketing SEO Engine</h3>
<div className="space-y-4 p-5 border rounded-md bg-muted/20">
<div className="flex items-center justify-between">
<p className="text-base font-bold">Gallery Media Content</p>
<Button type="button" variant="outline" size="sm" onClick={handleAddImageField}>
+ Add Photo
</Button>
</div>
{form.images?.length === 0 ? (
<p className="text-sm text-muted-foreground italic text-center py-4">No gallery items attached.</p>
) : (
<div className="space-y-4 max-h-[300px] overflow-y-auto pr-1">
{form.images.map((img: any, idx: number) => (
<div key={idx} className="border rounded-lg p-4 space-y-3 bg-background relative shadow-sm">
<div className="flex justify-between items-center">
<span className="text-xs font-bold font-mono uppercase tracking-wider text-muted-foreground">
Asset Resource #{idx + 1}
</span>
<Button
type="button"
variant="ghost"
size="sm"
className="text-red-500 h-7 px-2"
onClick={() => handleRemoveImageField(idx)}
>
Delete
</Button>
</div>
<div className="space-y-2">
<Label className="text-xs">Dynamic Storage Upload CDN Engine Image Target</Label>
<BytescaleUploader
value={img.url}
folderPath="/facilities"
onChange={(url) => handleImageChange(idx, 'url', url)}
/>
</div>
<div className="space-y-1">
<Input
placeholder="Accessibility Alternative Image Text String..."
value={img.altText}
onChange={(e) => handleImageChange(idx, 'altText', e.target.value)}
className="h-8 text-xs"
/>
</div>
</div>
))}
</div>
)}
</div>
<div className="space-y-4 p-5 border rounded-md bg-muted/20">
<p className="text-base font-bold">Search Metadata Configurations (SEO)</p>
<div className="space-y-2">
<Label className="text-sm font-semibold">Dynamic Header SEO Title</Label>
<Input
name="seoTitle"
placeholder="Advanced Facility Features Setup..."
value={form.seoTitle}
onChange={handleChange}
className="text-base"
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">Engine Meta Crawler Snippet Description</Label>
<Textarea
name="metaDescription"
placeholder="Enter target Google crawler index info context snippet description..."
value={form.metaDescription}
onChange={handleChange}
className="min-h-[80px] text-base"
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">Focus Core Optimization Target Phrase</Label>
<Input
name="focusKeyphrase"
placeholder="best specialized diagnostic lab facility"
value={form.focusKeyphrase}
onChange={handleChange}
className="text-base"
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">URL Custom Route Slug Segment</Label>
<Input
name="slug"
placeholder="advanced-mri-center"
value={form.slug}
onChange={handleChange}
className="text-base"
/>
<p className="text-xs text-muted-foreground font-mono">
Routing Path Output: /facilities/{form.slug || 'slug-placeholder'}
</p>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">Social Index Keywords Engine Tags</Label>
<div className="flex flex-wrap gap-2 border rounded-md p-3 min-h-[48px] bg-background">
{form.tags?.map((tag: string, i: number) => (
<div
key={i}
className="bg-primary/10 text-primary px-3 py-1 rounded-full text-sm flex items-center gap-2"
>
{tag}
<button
type="button"
onClick={() =>
setForm({ ...form, tags: form.tags.filter((_: string, idx: number) => idx !== i) })
}
>
×
</button>
</div>
))}
<Input
placeholder="Type keyword entity context strings and press Enter"
className="border-0 shadow-none focus-visible:ring-0 min-w-[200px]"
onKeyDown={(e) => {
if (e.key === 'Enter' && e.currentTarget.value.trim()) {
e.preventDefault();
setForm({ ...form, tags: [...(form.tags || []), e.currentTarget.value.trim()] });
e.currentTarget.value = '';
}
}}
/>
</div>
</div>
<div className="border-t pt-4 space-y-3">
<p className="text-sm font-bold text-muted-foreground">Social Graph Share Parameters (OG Settings)</p>
<div className="space-y-1">
<Label className="text-xs">OG Share Title</Label>
<Input name="ogTitle" value={form.ogTitle} onChange={handleChange} className="h-9 text-sm" />
</div>
<div className="space-y-1">
<Label className="text-xs">OG Share Summary</Label>
<Textarea
name="ogDescription"
value={form.ogDescription}
onChange={handleChange}
className="min-h-[60px] text-sm"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">OG Share Branding Image Media</Label>
<BytescaleUploader
value={form.ogImage}
folderPath="/seo"
onChange={(url) => setForm({ ...form, ogImage: url })}
/>
</div>
</div>
</div>
</div>
</div>
</div>
<DialogFooter className="p-6 border-t bg-background z-10 mt-0">
<Button variant="ghost" onClick={() => onOpenChange(false)} className="text-base">
Cancel
</Button>
<Button onClick={onSubmit} className="px-10 text-base">
{editing ? 'Commit Modifications' : 'Publish Asset Facility'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,209 @@
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 { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { BytescaleUploader } from '@/components/BytescaleUploader/BytescaleUploader';
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
editingReview: any;
reviewForm: any;
setReviewForm: any;
onSave: () => void;
}
export default function GoogleReviewModal({
open,
onOpenChange,
editingReview,
reviewForm,
setReviewForm,
onSave,
}: Props) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-full !max-w-2xl h-auto max-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">
{editingReview ? 'Edit Google Review' : 'Create Google Review'}
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto p-6 space-y-6">
<div className="space-y-4">
<div className="border-b pb-2">
<h3 className="text-lg font-bold">Reviewer Information</h3>
<p className="text-sm text-muted-foreground">Details about the customer leaving the review</p>
</div>
<div className="space-y-2">
<Label className="font-semibold">Reviewer Name</Label>
<Input
value={reviewForm.reviewerName || ''}
placeholder="e.g., John Doe"
onChange={(e) =>
setReviewForm({
...reviewForm,
reviewerName: e.target.value,
})
}
/>
</div>
<div className="space-y-2">
<Label className="font-semibold">Reviewer Profile Picture (Optional)</Label>
<BytescaleUploader
value={reviewForm.reviewerImage || ''}
folderPath="/reviews"
onChange={(url) =>
setReviewForm({
...reviewForm,
reviewerImage: url,
})
}
/>
</div>
</div>
<div className="space-y-4">
<div className="border-b pb-2">
<h3 className="text-lg font-bold">Review Content</h3>
<p className="text-sm text-muted-foreground">Ratings and testimonial text</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="font-semibold">Rating (1-5)</Label>
<Select
value={String(reviewForm.rating || 5)}
onValueChange={(val) =>
setReviewForm({
...reviewForm,
rating: Number(val),
})
}
>
<SelectTrigger>
<SelectValue placeholder="Select a rating" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1 Star</SelectItem>
<SelectItem value="2">2 Stars</SelectItem>
<SelectItem value="3">3 Stars</SelectItem>
<SelectItem value="4">4 Stars</SelectItem>
<SelectItem value="5">5 Stars</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="font-semibold">Review Date (Optional)</Label>
<Input
type="date"
value={reviewForm.reviewDate ? new Date(reviewForm.reviewDate).toISOString().split('T')[0] : ''}
onChange={(e) =>
setReviewForm({
...reviewForm,
reviewDate: e.target.value ? new Date(e.target.value).toISOString() : null,
})
}
/>
</div>
</div>
<div className="space-y-2">
<Label className="font-semibold">Review Message</Label>
<Textarea
rows={4}
value={reviewForm.review || ''}
placeholder="Share the customer experience..."
onChange={(e) =>
setReviewForm({
...reviewForm,
review: e.target.value,
})
}
/>
</div>
<div className="space-y-2">
<Label className="font-semibold">Original Google Review URL (Optional)</Label>
<Input
value={reviewForm.googleReviewUrl || ''}
placeholder="e.g., https://g.co/kgs/..."
onChange={(e) =>
setReviewForm({
...reviewForm,
googleReviewUrl: e.target.value,
})
}
/>
</div>
</div>
<div className="space-y-4 border-t pt-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label className="font-semibold">Sorting Rank</Label>
<Input
type="number"
value={reviewForm.sortOrder ?? 1000}
onChange={(e) =>
setReviewForm({
...reviewForm,
sortOrder: Number(e.target.value),
})
}
/>
</div>
<div className="flex items-center justify-between border rounded-xl p-4 bg-muted/30 col-span-1 md:col-span-1">
<div>
<p className="font-semibold text-sm">Featured</p>
</div>
<Switch
checked={!!reviewForm.isFeatured}
onCheckedChange={(val) =>
setReviewForm({
...reviewForm,
isFeatured: val,
})
}
/>
</div>
<div className="flex items-center justify-between border rounded-xl p-4 bg-muted/30 col-span-1 md:col-span-1">
<div>
<p className="font-semibold text-sm">Active Visibility</p>
</div>
<Switch
checked={!!reviewForm.isActive}
onCheckedChange={(val) =>
setReviewForm({
...reviewForm,
isActive: val,
})
}
/>
</div>
</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}>
{editingReview ? 'Save Changes' : 'Add Review'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -120,22 +120,40 @@ export default function HealthPackageModal({
/>
</div>
<div className="flex items-center justify-between border rounded-xl p-4 bg-muted/30">
<div>
<p className="font-semibold">Active Visibility</p>
<div className="grid grid-cols-2 gap-4">
<div className="flex items-center justify-between border rounded-xl p-4 bg-muted/30">
<div>
<p className="font-semibold">Active</p>
<p className="text-sm text-muted-foreground">Show publicly</p>
</div>
<p className="text-sm text-muted-foreground">Show this package publicly</p>
<Switch
checked={pkgForm.isActive}
onCheckedChange={(val) =>
setPkgForm({
...pkgForm,
isActive: val,
})
}
/>
</div>
<Switch
checked={pkgForm.isActive}
onCheckedChange={(val) =>
setPkgForm({
...pkgForm,
isActive: val,
})
}
/>
<div className="flex items-center justify-between border rounded-xl p-4 bg-muted/30">
<div>
<p className="font-semibold">Featured</p>
<p className="text-sm text-muted-foreground">Show on homepage</p>
</div>
<Switch
checked={pkgForm.isFeatured || false}
onCheckedChange={(val) =>
setPkgForm({
...pkgForm,
isFeatured: val,
})
}
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
@@ -65,6 +65,8 @@ export default function Sidebar() {
path: '/insurance-partner',
},
{ name: 'Accreditation', path: '/accreditation' },
{ name: 'Facility', path: '/facility' },
{ name: 'Google Review', path: '/reviews' },
],
},
];
+44 -4
View File
@@ -51,6 +51,7 @@ export default function DoctorPage() {
workingStatus: '',
qualification: '',
isActive: true,
isFeatured: false,
globalSortOrder: 0,
departments: [],
professionalSummary: '',
@@ -156,6 +157,22 @@ export default function DoctorPage() {
}
};
const handleToggleFeatured = async (doc: any) => {
try {
const newFeaturedStatus = !doc.isFeatured;
const payload = {
isFeatured: newFeaturedStatus,
};
await updateDoctorApi(doc.doctorId, payload, 'toggleFeatured');
fetchAll();
} catch (err) {
console.error('Failed to update featured status', err);
}
};
function handleDepartmentToggle(depId: string) {
const exists = form.departments.find((d: any) => d.departmentId === depId);
if (exists) {
@@ -201,6 +218,7 @@ export default function DoctorPage() {
experience: '',
professionalSummary: '',
isActive: true,
isFeatured: false,
globalSortOrder: 0,
specializations: [
{
@@ -235,6 +253,7 @@ export default function DoctorPage() {
workingStatus: doc.workingStatus,
qualification: doc.qualification,
isActive: doc.isActive ?? true,
isFeatured: doc.isFeatured ?? false,
globalSortOrder: doc.globalSortOrder ?? 0,
experience: doc.experience || '',
professionalSummary: doc.professionalSummary || '',
@@ -353,14 +372,15 @@ export default function DoctorPage() {
<CardContent className="p-0 sm:p-6 space-y-4">
<div className="rounded-md border overflow-x-auto overflow-y-auto max-h-[650px] relative">
<Table className="w-full min-w-[1100px] table-fixed border-separate border-spacing-0">
<Table className="w-full min-w-[1200px] 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-[180px] bg-background text-sm font-bold">Doctor Info</TableHead>
<TableHead className="w-[150px] bg-background text-sm font-bold">Designation</TableHead>
<TableHead className="w-[220px] bg-background text-sm font-bold">Departments (Hierarchy)</TableHead>
<TableHead className="w-[80px] bg-background text-sm font-bold">Status (Active)</TableHead>
<TableHead className="w-[100px] bg-background text-sm font-bold">Status (Active)</TableHead>
<TableHead className="w-[100px] bg-background text-sm font-bold">Featured</TableHead>
<TableHead className="w-[80px] bg-background text-right text-sm font-bold">Actions</TableHead>
</TableRow>
</TableHeader>
@@ -368,13 +388,13 @@ export default function DoctorPage() {
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-10">
<TableCell colSpan={7} className="text-center py-10">
<Loader2 className="h-8 w-8 animate-spin mx-auto" />
</TableCell>
</TableRow>
) : currentItems.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground py-10 text-base">
<TableCell colSpan={7} className="text-center text-muted-foreground py-10 text-base">
No doctors found
</TableCell>
</TableRow>
@@ -423,6 +443,15 @@ export default function DoctorPage() {
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Switch checked={doc.isFeatured || false} onCheckedChange={() => handleToggleFeatured(doc)} />
<Badge variant={doc.isFeatured ? 'default' : 'secondary'}>
{doc.isFeatured ? 'Featured' : '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" onClick={() => handlePreview(doc)}>
@@ -508,6 +537,17 @@ export default function DoctorPage() {
/>
</div>
<div className="flex items-center justify-between p-3 border rounded-md bg-muted/30">
<Label htmlFor="isFeatured" className="text-base font-semibold cursor-pointer">
Featured
</Label>
<Switch
id="isFeatured"
checked={form.isFeatured}
onCheckedChange={(val) => setForm({ ...form, isFeatured: val })}
/>
</div>
<div className="space-y-1">
<Label htmlFor="globalSortOrder" className="text-sm font-semibold">
Sort Priority (Lower numbers show first)
+406
View File
@@ -0,0 +1,406 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import toast from 'react-hot-toast';
import { AxiosError } from 'axios';
import { Eye, Plus, Pencil, Loader2, RefreshCw, ChevronLeft, ChevronRight, Trash2 } from 'lucide-react';
import { getAllFacilitiesApi, createFacilityApi, updateFacilityApi, deleteFacilityApi, Facility } from '@/api/facility';
import { getDepartmentsApi, Department } from '@/api/department';
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 { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import SeoPreview from '@/components/SeoPreview/SeoPreview';
import FacilityModal from '@/components/FacilityModal/FacilityModal';
export default function FacilityPage() {
const WEBSITE_URL = import.meta.env.VITE_WEBSITE_URL;
const [facilities, setFacilities] = useState<Facility[]>([]);
const [departments, setDepartments] = useState<Department[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [openModal, setOpenModal] = useState(false);
const [editing, setEditing] = useState<Facility | null>(null);
const [openSeoPreview, setOpenSeoPreview] = useState(false);
const [previewFacility, setPreviewFacility] = useState<any>(null);
const [searchText, setSearchText] = useState('');
const [filterDepartment, setFilterDepartment] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 10;
const [form, setForm] = useState<any>({
facilityId: '',
name: '',
slug: '',
shortDescription: '',
description: '',
videoUrl: '',
isActive: true,
isFeatured: false,
sortOrder: 1000,
departmentId: '',
images: [],
seoTitle: '',
metaDescription: '',
focusKeyphrase: '',
tags: [],
ogTitle: '',
ogDescription: '',
ogImage: '',
});
const fetchData = useCallback(async () => {
setLoading(true);
setError('');
try {
const [facRes, depRes] = await Promise.all([getAllFacilitiesApi(), getDepartmentsApi()]);
setFacilities(facRes?.data || []);
setDepartments(depRes?.data || []);
} catch (err) {
if (err instanceof AxiosError) {
setError(err.response?.data?.message || 'Failed to load system facilities');
} else {
setError('An unexpected system error occurred');
}
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
const filteredFacilities = useMemo(() => {
return facilities.filter((fac) => {
const matchesSearch =
fac.name.toLowerCase().includes(searchText.toLowerCase()) ||
fac.facilityId.toLowerCase().includes(searchText.toLowerCase());
const matchesDept = filterDepartment
? fac.department?.id.toString() === filterDepartment || fac.department?.departmentId === filterDepartment
: true;
return matchesSearch && matchesDept;
});
}, [facilities, searchText, filterDepartment]);
useEffect(() => {
setCurrentPage(1);
}, [searchText, filterDepartment]);
const totalPages = Math.ceil(filteredFacilities.length / itemsPerPage);
const indexOfLastItem = currentPage * itemsPerPage;
const indexOfFirstItem = indexOfLastItem - itemsPerPage;
const currentItems = filteredFacilities.slice(indexOfFirstItem, indexOfLastItem);
const handleToggleStatus = async (fac: Facility) => {
try {
await updateFacilityApi(fac.facilityId, { isActive: !fac.isActive }, 'toggleStatus');
toast.success(`Facility ${fac.isActive ? 'hidden' : 'activated'} successfully`);
fetchData();
} catch (err) {
console.error(err);
}
};
const handleToggleFeatured = async (fac: Facility) => {
try {
await updateFacilityApi(fac.facilityId, { isFeatured: !fac.isFeatured }, 'toggleFeatured');
toast.success(`Facility status modified successfully`);
fetchData();
} catch (err) {
console.error(err);
}
};
const openAdd = () => {
setEditing(null);
setForm({
facilityId: '',
name: '',
slug: '',
shortDescription: '',
description: '',
videoUrl: '',
isActive: true,
isFeatured: false,
sortOrder: 1000,
departmentId: '',
images: [],
seoTitle: '',
metaDescription: '',
focusKeyphrase: '',
tags: [],
ogTitle: '',
ogDescription: '',
ogImage: '',
});
setOpenModal(true);
};
const openEdit = async (fac: Facility) => {
setEditing(fac);
setForm({
facilityId: fac.facilityId,
name: fac.name,
shortDescription: fac.shortDescription || '',
description: fac.description || '',
videoUrl: fac.videoUrl || '',
isActive: fac.isActive,
isFeatured: fac.isFeatured,
sortOrder: fac.sortOrder,
departmentId: fac.department?.departmentId || '',
images:
fac.images?.map((img) => ({
url: img.url,
altText: img.altText || '',
description: img.description || '',
})) || [],
seoTitle: fac.seo?.seoTitle || '',
metaDescription: fac.seo?.metaDescription || '',
focusKeyphrase: fac.seo?.focusKeyphrase || '',
slug: fac.seo?.slug || fac.slug,
tags: fac.seo?.tags || [],
ogTitle: fac.seo?.ogTitle || '',
ogDescription: fac.seo?.ogDescription || '',
ogImage: fac.seo?.ogImage || '',
});
setOpenModal(true);
};
const handleDelete = async (facilityId: string) => {
if (
!window.confirm(
'Are you entirely sure you want to delete this facility profile? This structural shift cannot be reversed.'
)
)
return;
try {
await deleteFacilityApi(facilityId);
fetchData();
} catch (err) {
console.error(err);
}
};
const handlePreview = (fac: Facility) => {
setPreviewFacility(fac);
setOpenSeoPreview(true);
};
const handleSubmit = async () => {
if (!form.facilityId) return toast.error('Facility ID signature is required');
if (!form.name?.trim()) return toast.error('Facility Name string configuration is required');
if (!form.slug?.trim()) return toast.error('Valid clean URL slug structure is required');
try {
const payload = {
...form,
departmentId: form.departmentId && form.departmentId !== '' ? form.departmentId : null,
};
if (editing) {
await updateFacilityApi(editing.facilityId, payload, 'updateDetails');
} else {
await createFacilityApi(payload);
}
setOpenModal(false);
fetchData();
} catch (error) {
console.error(error);
}
};
const getFacilityUrl = (fac: any) => {
return `${WEBSITE_URL}/facilities/${fac?.slug || fac?.facilityId}`;
};
return (
<div className="p-6 space-y-6">
<div className="flex flex-col md:flex-row md:justify-between md:items-center gap-4">
<h1 className="text-3xl font-bold">Medical & Hospital Facilities</h1>
<div className="flex flex-wrap gap-3">
<Input
placeholder="Search facility profiles..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="w-[250px] text-base"
/>
<select
value={filterDepartment}
onChange={(e) => setFilterDepartment(e.target.value)}
className="flex h-10 w-[220px] rounded-md border border-input bg-background px-3 py-2 text-base focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<option value="">All Departments</option>
{departments.map((dep) => (
<option key={dep.departmentId} value={dep.departmentId}>
{dep.name}
</option>
))}
</select>
<Button variant="outline" onClick={fetchData} disabled={loading} className="text-base">
<RefreshCw className="mr-2 h-5 w-5" />
Refresh
</Button>
<Button onClick={openAdd} className="text-base">
<Plus className="mr-2 h-5 w-5" />
Add Facility
</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">Facilities Portfolio</CardTitle>
</CardHeader>
<CardContent className="p-0 sm:p-6 space-y-4">
<div className="rounded-md border overflow-x-auto overflow-y-auto max-h-[650px] relative">
<Table className="w-full min-w-[1100px] 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">Order</TableHead>
<TableHead className="w-[250px] bg-background text-sm font-bold">Facility Name</TableHead>
<TableHead className="w-[180px] bg-background text-sm font-bold">Linked Department</TableHead>
<TableHead className="w-[120px] bg-background text-sm font-bold">Status (Active)</TableHead>
<TableHead className="w-[100px] bg-background text-sm font-bold">Featured</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>
) : currentItems.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground py-10 text-base">
No explicit healthcare or asset facilities found.
</TableCell>
</TableRow>
) : (
currentItems.map((fac) => (
<TableRow key={fac.facilityId} className="hover:bg-muted/50">
<TableCell className="font-mono text-sm">{fac.sortOrder}</TableCell>
<TableCell>
<div className="font-semibold text-base truncate" title={fac.name}>
{fac.name}
</div>
<div className="text-xs text-muted-foreground font-mono truncate mt-0.5">/{fac.slug}</div>
</TableCell>
<TableCell>
{fac.department ? (
<Badge variant="secondary" className="text-xs">
{fac.department.name}
</Badge>
) : (
<span className="text-xs text-muted-foreground italic">None Assigned</span>
)}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Switch checked={fac.isActive} onCheckedChange={() => handleToggleStatus(fac)} />
<Badge variant={fac.isActive ? 'default' : 'secondary'}>
{fac.isActive ? 'Active' : 'Hidden'}
</Badge>
</div>
</TableCell>
<TableCell>
<Switch checked={fac.isFeatured} onCheckedChange={() => handleToggleFeatured(fac)} />
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button size="icon" variant="ghost" className="h-9 w-9" onClick={() => handlePreview(fac)}>
<Eye className="h-4 w-4" />
</Button>
<Button size="icon" variant="ghost" className="h-9 w-9" onClick={() => openEdit(fac)}>
<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={() => handleDelete(fac.facilityId)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{!loading && filteredFacilities.length > 0 && (
<div className="flex items-center justify-between px-2 py-6 border-t">
<div className="text-base text-muted-foreground">
Showing <span className="font-semibold">{indexOfFirstItem + 1}</span> to{' '}
<span className="font-semibold">{Math.min(indexOfLastItem, filteredFacilities.length)}</span> of{' '}
<span className="font-semibold">{filteredFacilities.length}</span> entries
</div>
<div className="flex items-center gap-6">
<div className="text-base font-semibold">
Page {currentPage} of {totalPages}
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="icon"
className="h-10 w-10"
onClick={() => setCurrentPage((p) => Math.max(p - 1, 1))}
disabled={currentPage === 1}
>
<ChevronLeft className="h-5 w-5" />
</Button>
<Button
variant="outline"
size="icon"
className="h-10 w-10"
onClick={() => setCurrentPage((p) => Math.min(p + 1, totalPages))}
disabled={currentPage === totalPages || totalPages === 0}
>
<ChevronRight className="h-5 w-5" />
</Button>
</div>
</div>
</div>
)}
</CardContent>
</Card>
<FacilityModal
open={openModal}
onOpenChange={setOpenModal}
editing={editing}
form={form}
setForm={setForm}
departments={departments}
onSubmit={handleSubmit}
/>
<SeoPreview
open={openSeoPreview}
onOpenChange={setOpenSeoPreview}
previewData={previewFacility}
url={getFacilityUrl(previewFacility)}
/>
</div>
);
}
+326
View File
@@ -0,0 +1,326 @@
import { useState, useEffect, useCallback } from 'react';
import toast from 'react-hot-toast';
import { AxiosError } from 'axios';
import {
getGoogleReviewsApi,
createGoogleReviewApi,
updateGoogleReviewApi,
deleteGoogleReviewApi,
GoogleReview,
} from '@/api/googleReview';
import GoogleReviewModal from '@/components/GoogleReviewModal/GoogleReviewModal';
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, Star } from 'lucide-react';
export default function GoogleReviewPage() {
const [reviews, setReviews] = useState<GoogleReview[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [reviewModal, setReviewModal] = useState(false);
const [editingReview, setEditingReview] = useState<GoogleReview | null>(null);
const [searchText, setSearchText] = useState('');
const [reviewForm, setReviewForm] = useState<Partial<GoogleReview>>({
reviewerName: '',
reviewerImage: '',
rating: 5,
review: '',
reviewDate: null,
googleReviewUrl: '',
isFeatured: false,
isActive: true,
sortOrder: 1000,
});
const fetchData = useCallback(async () => {
setLoading(true);
setError('');
try {
const res = await getGoogleReviewsApi();
setReviews(res.data || []);
} catch (err) {
if (err instanceof AxiosError) {
setError(err.response?.data?.message || 'Failed to sync Google review records.');
} else {
setError('An unhandled database communication error occurred.');
}
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
const handleToggleStatus = async (review: GoogleReview) => {
if (review.id === undefined) return;
try {
await updateGoogleReviewApi(review.id, { isActive: !review.isActive });
toast.success(`Review status updated successfully`);
fetchData();
} catch (err) {
console.error(err);
}
};
const handleToggleFeatured = async (review: GoogleReview) => {
if (review.id === undefined) return;
try {
await updateGoogleReviewApi(review.id, { isFeatured: !review.isFeatured });
toast.success(`Review featured status updated`);
fetchData();
} catch (err) {
console.error(err);
}
};
const handleDeleteReview = async (id: number) => {
const confirmDelete = window.confirm(
'Are you completely sure you want to remove this testimonial record? This step is irreversible.'
);
if (!confirmDelete) return;
try {
await deleteGoogleReviewApi(id);
fetchData();
} catch (err) {
console.error(err);
}
};
const openAddReview = () => {
setEditingReview(null);
setReviewForm({
reviewerName: '',
reviewerImage: '',
rating: 5,
review: '',
reviewDate: null,
googleReviewUrl: '',
isFeatured: false,
isActive: true,
sortOrder: 1000,
});
setReviewModal(true);
};
const openEditReview = (review: GoogleReview) => {
setEditingReview(review);
setReviewForm({ ...review });
setReviewModal(true);
};
const saveReview = async () => {
if (!reviewForm.reviewerName) return toast.error('Reviewer name is required.');
if (!reviewForm.review) return toast.error('Review message content is required.');
try {
const finalData = { ...reviewForm };
if (editingReview?.id) {
const changedFields: Record<string, any> = {};
Object.keys(finalData).forEach((key) => {
const k = key as keyof GoogleReview;
if (JSON.stringify(finalData[k]) !== JSON.stringify(editingReview[k])) {
changedFields[k] = finalData[k];
}
});
delete changedFields.id;
if (Object.keys(changedFields).length === 0) {
setReviewModal(false);
return;
}
await updateGoogleReviewApi(editingReview.id, changedFields);
} else {
await createGoogleReviewApi(finalData as GoogleReview);
}
setReviewModal(false);
fetchData();
} catch (err) {
console.error(err);
}
};
const filteredReviews = reviews.filter((r) => r.reviewerName.toLowerCase().includes(searchText.toLowerCase()));
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">Google Reviews</h1>
</div>
<div className="flex flex-wrap gap-3">
<Input
placeholder="Search reviews by name..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="w-[260px] text-base"
/>
<Button variant="outline" onClick={fetchData} disabled={loading} className="text-base">
<RefreshCw className="mr-2 h-5 w-5" />
Refresh
</Button>
<Button onClick={openAddReview} className="text-base">
<Plus className="mr-2 h-5 w-5" />
Add Review
</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">Testimonials Sequence Directory</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-[900px] table-fixed border-separate border-spacing-0">
<TableHeader className="sticky top-0 z-20 bg-background shadow-sm">
<TableRow>
<TableHead className="w-[70px] bg-background text-sm font-bold">Order</TableHead>
<TableHead className="w-[110px] bg-background text-sm font-bold">Avatar</TableHead>
<TableHead className="w-[170px] bg-background text-sm font-bold">Reviewer</TableHead>
<TableHead className="w-[90px] bg-background text-sm font-bold">Rating</TableHead>
<TableHead className="w-[260px] bg-background text-sm font-bold">Testimonial</TableHead>
<TableHead className="w-[110px] bg-background text-sm font-bold">Source Link</TableHead>
<TableHead className="w-[100px] bg-background text-sm font-bold">Featured</TableHead>
<TableHead className="w-[100px] bg-background text-sm font-bold">Status</TableHead>
<TableHead className="w-[100px] bg-background text-right text-sm font-bold">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={9} className="text-center py-10">
<Loader2 className="h-8 w-8 animate-spin mx-auto" />
</TableCell>
</TableRow>
) : filteredReviews.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="text-center text-muted-foreground py-10 text-base">
No reviews found matching your criteria.
</TableCell>
</TableRow>
) : (
filteredReviews.map((review) => (
<TableRow key={review.id} className="hover:bg-muted/50">
<TableCell className="font-mono text-sm">{review.sortOrder}</TableCell>
<TableCell>
{review.reviewerImage ? (
<img
src={review.reviewerImage}
alt={review.reviewerName}
className="w-10 h-10 rounded-full object-cover border"
/>
) : (
<div className="w-10 h-10 rounded-full bg-secondary flex items-center justify-center border text-muted-foreground font-bold">
{review.reviewerName.charAt(0).toUpperCase()}
</div>
)}
</TableCell>
<TableCell>
<div className="font-semibold text-base truncate" title={review.reviewerName}>
{review.reviewerName}
</div>
{review.reviewDate && (
<div className="text-xs text-muted-foreground">
{new Date(review.reviewDate).toLocaleDateString()}
</div>
)}
</TableCell>
<TableCell>
<div className="flex items-center gap-1 font-medium text-sm">
{review.rating}
<Star className="h-4 w-4 fill-amber-400 text-amber-500" />
</div>
</TableCell>
<TableCell>
<div className="text-sm truncate max-w-[240px]" title={review.review}>
"{review.review}"
</div>
</TableCell>
<TableCell>
{review.googleReviewUrl ? (
<a
href={review.googleReviewUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-sky-600 hover:underline flex items-center gap-1"
>
Link <ExternalLink className="h-3 w-3" />
</a>
) : (
<span className="text-xs text-muted-foreground italic">-</span>
)}
</TableCell>
<TableCell>
<Switch checked={review.isFeatured} onCheckedChange={() => handleToggleFeatured(review)} />
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Switch checked={review.isActive} onCheckedChange={() => handleToggleStatus(review)} />
<Badge variant={review.isActive ? 'default' : 'secondary'}>
{review.isActive ? 'Active' : 'Disabled'}
</Badge>
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button
size="icon"
variant="ghost"
className="h-8 w-8 text-muted-foreground hover:text-foreground"
onClick={() => openEditReview(review)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-8 w-8 text-red-500 hover:text-red-600"
onClick={() => review.id && handleDeleteReview(review.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
<GoogleReviewModal
open={reviewModal}
onOpenChange={setReviewModal}
editingReview={editingReview}
reviewForm={reviewForm}
setReviewForm={setReviewForm}
onSave={saveReview}
/>
</div>
);
}
+14
View File
@@ -62,6 +62,7 @@ export default function HealthPackagePage() {
discountedPrice: undefined,
categoryId: 0,
isActive: true,
isFeatured: false,
sortOrder: 1000,
seo: {
seoTitle: '',
@@ -167,6 +168,7 @@ export default function HealthPackagePage() {
discountedPrice: undefined,
categoryId: categories[0]?.id || 0,
isActive: true,
isFeatured: false,
sortOrder: 1000,
seo: {
seoTitle: '',
@@ -366,6 +368,7 @@ export default function HealthPackagePage() {
<TableHead className="w-[150px] bg-background text-sm font-bold">Category</TableHead>
<TableHead className="w-[150px] bg-background text-sm font-bold">Pricing</TableHead>
<TableHead className="w-[120px] bg-background text-sm font-bold">Status</TableHead>
<TableHead className="w-[100px] bg-background text-sm font-bold">Featured</TableHead>
<TableHead className="w-[120px] bg-background text-right text-sm font-bold">Actions</TableHead>
</TableRow>
</TableHeader>
@@ -418,6 +421,17 @@ export default function HealthPackagePage() {
</Badge>
</div>
</TableCell>
<TableCell>
<Switch
checked={pkg.isFeatured}
onCheckedChange={async () => {
await updateHealthPackageApi(pkg.id!, {
isFeatured: !pkg.isFeatured,
});
fetchData();
}}
/>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button