Compare commits

..

2 Commits

Author SHA1 Message Date
rishalkv 01998b3413 chore: add health check schema 2026-05-19 17:30:27 +05:30
rishalkv 4750390368 feat:add doc seo and content 2026-05-19 15:53:31 +05:30
24 changed files with 625 additions and 1365 deletions
@@ -0,0 +1,37 @@
/*
Warnings:
- You are about to drop the `HealthCheckCategory` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `HealthPackage` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `HealthPackageInquiry` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropForeignKey
ALTER TABLE "HealthPackage" DROP CONSTRAINT "HealthPackage_categoryId_fkey";
-- DropForeignKey
ALTER TABLE "HealthPackageInquiry" DROP CONSTRAINT "HealthPackageInquiry_packageId_fkey";
-- DropTable
DROP TABLE "HealthCheckCategory";
-- DropTable
DROP TABLE "HealthPackage";
-- DropTable
DROP TABLE "HealthPackageInquiry";
-- CreateTable
CREATE TABLE "DoctorSpecialization" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"doctorId" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DoctorSpecialization_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "DoctorSpecialization" ADD CONSTRAINT "DoctorSpecialization_doctorId_fkey" FOREIGN KEY ("doctorId") REFERENCES "Doctor"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Doctor" ADD COLUMN "professionalSummary" TEXT;
@@ -0,0 +1,7 @@
-- AlterTable
ALTER TABLE "Doctor" ADD COLUMN "experience" INTEGER,
ADD COLUMN "focusKeyphrase" TEXT,
ADD COLUMN "metaDescription" TEXT,
ADD COLUMN "seoTitle" TEXT,
ADD COLUMN "slug" TEXT,
ADD COLUMN "tags" TEXT[];
@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "Doctor" ADD COLUMN "ogDescription" TEXT,
ADD COLUMN "ogImage" TEXT,
ADD COLUMN "ogTitle" TEXT;
@@ -0,0 +1,19 @@
-- CreateTable
CREATE TABLE "Seo" (
"id" SERIAL NOT NULL,
"seoTitle" TEXT,
"metaDescription" TEXT,
"focusKeyphrase" TEXT,
"slug" TEXT,
"tags" TEXT[],
"ogTitle" TEXT,
"ogDescription" TEXT,
"ogImage" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Seo_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Seo_slug_key" ON "Seo"("slug");
@@ -0,0 +1,30 @@
/*
Warnings:
- You are about to drop the column `focusKeyphrase` on the `Doctor` table. All the data in the column will be lost.
- You are about to drop the column `metaDescription` on the `Doctor` table. All the data in the column will be lost.
- You are about to drop the column `ogDescription` on the `Doctor` table. All the data in the column will be lost.
- You are about to drop the column `ogImage` on the `Doctor` table. All the data in the column will be lost.
- You are about to drop the column `ogTitle` on the `Doctor` table. All the data in the column will be lost.
- You are about to drop the column `seoTitle` on the `Doctor` table. All the data in the column will be lost.
- You are about to drop the column `slug` on the `Doctor` table. All the data in the column will be lost.
- You are about to drop the column `tags` on the `Doctor` table. All the data in the column will be lost.
- A unique constraint covering the columns `[seoId]` on the table `Doctor` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "Doctor" DROP COLUMN "focusKeyphrase",
DROP COLUMN "metaDescription",
DROP COLUMN "ogDescription",
DROP COLUMN "ogImage",
DROP COLUMN "ogTitle",
DROP COLUMN "seoTitle",
DROP COLUMN "slug",
DROP COLUMN "tags",
ADD COLUMN "seoId" INTEGER;
-- CreateIndex
CREATE UNIQUE INDEX "Doctor_seoId_key" ON "Doctor"("seoId");
-- AddForeignKey
ALTER TABLE "Doctor" ADD CONSTRAINT "Doctor_seoId_fkey" FOREIGN KEY ("seoId") REFERENCES "Seo"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,66 @@
-- CreateTable
CREATE TABLE "HealthCheckCategory" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT,
"description" TEXT,
"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 "HealthCheckCategory_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "HealthPackage" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT,
"price" DECIMAL(10,2),
"image" TEXT,
"discountedPrice" DECIMAL(10,2),
"inclusions" JSONB NOT NULL DEFAULT '{}',
"isActive" BOOLEAN NOT NULL DEFAULT true,
"isFeatured" BOOLEAN NOT NULL DEFAULT false,
"sortOrder" INTEGER NOT NULL DEFAULT 1000,
"categoryId" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "HealthPackage_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "HealthPackageInquiry" (
"id" SERIAL NOT NULL,
"fullName" TEXT NOT NULL,
"mobileNumber" TEXT NOT NULL,
"email" TEXT,
"age" INTEGER,
"gender" TEXT,
"preferredDate" TIMESTAMP(3),
"message" TEXT,
"packageId" INTEGER NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "HealthPackageInquiry_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "HealthCheckCategory_name_key" ON "HealthCheckCategory"("name");
-- CreateIndex
CREATE UNIQUE INDEX "HealthCheckCategory_slug_key" ON "HealthCheckCategory"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "HealthPackage_slug_key" ON "HealthPackage"("slug");
-- AddForeignKey
ALTER TABLE "HealthPackage" ADD CONSTRAINT "HealthPackage_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "HealthCheckCategory"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "HealthPackageInquiry" ADD CONSTRAINT "HealthPackageInquiry_packageId_fkey" FOREIGN KEY ("packageId") REFERENCES "HealthPackage"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -1,50 +0,0 @@
/*
Warnings:
- A unique constraint covering the columns `[seoId]` on the table `Doctor` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "Doctor" ADD COLUMN "professionalSummary" TEXT,
ADD COLUMN "seoId" INTEGER;
-- CreateTable
CREATE TABLE "DoctorSpecialization" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"doctorId" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DoctorSpecialization_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Seo" (
"id" SERIAL NOT NULL,
"seoTitle" TEXT,
"metaDescription" TEXT,
"focusKeyphrase" TEXT,
"slug" TEXT,
"tags" TEXT[],
"ogTitle" TEXT,
"ogDescription" TEXT,
"ogImage" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Seo_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Seo_slug_key" ON "Seo"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "Doctor_seoId_key" ON "Doctor"("seoId");
-- AddForeignKey
ALTER TABLE "Doctor" ADD CONSTRAINT "Doctor_seoId_fkey" FOREIGN KEY ("seoId") REFERENCES "Seo"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DoctorSpecialization" ADD CONSTRAINT "DoctorSpecialization_doctorId_fkey" FOREIGN KEY ("doctorId") REFERENCES "Doctor"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "Doctor" ADD COLUMN "experience" INTEGER;
@@ -1,14 +0,0 @@
/*
Warnings:
- A unique constraint covering the columns `[seoId]` on the table `HealthPackage` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "HealthPackage" ADD COLUMN "seoId" INTEGER;
-- CreateIndex
CREATE UNIQUE INDEX "HealthPackage_seoId_key" ON "HealthPackage"("seoId");
-- AddForeignKey
ALTER TABLE "HealthPackage" ADD CONSTRAINT "HealthPackage_seoId_fkey" FOREIGN KEY ("seoId") REFERENCES "Seo"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+2 -11
View File
@@ -28,13 +28,12 @@ model Doctor {
qualification String?
isActive Boolean @default(true)
globalSortOrder Int @default(1000)
departments DoctorDepartment[]
appointments Appointment[]
specializations DoctorSpecialization[]
professionalSummary String? @db.Text
seoId Int? @unique
seo Seo? @relation(fields: [seoId], references: [id])
departments DoctorDepartment[]
appointments Appointment[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
@@ -224,7 +223,6 @@ model NewsImage {
createdAt DateTime @default(now())
}
model HealthCheckCategory {
id Int @id @default(autoincrement())
name String @unique
@@ -259,9 +257,6 @@ model HealthPackage {
inquiries HealthPackageInquiry[]
seoId Int? @unique
seo Seo? @relation(fields: [seoId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
@@ -283,8 +278,6 @@ model HealthPackageInquiry {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model DoctorSpecialization {
id Int @id @default(autoincrement())
name String
@@ -298,8 +291,6 @@ model DoctorSpecialization {
model Seo {
id Int @id @default(autoincrement())
doctor Doctor?
healthPackage HealthPackage?
seoTitle String?
metaDescription String? @db.Text
+45 -136
View File
@@ -189,15 +189,7 @@ export const getDoctorsByDepartmentId = async (req, res) => {
doctor: {isActive: true},
},
include: {
doctor: {
include: {
seo: {
select: {
slug: true,
},
},
},
},
doctor: true,
},
orderBy: {sortOrder: "asc"},
});
@@ -208,7 +200,6 @@ export const getDoctorsByDepartmentId = async (req, res) => {
image: d.doctor.image ?? "",
designation: d.doctor.designation,
hierarchyOrder: d.sortOrder,
slug: d.doctor.seo?.slug ?? "",
}));
res.status(200).json({
@@ -249,22 +240,6 @@ export const createDoctor = async (req, res) => {
ogDescription,
ogImage,
} = req.body;
const messages = [];
if (!doctorId) messages.push("Doctor ID is required");
if (!name?.trim()) messages.push("Doctor name is required");
if (!designation?.trim()) messages.push("Designation is required");
if (!qualification?.trim()) messages.push("Qualification is required");
if (!departments || departments.length === 0) {
messages.push("At least one department is required");
}
if (messages.length > 0) {
return res.status(400).json({
success: false,
message: messages.join(", "),
});
}
const seo = await prisma.seo.create({
data: {
seoTitle,
@@ -349,7 +324,7 @@ export const createDoctor = async (req, res) => {
//update doctors
export const updateDoctor = async (req, res) => {
try {
const {doctorId, action} = req.params;
const {doctorId} = req.params;
const {
name,
designation,
@@ -363,58 +338,17 @@ export const updateDoctor = async (req, res) => {
professionalSummary,
seoTitle,
metaDescription,
ogTitle,
ogDescription,
focusKeyphrase,
slug,
tags,
ogImage,
specializations,
} = req.body;
if (!doctorId) {
return res.status(400).json({
success: false,
message: "Doctor ID is required",
});
}
const doctor = await prisma.doctor.findUnique({where: {doctorId}});
if (!doctor)
return res
.status(404)
.json({success: false, message: "Doctor not found"});
if (action === "toggleStatus") {
await prisma.doctor.update({
where: {id: doctor.id},
data: {
isActive: !doctor.isActive,
},
});
return res.status(200).json({
success: true,
message: `Doctor has been ${
doctor.isActive ? "deactivated" : "activated"
} successfully`,
});
}
const messages = [];
if (!doctorId) messages.push("Doctor ID is required");
if (!name?.trim()) messages.push("Doctor name is required");
if (!qualification?.trim()) messages.push("Qualification is required");
if (!designation?.trim()) messages.push("Designation is required");
if (!departments || departments.length === 0) {
messages.push("At least one department is required");
}
if (messages.length > 0) {
return res.status(400).json({
success: false,
message: messages.join(", "),
});
}
await prisma.doctor.update({
where: {id: doctor.id},
@@ -431,7 +365,6 @@ export const updateDoctor = async (req, res) => {
globalSortOrder !== undefined ? Number(globalSortOrder) : undefined,
},
});
if (doctor.seoId) {
await prisma.seo.update({
where: {
@@ -440,9 +373,6 @@ export const updateDoctor = async (req, res) => {
data: {
seoTitle,
metaDescription,
ogTitle,
ogDescription,
ogImage,
focusKeyphrase,
slug: slug ? slug : null,
tags: tags || [],
@@ -451,11 +381,8 @@ export const updateDoctor = async (req, res) => {
} else {
const seo = await prisma.seo.create({
data: {
ogImage,
metaDescription,
seoTitle,
ogDescription,
ogTitle,
metaDescription,
focusKeyphrase,
slug: slug ? slug : null,
tags: tags || [],
@@ -472,66 +399,9 @@ export const updateDoctor = async (req, res) => {
});
}
// Update Departments & Timings
if (Array.isArray(departments)) {
const oldRelations = await prisma.doctorDepartment.findMany({
where: {
doctorId: doctor.id,
},
include: {
timing: true,
},
});
// Delete old timings
for (const rel of oldRelations) {
if (rel.timing) {
await prisma.doctorTiming.deleteMany({
where: {
doctorDepartmentId: rel.id,
},
});
}
}
// Delete old departments
await prisma.doctorDepartment.deleteMany({
where: {
doctorId: doctor.id,
},
});
// Recreate departments + timings
for (const dep of departments) {
const department = await prisma.department.findUnique({
where: {
departmentId: dep.departmentId,
},
});
if (!department) continue;
const doctorDepartment = await prisma.doctorDepartment.create({
data: {
doctorId: doctor.id,
departmentId: department.id,
sortOrder: dep.sortOrder !== undefined ? Number(dep.sortOrder) : 0,
},
});
if (dep.timing && Object.keys(dep.timing).length > 0) {
const {id, doctorDepartmentId, createdAt, updatedAt, ...cleanTiming} =
dep.timing;
await prisma.doctorTiming.create({
data: {
doctorDepartmentId: doctorDepartment.id,
...cleanTiming,
},
});
}
}
}
const hasTimingData = departments?.some(
(dep) => dep.timing && Object.keys(dep.timing).length > 0,
);
// Update Specializations
if (Array.isArray(specializations)) {
@@ -554,6 +424,45 @@ export const updateDoctor = async (req, res) => {
}
}
if (departments && Array.isArray(departments) && hasTimingData) {
const oldRelations = await prisma.doctorDepartment.findMany({
where: {doctorId: doctor.id},
});
for (const rel of oldRelations) {
await prisma.doctorTiming.deleteMany({
where: {doctorDepartmentId: rel.id},
});
}
await prisma.doctorDepartment.deleteMany({
where: {doctorId: doctor.id},
});
for (const dep of departments) {
const targetDept = await prisma.department.findUnique({
where: {departmentId: dep.departmentId},
});
if (!targetDept) continue;
const newDD = await prisma.doctorDepartment.create({
data: {
doctorId: doctor.id,
departmentId: targetDept.id,
sortOrder: dep.sortOrder !== undefined ? Number(dep.sortOrder) : 0,
},
});
if (dep.timing) {
const {id, doctorDepartmentId, createdAt, updatedAt, ...cleanTiming} =
dep.timing;
await prisma.doctorTiming.create({
data: {doctorDepartmentId: newDD.id, ...cleanTiming},
});
}
}
}
res
.status(200)
.json({success: true, message: "Doctor updated successfully"});
@@ -121,10 +121,7 @@ export const getAllPackages = async (req, res) => {
categorySlug ? { category: { slug: categorySlug } } : {},
],
},
include: {
category: true,
seo: true,
},
include: { category: true },
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
});
@@ -151,7 +148,6 @@ export const createPackage = async (req, res) => {
isActive,
isFeatured,
sortOrder,
seo,
} = req.body;
const healthPackage = await prisma.healthPackage.create({
@@ -167,25 +163,6 @@ export const createPackage = async (req, res) => {
isActive: isActive ?? true,
isFeatured: isFeatured ?? false,
sortOrder: sortOrder ? Number(sortOrder) : 1000,
...(seo && {
seo: {
create: {
seoTitle: seo.seoTitle,
metaDescription: seo.metaDescription,
focusKeyphrase: seo.focusKeyphrase,
slug: slug,
tags: seo.tags || [],
ogTitle: seo.ogTitle,
ogDescription: seo.ogDescription,
ogImage: seo.ogImage,
},
},
}),
},
include: {
category: true,
seo: true,
},
});
@@ -206,58 +183,13 @@ export const updatePackage = async (req, res) => {
const data = { ...req.body };
delete data.id;
delete data.category;
delete data.createdAt;
delete data.updatedAt;
delete data.seoId;
if (data.categoryId) data.categoryId = Number(data.categoryId);
if (data.sortOrder) data.sortOrder = Number(data.sortOrder);
const existingPackage = await prisma.healthPackage.findUnique({
where: { id: Number(id) },
select: { slug: true },
});
const seoSlug = data.slug || existingPackage.slug;
const updated = await prisma.healthPackage.update({
where: { id: Number(id) },
data: {
...data,
seo: data.seo
? {
upsert: {
create: {
seoTitle: data.seo.seoTitle,
metaDescription: data.seo.metaDescription,
focusKeyphrase: data.seo.focusKeyphrase,
slug: seoSlug,
tags: data.seo.tags || [],
ogTitle: data.seo.ogTitle,
ogDescription: data.seo.ogDescription,
ogImage: data.seo.ogImage,
},
update: {
seoTitle: data.seo.seoTitle,
metaDescription: data.seo.metaDescription,
focusKeyphrase: data.seo.focusKeyphrase,
slug: seoSlug,
tags: data.seo.tags || [],
ogTitle: data.seo.ogTitle,
ogDescription: data.seo.ogDescription,
ogImage: data.seo.ogImage,
},
},
}
: undefined,
},
include: {
category: true,
seo: true,
},
data,
});
return res
@@ -272,21 +204,11 @@ export const updatePackage = async (req, res) => {
export const deletePackage = async (req, res) => {
try {
const { id } = req.params;
await prisma.healthPackage.delete({
where: { id: Number(id) },
});
return res.status(200).json({
success: true,
message: "Package deleted",
});
await prisma.healthPackage.delete({ where: { id: Number(id) } });
return res.status(200).json({ success: true, message: "Package deleted" });
} catch (error) {
console.error(error);
return res.status(500).json({
success: false,
message: "Delete failed",
});
return res.status(500).json({ success: false, message: "Delete failed" });
}
};
@@ -441,11 +363,7 @@ export const getPackageBySlug = async (req, res) => {
const { slug } = req.params;
const healthPackage = await prisma.healthPackage.findFirst({
where: { slug, isActive: true },
include: {
category: true,
seo: true,
},
include: { category: true },
});
if (!healthPackage) {
@@ -454,10 +372,7 @@ export const getPackageBySlug = async (req, res) => {
.json({ success: false, message: "Package not found" });
}
return res.status(200).json({
success: true,
data: healthPackage,
});
return res.status(200).json({ success: true, data: healthPackage });
} catch (error) {
console.error(error);
return res
@@ -499,9 +414,7 @@ export const getAllInquiries = async (req, res) => {
take: queryLimit,
include: {
healthPackage: {
include: {
category: true,
},
include: { category: true },
},
},
orderBy: { createdAt: "desc" },
+1 -1
View File
@@ -21,7 +21,7 @@ router.get("/getTimings/:doctorId", getDoctorTimingById);
router.get("/:doctorId", getDoctorByDoctorId);
router.post("/", jwtAuthMiddleware, createDoctor);
router.patch("/:doctorId/:action", jwtAuthMiddleware, updateDoctor);
router.patch("/:doctorId", jwtAuthMiddleware, updateDoctor);
router.delete("/:doctorId", jwtAuthMiddleware, deleteDoctor);
export default router;
+4 -2
View File
@@ -4,8 +4,10 @@ set -e # Exit immediately if a command exits with a non-zero status
echo "Generating Prisma Client..."
npx prisma generate
echo "Running migrate..."
npx prisma migrate deploy
# echo "Running migrate..."
# npx prisma migrate deploy
echo "Running PUSH..."
npx prisma db push
echo "Executing command: $@"
exec "$@"
+1 -2
View File
@@ -53,10 +53,9 @@ export const createDoctorApi = async (data: Doctor) => {
export const updateDoctorApi = async (
doctorId: string,
data: Partial<Doctor>,
action: "toggleStatus" | "updateDetails" = "updateDetails",
) => {
try {
const res = await apiClient.patch(`/doctors/${doctorId}/${action}`, data);
const res = await apiClient.patch(`/doctors/${doctorId}`, data);
toast.success("Doctor updated successfully");
+1 -11
View File
@@ -1,21 +1,12 @@
import apiClient from "@/api/client";
import toast from "react-hot-toast";
export interface SeoData {
seoTitle?: string;
metaDescription?: string;
focusKeyphrase?: string;
tags?: string[];
ogTitle?: string;
ogDescription?: string;
ogImage?: string;
}
export interface HealthPackage {
id?: number;
name: string;
slug: string;
description?: string;
price?: number;
price: number;
image?: string;
discountedPrice?: number;
inclusions: Record<string, string[]>;
@@ -23,7 +14,6 @@ export interface HealthPackage {
isActive: boolean;
isFeatured: boolean;
sortOrder: number;
seo?: SeoData | null;
category?: {
name: string;
};
@@ -7,12 +7,11 @@ interface BytescaleUploaderProps {
value: string;
onChange: (url: string) => void;
folderPath:
| "/health-packages"
| "/seo"
| "/doctors"
| "/departments"
| "/news"
| "/blog"
| "/health-packages"
| "/doctor-og";
}
@@ -1,436 +0,0 @@
import { BytescaleUploader } from "@/components/BytescaleUploader/BytescaleUploader";
import SeoFields from "@/components/SeoFields/SeoFields";
import { useEffect } from "react";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Plus, Trash2 } from "lucide-react";
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
editingPackage: any;
pkgForm: any;
setPkgForm: any;
inclusionsList: any[];
setInclusionsList: any;
categories: any[];
onSave: () => void;
}
export default function HealthPackageModal({
open,
onOpenChange,
editingPackage,
pkgForm,
setPkgForm,
inclusionsList,
setInclusionsList,
categories,
onSave,
}: Props) {
useEffect(() => {
if (!editingPackage && pkgForm.name) {
setPkgForm((prev: any) => ({
...prev,
slug: prev.slug
? prev.slug
: pkgForm.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, ""),
}));
}
}, [pkgForm.name]);
const handleAddInclusionField = () => {
setInclusionsList([
...inclusionsList,
{
id: Date.now(),
category: "",
items: "",
},
]);
};
const handleRemoveInclusionField = (id: number) => {
setInclusionsList(inclusionsList.filter((item) => item.id !== id));
};
const handleUpdateInclusionField = (
id: number,
field: string,
value: string,
) => {
setInclusionsList(
inclusionsList.map((item) =>
item.id === id
? {
...item,
[field]: value,
}
: item,
),
);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-full !max-w-7xl h-[92vh] 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">
{editingPackage ? "Edit Health Package" : "Create Health Package"}
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto">
<div className="grid grid-cols-1 xl:grid-cols-[1.2fr_0.8fr] gap-8 p-6">
{/* LEFT COLUMN */}
<div className="space-y-8">
<div className="space-y-5">
<div className="sticky top-0 bg-background z-10 pb-2">
<h3 className="text-lg font-bold">Profile & Pricing</h3>
<p className="text-sm text-muted-foreground">
Main package information
</p>
</div>
<div className="space-y-5">
<div className="space-y-2">
<Label className="font-semibold">Package Image</Label>
<p className="text-xs text-muted-foreground">
Recommended size: 650 × 250
</p>
<BytescaleUploader
value={pkgForm.image || ""}
folderPath="/health-packages"
onChange={(url) =>
setPkgForm({
...pkgForm,
image: url,
})
}
/>
</div>
<div className="flex items-center justify-between border rounded-xl p-4 bg-muted/30">
<div>
<p className="font-semibold">Active Visibility</p>
<p className="text-sm text-muted-foreground">
Show this package publicly
</p>
</div>
<Switch
checked={pkgForm.isActive}
onCheckedChange={(val) =>
setPkgForm({
...pkgForm,
isActive: val,
})
}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="font-semibold">Package Name</Label>
<Input
value={pkgForm.name}
onChange={(e) =>
setPkgForm({
...pkgForm,
name: e.target.value,
})
}
/>
</div>
<div className="space-y-2">
<Label className="font-semibold">URL Slug</Label>
<Input
value={pkgForm.slug}
onChange={(e) =>
setPkgForm({
...pkgForm,
slug: e.target.value,
})
}
/>
</div>
</div>
<div className="space-y-2">
<Label className="font-semibold">Category</Label>
<Select
value={pkgForm.categoryId?.toString()}
onValueChange={(v) =>
setPkgForm({
...pkgForm,
categoryId: Number(v),
})
}
>
<SelectTrigger>
<SelectValue placeholder="Select category" />
</SelectTrigger>
<SelectContent>
{categories.map((c) => (
<SelectItem key={c.id} value={c.id.toString()}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label className="font-semibold">Regular Price ()</Label>
<Input
type="number"
value={pkgForm.price || ""}
onChange={(e) => {
const value = e.target.value
? Number(e.target.value)
: undefined;
setPkgForm({
...pkgForm,
price: value,
});
}}
/>
</div>
<div className="space-y-2">
<Label className="font-semibold">
Discounted Price ()
</Label>
<Input
type="number"
disabled={!pkgForm.price}
value={pkgForm.discountedPrice || ""}
onChange={(e) =>
setPkgForm({
...pkgForm,
discountedPrice: e.target.value
? Number(e.target.value)
: undefined,
})
}
/>
</div>
<div className="space-y-2">
<Label className="font-semibold">Sort Priority</Label>
<Input
type="number"
value={pkgForm.sortOrder}
onChange={(e) =>
setPkgForm({
...pkgForm,
sortOrder: Number(e.target.value),
})
}
/>
</div>
</div>
<div className="space-y-2">
<Label className="font-semibold">Description</Label>
<Textarea
rows={5}
value={pkgForm.description}
onChange={(e) =>
setPkgForm({
...pkgForm,
description: e.target.value,
})
}
/>
</div>
</div>
</div>
{/* INCLUSIONS */}
<div className="space-y-5">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-bold">Tests & Inclusions</h3>
<p className="text-sm text-muted-foreground">
Group tests into categories
</p>
</div>
<Badge variant="outline">
{inclusionsList.length} Groups
</Badge>
</div>
<Accordion type="multiple" className="space-y-4">
{inclusionsList.map((inc, index) => {
const testCount = inc.items
?.split(",")
.filter(Boolean).length;
return (
<AccordionItem
key={inc.id}
value={inc.id.toString()}
className="border rounded-xl bg-background px-5 shadow-sm"
>
<AccordionTrigger className="hover:no-underline w-full">
<div className="flex w-full items-center justify-between">
<div className="flex flex-col items-start text-left">
<p className="font-semibold">
{inc.category || `Group ${index + 1}`}
</p>
<p className="text-xs text-muted-foreground">
{testCount || 0} tests included
</p>
</div>
<Button
variant="ghost"
size="sm"
className="text-red-500 hover:text-red-600"
onClick={() => handleRemoveInclusionField(inc.id)}
>
<Trash2 className="h-4 w-4 mr-1" />
Remove
</Button>
</div>
</AccordionTrigger>
<AccordionContent className="pt-4">
<div className="space-y-4">
<div className="space-y-2">
<Label>Category Title</Label>
<Input
placeholder="Routine Blood Tests"
value={inc.category}
onChange={(e) =>
handleUpdateInclusionField(
inc.id,
"category",
e.target.value,
)
}
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Included Tests</Label>
</div>
<Textarea
rows={4}
placeholder="CBC, LFT, RFT, TSH"
value={inc.items}
onChange={(e) =>
handleUpdateInclusionField(
inc.id,
"items",
e.target.value,
)
}
/>
<p className="text-xs text-muted-foreground">
Separate each test using commas
</p>
</div>
</div>
</AccordionContent>
</AccordionItem>
);
})}
</Accordion>
<Button
variant="outline"
className="w-full border-dashed border-2 h-12"
onClick={handleAddInclusionField}
>
<Plus className="h-4 w-4 mr-2" />
Add New Inclusion Group
</Button>
</div>
</div>
{/* RIGHT COLUMN */}
<div className="space-y-6">
<SeoFields
value={pkgForm.seo}
slug={pkgForm.slug}
folderPath="/seo"
onChange={(seo) =>
setPkgForm({
...pkgForm,
seo,
})
}
/>
</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}>
{editingPackage ? "Save Changes" : "Create Package"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -1,211 +0,0 @@
import { BytescaleUploader } from "@/components/BytescaleUploader/BytescaleUploader";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { X } from "lucide-react";
interface SeoData {
seoTitle?: string;
metaDescription?: string;
focusKeyphrase?: string;
tags?: string[];
ogTitle?: string;
ogDescription?: string;
ogImage?: string;
}
interface SeoFieldsProps {
value?: SeoData;
onChange: (seo: SeoData) => void;
slug?: string;
folderPath?: "/seo";
}
export default function SeoFields({
value,
onChange,
slug,
folderPath = "/seo",
}: SeoFieldsProps) {
const seo = value || {};
const updateSeo = (field: keyof SeoData, fieldValue: any) => {
onChange({
...seo,
[field]: fieldValue,
});
};
const removeTag = (index: number) => {
updateSeo(
"tags",
(seo.tags || []).filter((_, i) => i !== index),
);
};
return (
<div className="space-y-5 p-5 border rounded-xl bg-muted/20">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-bold">SEO Settings</h3>
<p className="text-sm text-muted-foreground">
Optimize for Google & social sharing
</p>
</div>
<Badge variant="secondary">Optional</Badge>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-semibold">SEO Title</Label>
<span className="text-xs text-muted-foreground">
{seo.seoTitle?.length || 0}/60
</span>
</div>
<Input
placeholder="Best Health Checkup Package in Kochi"
value={seo.seoTitle || ""}
onChange={(e) => updateSeo("seoTitle", e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Recommended: 5060 characters
</p>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-semibold">Meta Description</Label>
<span className="text-xs text-muted-foreground">
{seo.metaDescription?.length || 0}/160
</span>
</div>
<Textarea
rows={4}
placeholder="Short description shown in Google search results"
value={seo.metaDescription || ""}
onChange={(e) => updateSeo("metaDescription", e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Recommended: 150160 characters
</p>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">Focus Keyphrase</Label>
<Input
placeholder="health checkup package kochi"
value={seo.focusKeyphrase || ""}
onChange={(e) => updateSeo("focusKeyphrase", e.target.value)}
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">Tags / Keywords</Label>
<div className="flex flex-wrap gap-2 border rounded-md p-3 min-h-[48px] bg-background">
{(seo.tags || []).map((tag, index) => (
<div
key={index}
className="bg-primary/10 text-primary px-3 py-1 rounded-full text-sm flex items-center gap-2"
>
<span>{tag}</span>
<button
type="button"
onClick={() => removeTag(index)}
className="hover:text-red-500 transition-colors"
>
<X className="h-3 w-3" />
</button>
</div>
))}
<Input
placeholder="Type keyword and press Enter"
className="border-0 shadow-none focus-visible:ring-0 min-w-[220px] flex-1"
onKeyDown={(e) => {
if (e.key === "Enter" && e.currentTarget.value.trim()) {
e.preventDefault();
const newTag = e.currentTarget.value.trim();
if (!(seo.tags || []).includes(newTag)) {
updateSeo("tags", [...(seo.tags || []), newTag]);
}
e.currentTarget.value = "";
}
}}
/>
</div>
<p className="text-xs text-muted-foreground">Press Enter to add tags</p>
</div>
<div className="border-t pt-5 space-y-5">
<div className="flex items-center justify-between">
<div>
<h4 className="font-bold">Open Graph (Social Preview)</h4>
<p className="text-sm text-muted-foreground">
Facebook, WhatsApp & Twitter sharing
</p>
</div>
<Badge variant="secondary">Optional</Badge>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">OG Title</Label>
<Input
placeholder="Title for social sharing"
value={seo.ogTitle || ""}
onChange={(e) => updateSeo("ogTitle", e.target.value)}
/>
<p className="text-xs text-muted-foreground">
If empty, SEO title will be used
</p>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">OG Description</Label>
<Textarea
rows={4}
placeholder="Description for social sharing"
value={seo.ogDescription || ""}
onChange={(e) => updateSeo("ogDescription", e.target.value)}
/>
<p className="text-xs text-muted-foreground">
If empty, meta description will be used
</p>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">OG Image</Label>
<BytescaleUploader
value={seo.ogImage || ""}
folderPath={folderPath}
onChange={(url) => updateSeo("ogImage", url)}
/>
</div>
</div>
</div>
);
}
@@ -1,158 +0,0 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
interface SeoPreviewData {
seo?: {
ogImage?: string;
ogTitle?: string;
seoTitle?: string;
ogDescription?: string;
metaDescription?: string;
slug?: string;
};
doctorId?: string;
name?: string;
}
interface SeoPreviewProps {
open: boolean;
onOpenChange: (open: boolean) => void;
previewData?: SeoPreviewData | null;
url?: string;
title?: string;
}
export default function SeoPreview({
open,
onOpenChange,
previewData,
url,
title = "SEO Preview",
}: SeoPreviewProps) {
const previewUrl = url || "https://www.gg-hospital.com";
const hasSeoData =
!!previewData?.seo &&
!!(
previewData.seo.ogImage ||
previewData.seo.ogTitle ||
previewData.seo.seoTitle ||
previewData.seo.ogDescription ||
previewData.seo.metaDescription
);
const imageUrl =
previewData?.seo?.ogImage ||
"https://placehold.co/1200x630?text=GG+Hospital";
const ogTitle =
previewData?.seo?.ogTitle || previewData?.seo?.seoTitle || "GG Hospital";
const ogDescription =
previewData?.seo?.ogDescription ||
previewData?.seo?.metaDescription ||
"No description available";
const searchTitle =
previewData?.seo?.seoTitle ||
previewData?.seo?.ogTitle ||
"SEO title preview";
const searchDescription =
previewData?.seo?.metaDescription ||
previewData?.seo?.ogDescription ||
"No meta description available";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:!max-w-4xl overflow-hidden">
<DialogHeader>
<DialogTitle className="text-xl">{title}</DialogTitle>
</DialogHeader>
{hasSeoData ? (
<div className="space-y-10 py-2">
{/* Social Preview */}
<div>
<p className="mb-4 text-sm font-semibold text-muted-foreground">
Social Media Preview (WhatsApp / Facebook)
</p>
<a
href={previewUrl}
target="_blank"
rel="noopener noreferrer"
className="block max-w-[560px] overflow-hidden rounded-xl border bg-white shadow-sm transition hover:shadow-md"
>
<div className="aspect-[1.91/1] overflow-hidden bg-muted">
<img
src={imageUrl}
alt="OG Preview"
className="h-full w-full object-cover"
/>
</div>
<div className="border-t bg-[#f0f2f5] px-4 py-3">
<h3 className="mt-1 line-clamp-2 text-[18px] font-semibold leading-snug text-[#1c1e21]">
{ogTitle}
</h3>
<p className="mt-1 line-clamp-2 text-[14px] text-[#65676b]">
{ogDescription}
</p>
<p className="mt-1 truncate text-[11px] tracking-wide text-[#65676b]">
{previewUrl}
</p>
</div>
</a>
</div>
{/* Google Preview */}
<div>
<p className="mb-4 text-sm font-semibold text-muted-foreground">
Google Search Preview
</p>
<div className="rounded-xl border bg-white p-6">
<a
href={previewUrl}
target="_blank"
rel="noopener noreferrer"
className="block"
>
<p className="truncate text-[14px] text-[#202124] hover:underline">
{previewUrl}
</p>
<h3 className="mt-1 text-[22px] leading-tight text-[#1a0dab] hover:underline">
{searchTitle}
</h3>
</a>
<p className="mt-2 line-clamp-3 text-[14px] leading-6 text-[#4d5156]">
{searchDescription}
</p>
</div>
</div>
</div>
) : (
<div className="flex items-center justify-center py-16 text-sm text-muted-foreground">
No preview data available.
</div>
)}
<DialogFooter className="mt-0 border-t bg-background p-6">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
-79
View File
@@ -1,79 +0,0 @@
import * as React from "react"
import { Accordion as AccordionPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"
function Accordion({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return (
<AccordionPrimitive.Root
data-slot="accordion"
className={cn("flex w-full flex-col", className)}
{...props}
/>
)
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("not-last:border-b", className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"group/accordion-trigger relative flex flex-1 items-start justify-between rounded-lg border border-transparent py-2.5 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:after:border-ring disabled:pointer-events-none disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
className
)}
{...props}
>
{children}
<ChevronDownIcon data-slot="accordion-trigger-icon" className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden" />
<ChevronUpIcon data-slot="accordion-trigger-icon" className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="overflow-hidden text-sm data-open:animate-accordion-down data-closed:animate-accordion-up"
{...props}
>
<div
className={cn(
"h-(--radix-accordion-content-height) pt-0 pb-2.5 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className
)}
>
{children}
</div>
</AccordionPrimitive.Content>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
+4 -52
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from "react";
import { AxiosError } from "axios";
import { Eye } from "lucide-react";
import { BytescaleUploader } from "@/components/BytescaleUploader/BytescaleUploader";
import {
@@ -28,7 +28,6 @@ import {
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import SeoPreview from "@/components/SeoPreview/SeoPreview";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
@@ -61,8 +60,6 @@ const DAYS = [
];
export default function DoctorPage() {
const WEBSITE_URL = import.meta.env.VITE_WEBSITE_URL;
const [doctors, setDoctors] = useState<any[]>([]);
const [departments, setDepartments] = useState<Department[]>([]);
const [loading, setLoading] = useState(true);
@@ -103,8 +100,6 @@ export default function DoctorPage() {
slug: "",
tags: [],
});
const [openOgPreview, setOpenOgPreview] = useState(false);
const [previewDoctor, setPreviewDoctor] = useState<any>(null);
const fetchAll = useCallback(async () => {
setLoading(true);
@@ -172,16 +167,8 @@ export default function DoctorPage() {
const currentItems = filteredDoctors.slice(indexOfFirstItem, indexOfLastItem);
function handleChange(e: any) {
let value =
const value =
e.target.type === "number" ? Number(e.target.value) : e.target.value;
if (e.target.name === "slug") {
value = value
.toLowerCase()
.replace(/\s+/g, "-") // replace spaces with -
.replace(/[^\w-]+/g, "") // remove special chars
.replace(/--+/g, "-"); // remove duplicate -
}
setForm({ ...form, [e.target.name]: value });
}
@@ -193,7 +180,7 @@ export default function DoctorPage() {
isActive: newStatus,
};
await updateDoctorApi(doc.doctorId, payload, "toggleStatus");
await updateDoctorApi(doc.doctorId, payload);
fetchAll();
} catch (err) {
@@ -321,10 +308,7 @@ export default function DoctorPage() {
}
}
function handlePreview(doc: any) {
setPreviewDoctor(doc);
setOpenOgPreview(true);
}
console.log("Current form state:", form); // Debug log to check form state
async function handleSubmit() {
try {
@@ -340,24 +324,6 @@ export default function DoctorPage() {
}
}
const createSlug = (text: string) => {
if (!text) return "";
return text
.toString()
.toLowerCase()
.trim()
.replace(/\s+/g, "-")
.replace(/[^\w-]+/g, "")
.replace(/--+/g, "-");
};
const getDoctorUrl = (doctor: any) => {
const slug = doctor?.seo?.slug || createSlug(doctor?.name);
return `${WEBSITE_URL}/${doctor?.doctorId}/${slug}`;
};
return (
<div className="p-6 space-y-6">
<div className="flex flex-col md:flex-row md:justify-between md:items-center gap-4">
@@ -518,14 +484,6 @@ export default function DoctorPage() {
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
size="icon"
variant="ghost"
className="h-9 w-9"
onClick={() => handlePreview(doc)}
>
<Eye className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="ghost"
@@ -1160,12 +1118,6 @@ export default function DoctorPage() {
</DialogFooter>
</DialogContent>
</Dialog>
<SeoPreview
open={openOgPreview}
onOpenChange={setOpenOgPreview}
previewData={previewDoctor}
url={getDoctorUrl(previewDoctor)}
/>
</div>
);
}
+391 -101
View File
@@ -1,21 +1,20 @@
import { useState, useEffect, useCallback, useMemo } from "react";
import toast from "react-hot-toast";
import { AxiosError } from "axios";
import { BytescaleUploader } from "@/components/BytescaleUploader/BytescaleUploader";
import {
getHealthPackagesApi,
getHealthCategoriesApi,
updateHealthPackageApi,
createHealthPackageApi,
updateHealthPackageApi,
createCategoryApi,
updateCategoryApi,
deleteCategoryApi,
HealthPackage,
HealthCategory,
} from "@/api/healthCheck";
import PackageInquiriesTab from "@/components/PackageInquiriesTab/PackageInquiriesTab";
import HealthPackageModal from "@/components/HealthPackageModal/HealthPackageModal";
import SeoPreview from "@/components/SeoPreview/SeoPreview";
import {
Table,
@@ -35,10 +34,18 @@ import {
DialogFooter,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Loader2,
@@ -49,10 +56,10 @@ import {
ChevronRight,
LayoutGrid,
Eye,
Trash2,
} from "lucide-react";
export default function HealthPackagePage() {
const WEBSITE_URL = import.meta.env.VITE_WEBSITE_URL;
const [packages, setPackages] = useState<HealthPackage[]>([]);
const [categories, setCategories] = useState<HealthCategory[]>([]);
const [loading, setLoading] = useState(true);
@@ -86,20 +93,11 @@ export default function HealthPackagePage() {
slug: "",
description: "",
image: "",
price: undefined,
discountedPrice: undefined,
price: 0,
discountedPrice: 0,
categoryId: 0,
isActive: true,
sortOrder: 1000,
seo: {
seoTitle: "",
metaDescription: "",
focusKeyphrase: "",
tags: [],
ogTitle: "",
ogDescription: "",
ogImage: "",
},
});
const [inclusionsList, setInclusionsList] = useState([
{ id: Date.now(), category: "", items: "" },
@@ -166,11 +164,9 @@ export default function HealthPackagePage() {
if (!pkg.id) return;
try {
await updateHealthPackageApi(pkg.id, { isActive: !pkg.isActive });
toast.success(`Package ${pkg.isActive ? "hidden" : "activated"}`);
fetchData();
} catch (err) {
console.error("Failed to update status", err);
toast.error("Failed to update status");
}
};
const handleToggleCategoryStatus = async (cat: HealthCategory) => {
@@ -184,41 +180,24 @@ export default function HealthPackagePage() {
}
await updateCategoryApi(cat.id, { isActive: !cat.isActive });
toast.success(`Category ${cat.isActive ? "hidden" : "activated"}`);
fetchData();
} catch (err) {
console.error("Failed to update category status", err);
toast.error("Failed to update category status");
}
};
const openAddPackage = () => {
if (categories.length === 0) {
toast.error(
"Please create at least one category before attempting to add a health package.",
);
return;
}
setEditingPackage(null);
setPkgForm({
name: "",
slug: "",
description: "",
image: "",
price: undefined,
discountedPrice: undefined,
price: 0,
discountedPrice: 0,
categoryId: categories[0]?.id || 0,
isActive: true,
sortOrder: 1000,
seo: {
seoTitle: "",
metaDescription: "",
focusKeyphrase: "",
tags: [],
ogTitle: "",
ogDescription: "",
ogImage: "",
},
});
setInclusionsList([{ id: Date.now(), category: "", items: "" }]);
setPackageModal(true);
@@ -251,26 +230,32 @@ export default function HealthPackagePage() {
setPackageModal(true);
};
const handleAddInclusionField = () => {
setInclusionsList([
...inclusionsList,
{ id: Date.now(), category: "", items: "" },
]);
};
const handleRemoveInclusionField = (id: number) => {
setInclusionsList(inclusionsList.filter((item) => item.id !== id));
};
const handleUpdateInclusionField = (
id: number,
field: string,
value: string,
) => {
setInclusionsList(
inclusionsList.map((item) =>
item.id === id ? { ...item, [field]: value } : item,
),
);
};
const savePackage = async () => {
if (!pkgForm.image) return toast.error("Package image is required.");
if (!pkgForm.name?.trim()) return toast.error("Package Name is required.");
if (!pkgForm.slug?.trim()) return toast.error("URL Slug is required.");
if (!pkgForm.categoryId)
return toast.error("Please select a valid category.");
if (!pkgForm.description?.trim())
return toast.error("Description is required.");
const structureFilled = inclusionsList.some(
(item) => item.category.trim() !== "" && item.items.trim() !== "",
);
if (!structureFilled) {
return toast.error(
"Please provide at least one valid Category Group with tests inside it.",
);
}
try {
// Convert the dynamic array back into the required JSON object format
const parsedInclusions: Record<string, string[]> = {};
inclusionsList.forEach((entry) => {
const catName = entry.category.trim();
@@ -282,21 +267,7 @@ export default function HealthPackagePage() {
}
});
const finalData: Partial<HealthPackage> = {
...pkgForm,
inclusions: parsedInclusions,
};
finalData.price =
finalData.price !== undefined && finalData.price !== null
? Number(finalData.price)
: null;
finalData.discountedPrice =
finalData.discountedPrice !== undefined &&
finalData.discountedPrice !== null
? Number(finalData.discountedPrice)
: null;
const finalData = { ...pkgForm, inclusions: parsedInclusions };
if (editingPackage?.id) {
const changedFields: Record<string, any> = {};
@@ -326,15 +297,10 @@ export default function HealthPackagePage() {
fetchData();
} catch (err) {
console.error(err);
toast.error(
"An unexpected system error occurred while trying to save the package.",
);
}
};
const saveCategory = async () => {
if (!catForm.name?.trim()) return toast.error("Category Name is required.");
try {
if (editingCategory?.id) {
const changedFields: Record<string, any> = {};
@@ -366,11 +332,15 @@ export default function HealthPackagePage() {
fetchData();
} catch (err) {
console.error(err);
toast.error("An error occurred while saving the category.");
}
};
const previewUrl = `${WEBSITE_URL}/preventivecheckupdirectory/${selectedPackage?.slug}`;
const deleteCategory = async (id: number) => {
if (confirm("Delete this category? Ensure no packages are linked to it.")) {
await deleteCategoryApi(id);
fetchData();
}
};
return (
<div className="p-6 space-y-6">
@@ -499,15 +469,9 @@ export default function HealthPackagePage() {
</TableCell>
<TableCell>
<div className="font-semibold">
{pkg.discountedPrice != null
? `${pkg.discountedPrice}`
: pkg.price != null
? `${pkg.price}`
: "Not Entered"}
{pkg.discountedPrice || pkg.price}
</div>
{pkg.discountedPrice != null &&
pkg.price != null &&
{pkg.discountedPrice &&
pkg.discountedPrice < pkg.price && (
<div className="text-xs text-muted-foreground line-through">
{pkg.price}
@@ -707,18 +671,264 @@ export default function HealthPackagePage() {
</TabsContent>
</Tabs>
{/* --- REPLACED MODAL CONTAINER --- */}
<HealthPackageModal
open={packageModal}
onOpenChange={setPackageModal}
editingPackage={editingPackage}
pkgForm={pkgForm}
setPkgForm={setPkgForm}
inclusionsList={inclusionsList}
setInclusionsList={setInclusionsList}
categories={categories}
onSave={savePackage}
{/* --- PACKAGE MODAL --- */}
<Dialog open={packageModal} onOpenChange={setPackageModal}>
<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">
{editingPackage ? "Edit Package" : "Add Package"}
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto p-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="space-y-6">
<h3 className="font-bold text-base border-b pb-2">
Profile & Pricing
</h3>
<div className="space-y-4">
<div className="space-y-2">
<Label className="text-sm font-semibold">
Package Image
</Label>
<BytescaleUploader
value={pkgForm.image || ""}
folderPath="/health-packages"
onChange={(url) =>
setPkgForm({
...pkgForm,
image: url,
})
}
/>
</div>
<div className="flex items-center justify-between p-3 border rounded-md bg-muted/30">
<Label className="text-base font-semibold cursor-pointer">
Active Visibility
</Label>
<Switch
checked={pkgForm.isActive}
onCheckedChange={(val) =>
setPkgForm({ ...pkgForm, isActive: val })
}
/>
</div>
<div className="space-y-1">
<Label className="text-sm font-semibold">
Sort Priority (Lower numbers show first)
</Label>
<Input
type="number"
value={pkgForm.sortOrder}
onChange={(e) =>
setPkgForm({
...pkgForm,
sortOrder: Number(e.target.value),
})
}
className="text-base"
/>
</div>
<div className="space-y-1">
<Label className="text-sm font-semibold">
Package Name
</Label>
<Input
value={pkgForm.name}
onChange={(e) =>
setPkgForm({ ...pkgForm, name: e.target.value })
}
className="text-base"
/>
</div>
<div className="space-y-1">
<Label className="text-sm font-semibold">URL Slug</Label>
<Input
value={pkgForm.slug}
onChange={(e) =>
setPkgForm({ ...pkgForm, slug: e.target.value })
}
className="text-base"
/>
</div>
<div className="space-y-1">
<Label className="text-sm font-semibold">Category</Label>
<Select
value={pkgForm.categoryId?.toString()}
onValueChange={(v) =>
setPkgForm({ ...pkgForm, categoryId: Number(v) })
}
>
<SelectTrigger className="text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
{categories.map((c) => (
<SelectItem key={c.id} value={c.id.toString()}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<Label className="text-sm font-semibold">
Regular Price ()
</Label>
<Input
type="number"
value={pkgForm.price || ""}
onChange={(e) =>
setPkgForm({
...pkgForm,
price: Number(e.target.value),
})
}
className="text-base"
/>
</div>
<div className="space-y-1">
<Label className="text-sm font-semibold">
Discounted Price ()
</Label>
<Input
type="number"
value={pkgForm.discountedPrice || ""}
onChange={(e) =>
setPkgForm({
...pkgForm,
discountedPrice: Number(e.target.value),
})
}
className="text-base"
/>
</div>
</div>
</div>
</div>
<div className="space-y-6">
<h3 className="font-bold text-base border-b pb-2">
Details & Inclusions
</h3>
<div className="space-y-4">
<div className="space-y-1">
<Label className="text-sm font-semibold">Description</Label>
<Textarea
rows={3}
value={pkgForm.description}
onChange={(e) =>
setPkgForm({ ...pkgForm, description: e.target.value })
}
className="text-base"
/>
</div>
<div className="space-y-1">
<div className="space-y-4">
<div className="flex items-center justify-between mb-2">
<Label className="text-sm font-semibold">
Tests & Inclusions
</Label>
<Badge variant="outline">Grouped Fields</Badge>
</div>
<div className="space-y-4 max-h-[400px] overflow-y-auto pr-2">
{inclusionsList.map((inc) => (
<div
key={inc.id}
className="p-4 border rounded-md bg-muted/10 relative"
>
{/* Remove Button */}
<Button
variant="ghost"
size="icon"
className="absolute top-2 right-2 h-8 w-8 text-red-500 hover:text-red-700 hover:bg-red-50"
onClick={() => handleRemoveInclusionField(inc.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
<div className="space-y-3 pr-8">
<div className="space-y-1">
<Label className="text-xs text-muted-foreground uppercase font-bold">
Category Title
</Label>
<Input
placeholder="e.g. Routine Blood Tests"
value={inc.category}
onChange={(e) =>
handleUpdateInclusionField(
inc.id,
"category",
e.target.value,
)
}
className="font-semibold text-base"
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground uppercase font-bold">
Included Tests
</Label>
<p className="text-[10px] text-muted-foreground leading-none mb-1">
Separate tests with a comma (,)
</p>
<Textarea
rows={2}
placeholder="e.g. CBC, LFT, RFT, TSH"
value={inc.items}
onChange={(e) =>
handleUpdateInclusionField(
inc.id,
"items",
e.target.value,
)
}
className="text-sm"
/>
</div>
</div>
</div>
))}
</div>
<Button
variant="outline"
className="w-full mt-2 border-dashed border-2"
onClick={handleAddInclusionField}
>
<Plus className="mr-2 h-4 w-4" />
Add New Category Group
</Button>
</div>
</div>
</div>
</div>
</div>
</div>
<DialogFooter className="p-6 border-t">
<Button
variant="ghost"
onClick={() => setPackageModal(false)}
className="text-base"
>
Cancel
</Button>
<Button onClick={savePackage} className="px-10 text-base">
{editingPackage ? "Save Changes" : "Create Package"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* --- CATEGORY MODAL --- */}
<Dialog open={categoryModal} onOpenChange={setCategoryModal}>
@@ -739,6 +949,16 @@ export default function HealthPackagePage() {
className="text-base"
/>
</div>
{/* <div className="space-y-1">
<Label className="text-sm font-semibold">URL Slug</Label>
<Input
value={catForm.slug}
onChange={(e) =>
setCatForm({ ...catForm, slug: e.target.value })
}
className="text-base"
/>
</div> */}
<div className="space-y-1">
<Label className="text-sm font-semibold">Sort Order</Label>
@@ -774,12 +994,82 @@ export default function HealthPackagePage() {
</DialogContent>
</Dialog>
<SeoPreview
open={viewModal}
onOpenChange={setViewModal}
previewData={selectedPackage}
url={previewUrl}
/>
{/* --- VIEW MODAL --- */}
<Dialog open={viewModal} onOpenChange={setViewModal}>
<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">
{selectedPackage?.name}
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto p-6 space-y-6">
<div className="flex justify-between items-center bg-muted/20 p-4 rounded-lg">
<div>
<p className="text-sm text-muted-foreground uppercase font-bold">
Category
</p>
<p className="text-base font-medium">
{selectedPackage?.category?.name}
</p>
</div>
<div className="text-right">
<p className="text-sm text-muted-foreground uppercase font-bold">
Pricing
</p>
<p className="text-xl font-bold">
{selectedPackage?.discountedPrice || selectedPackage?.price}
</p>
</div>
</div>
<div>
<h3 className="font-bold text-base border-b pb-2 mb-4">
Inclusions
</h3>
<div className="space-y-6">
{selectedPackage?.inclusions &&
typeof selectedPackage.inclusions === "object" &&
!Array.isArray(selectedPackage.inclusions) ? (
Object.entries(selectedPackage.inclusions).map(
([category, tests], idx) => (
<div key={idx}>
<h4 className="font-semibold text-sm text-primary mb-3 uppercase tracking-wider">
{category}
</h4>
<div className="grid grid-cols-2 gap-3">
{Array.isArray(tests) &&
tests.map((item, i) => (
<div
key={i}
className="text-sm border p-3 rounded bg-background shadow-sm"
>
{item}
</div>
))}
</div>
</div>
),
)
) : (
<div className="grid grid-cols-2 gap-3">
{Array.isArray(selectedPackage?.inclusions) &&
selectedPackage.inclusions.map((item, i) => (
<div
key={i}
className="text-sm border p-3 rounded bg-background shadow-sm"
>
{item}
</div>
))}
</div>
)}
</div>
</div>
</div>
<DialogFooter className="p-6 border-t">
<Button onClick={() => setViewModal(false)}>Close</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}