Compare commits

..

23 Commits

Author SHA1 Message Date
rishalkv 5aae2824ef fix: edge case creation of og 2026-05-26 12:36:11 +05:30
rishalkv 3af6401429 fix: og title description 2026-05-26 12:33:51 +05:30
rishalkv c2b54725fe fix: og image update 2026-05-26 11:57:10 +05:30
rishalkv fa06126219 chore: add seo reusable component 2026-05-26 11:38:34 +05:30
rishalkv fc491f4050 feat: seo preview 2026-05-25 16:20:47 +05:30
kailasdevdas 9210621d67 Merge pull request 'fix: optional price fields' (#39) from fix/optional-pricing into dev
Reviewed-on: #39
2026-05-25 06:11:31 +00:00
Kailasdevdas cefaf3a850 fix: optional price fields 2026-05-25 11:37:51 +05:30
kailasdevdas 120ff12fef Merge pull request 'fix: add toggle action update controller' (#38) from fix/doc-update-controller into dev
Reviewed-on: #38
2026-05-22 11:40:52 +00:00
rishalkv 12d9f2a4cb fix: add toggle action update controller 2026-05-22 16:34:37 +05:30
kailasdevdas 5eecc5092d Merge pull request 'fix: use migrate deploy' (#37) from fix/prisma-migrate into dev
Reviewed-on: #37
2026-05-22 09:20:03 +00:00
Kailasdevdas f11c8ae8dc fix: use migrate deploy 2026-05-22 14:21:25 +05:30
kailasdevdas 558ab12e1f Merge pull request 'fix: bytescale type' (#36) from fix/bytescale-type into dev
Reviewed-on: #36
2026-05-21 09:12:43 +00:00
rishalkv 0f839c7f84 fix: bytescale type 2026-05-21 14:36:17 +05:30
kailasdevdas 9271ea9b38 Merge pull request 'feat:add seo and more about doctors' (#34) from feat/doc-seo-content-enhacement into dev
Reviewed-on: #34
2026-05-21 08:47:32 +00:00
rishalkv eb68d0acc4 Merge pull request 'fix:added validations for api' (#35) from fix/doc-validations into feat/doc-seo-content-enhacement
Reviewed-on: #35
2026-05-21 06:06:30 +00:00
rishalkv 667e15513c fix:added validations for api 2026-05-21 11:20:09 +05:30
rishalkv 2a786ef118 fix:unwanted query exec on update 2026-05-20 10:43:05 +05:30
rishalkv da6587c83d fix:editing doctor dept 2026-05-20 10:28:46 +05:30
rishalkv 5fea2a306d feat:add seo and more about doctores 2026-05-20 10:15:53 +05:30
kailasdevdas 08b9c2647e Merge pull request 'chore: add toast show validation errors' (#32) from fix/health-checkup-validation into dev
Reviewed-on: #32
2026-05-18 11:21:57 +00:00
Kailasdevdas 98194283df chore: add toast show validation errors 2026-05-18 16:41:38 +05:30
kailasdevdas 1320ce6fe6 Merge pull request 'chore: show required image dimension' (#31) from feat/healthcheckup-crud into dev
Reviewed-on: #31
2026-05-18 07:47:06 +00:00
Kailasdevdas 3dbbb2e77e chore: show required image dimension 2026-05-18 13:15:00 +05:30
18 changed files with 437 additions and 238 deletions
@@ -1,37 +0,0 @@
/*
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;
@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "Doctor" ADD COLUMN "professionalSummary" TEXT;
@@ -1,7 +0,0 @@
-- 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[];
@@ -1,4 +0,0 @@
-- AlterTable
ALTER TABLE "Doctor" ADD COLUMN "ogDescription" TEXT,
ADD COLUMN "ogImage" TEXT,
ADD COLUMN "ogTitle" TEXT;
@@ -1,19 +0,0 @@
-- 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");
@@ -1,30 +0,0 @@
/*
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;
@@ -1,66 +0,0 @@
-- 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;
@@ -0,0 +1,50 @@
/*
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;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Doctor" ADD COLUMN "experience" INTEGER;
+6 -2
View File
@@ -28,12 +28,13 @@ model Doctor {
qualification String? qualification String?
isActive Boolean @default(true) isActive Boolean @default(true)
globalSortOrder Int @default(1000) globalSortOrder Int @default(1000)
departments DoctorDepartment[]
appointments Appointment[]
specializations DoctorSpecialization[] specializations DoctorSpecialization[]
professionalSummary String? @db.Text professionalSummary String? @db.Text
seoId Int? @unique seoId Int? @unique
seo Seo? @relation(fields: [seoId], references: [id]) seo Seo? @relation(fields: [seoId], references: [id])
departments DoctorDepartment[]
appointments Appointment[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
@@ -223,6 +224,7 @@ model NewsImage {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
} }
model HealthCheckCategory { model HealthCheckCategory {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String @unique name String @unique
@@ -278,6 +280,8 @@ model HealthPackageInquiry {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
model DoctorSpecialization { model DoctorSpecialization {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String name String
+126 -44
View File
@@ -240,6 +240,22 @@ export const createDoctor = async (req, res) => {
ogDescription, ogDescription,
ogImage, ogImage,
} = req.body; } = 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({ const seo = await prisma.seo.create({
data: { data: {
seoTitle, seoTitle,
@@ -324,7 +340,7 @@ export const createDoctor = async (req, res) => {
//update doctors //update doctors
export const updateDoctor = async (req, res) => { export const updateDoctor = async (req, res) => {
try { try {
const {doctorId} = req.params; const {doctorId, action} = req.params;
const { const {
name, name,
designation, designation,
@@ -338,17 +354,58 @@ export const updateDoctor = async (req, res) => {
professionalSummary, professionalSummary,
seoTitle, seoTitle,
metaDescription, metaDescription,
ogTitle,
ogDescription,
focusKeyphrase, focusKeyphrase,
slug, slug,
tags, tags,
ogImage,
specializations, specializations,
} = req.body; } = req.body;
if (!doctorId) {
return res.status(400).json({
success: false,
message: "Doctor ID is required",
});
}
const doctor = await prisma.doctor.findUnique({where: {doctorId}}); const doctor = await prisma.doctor.findUnique({where: {doctorId}});
if (!doctor) if (!doctor)
return res return res
.status(404) .status(404)
.json({success: false, message: "Doctor not found"}); .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({ await prisma.doctor.update({
where: {id: doctor.id}, where: {id: doctor.id},
@@ -365,6 +422,7 @@ export const updateDoctor = async (req, res) => {
globalSortOrder !== undefined ? Number(globalSortOrder) : undefined, globalSortOrder !== undefined ? Number(globalSortOrder) : undefined,
}, },
}); });
if (doctor.seoId) { if (doctor.seoId) {
await prisma.seo.update({ await prisma.seo.update({
where: { where: {
@@ -373,6 +431,9 @@ export const updateDoctor = async (req, res) => {
data: { data: {
seoTitle, seoTitle,
metaDescription, metaDescription,
ogTitle,
ogDescription,
ogImage,
focusKeyphrase, focusKeyphrase,
slug: slug ? slug : null, slug: slug ? slug : null,
tags: tags || [], tags: tags || [],
@@ -381,8 +442,11 @@ export const updateDoctor = async (req, res) => {
} else { } else {
const seo = await prisma.seo.create({ const seo = await prisma.seo.create({
data: { data: {
seoTitle, ogImage,
metaDescription, metaDescription,
seoTitle,
ogDescription,
ogTitle,
focusKeyphrase, focusKeyphrase,
slug: slug ? slug : null, slug: slug ? slug : null,
tags: tags || [], tags: tags || [],
@@ -399,9 +463,66 @@ export const updateDoctor = async (req, res) => {
}); });
} }
const hasTimingData = departments?.some( // Update Departments & Timings
(dep) => dep.timing && Object.keys(dep.timing).length > 0, 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,
},
});
}
}
}
// Update Specializations // Update Specializations
if (Array.isArray(specializations)) { if (Array.isArray(specializations)) {
@@ -424,45 +545,6 @@ 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 res
.status(200) .status(200)
.json({success: true, message: "Doctor updated successfully"}); .json({success: true, message: "Doctor updated successfully"});
+1 -1
View File
@@ -21,7 +21,7 @@ router.get("/getTimings/:doctorId", getDoctorTimingById);
router.get("/:doctorId", getDoctorByDoctorId); router.get("/:doctorId", getDoctorByDoctorId);
router.post("/", jwtAuthMiddleware, createDoctor); router.post("/", jwtAuthMiddleware, createDoctor);
router.patch("/:doctorId", jwtAuthMiddleware, updateDoctor); router.patch("/:doctorId/:action", jwtAuthMiddleware, updateDoctor);
router.delete("/:doctorId", jwtAuthMiddleware, deleteDoctor); router.delete("/:doctorId", jwtAuthMiddleware, deleteDoctor);
export default router; export default router;
+2 -4
View File
@@ -4,10 +4,8 @@ set -e # Exit immediately if a command exits with a non-zero status
echo "Generating Prisma Client..." echo "Generating Prisma Client..."
npx prisma generate npx prisma generate
# echo "Running migrate..." echo "Running migrate..."
# npx prisma migrate deploy npx prisma migrate deploy
echo "Running PUSH..."
npx prisma db push
echo "Executing command: $@" echo "Executing command: $@"
exec "$@" exec "$@"
+2 -1
View File
@@ -53,9 +53,10 @@ export const createDoctorApi = async (data: Doctor) => {
export const updateDoctorApi = async ( export const updateDoctorApi = async (
doctorId: string, doctorId: string,
data: Partial<Doctor>, data: Partial<Doctor>,
action: "toggleStatus" | "updateDetails" = "updateDetails",
) => { ) => {
try { try {
const res = await apiClient.patch(`/doctors/${doctorId}`, data); const res = await apiClient.patch(`/doctors/${doctorId}/${action}`, data);
toast.success("Doctor updated successfully"); toast.success("Doctor updated successfully");
+1 -1
View File
@@ -6,7 +6,7 @@ export interface HealthPackage {
name: string; name: string;
slug: string; slug: string;
description?: string; description?: string;
price: number; price?: number;
image?: string; image?: string;
discountedPrice?: number; discountedPrice?: number;
inclusions: Record<string, string[]>; inclusions: Record<string, string[]>;
@@ -0,0 +1,131 @@
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 || "#";
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>
{previewData ? (
<div className="space-y-10 py-2">
<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">
<p className="truncate text-[11px] uppercase tracking-wide text-[#65676b]">
gg-hospital.com
</p>
<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>
</div>
</a>
</div>
<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="p-6 text-sm text-muted-foreground">
No preview data available.
</div>
)}
<DialogFooter className="p-6 border-t bg-background z-10 mt-0">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+52 -4
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import { AxiosError } from "axios"; import { AxiosError } from "axios";
import { Eye } from "lucide-react";
import { BytescaleUploader } from "@/components/BytescaleUploader/BytescaleUploader"; import { BytescaleUploader } from "@/components/BytescaleUploader/BytescaleUploader";
import { import {
@@ -28,6 +28,7 @@ import {
DialogTitle, DialogTitle,
DialogFooter, DialogFooter,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import SeoPreview from "@/components/SeoPreview/SeoPreview";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
@@ -60,6 +61,8 @@ const DAYS = [
]; ];
export default function DoctorPage() { export default function DoctorPage() {
const WEBSITE_URL = import.meta.env.VITE_WEBSITE_URL;
const [doctors, setDoctors] = useState<any[]>([]); const [doctors, setDoctors] = useState<any[]>([]);
const [departments, setDepartments] = useState<Department[]>([]); const [departments, setDepartments] = useState<Department[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -100,6 +103,8 @@ export default function DoctorPage() {
slug: "", slug: "",
tags: [], tags: [],
}); });
const [openOgPreview, setOpenOgPreview] = useState(false);
const [previewDoctor, setPreviewDoctor] = useState<any>(null);
const fetchAll = useCallback(async () => { const fetchAll = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -167,8 +172,16 @@ export default function DoctorPage() {
const currentItems = filteredDoctors.slice(indexOfFirstItem, indexOfLastItem); const currentItems = filteredDoctors.slice(indexOfFirstItem, indexOfLastItem);
function handleChange(e: any) { function handleChange(e: any) {
const value = let value =
e.target.type === "number" ? Number(e.target.value) : e.target.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 }); setForm({ ...form, [e.target.name]: value });
} }
@@ -180,7 +193,7 @@ export default function DoctorPage() {
isActive: newStatus, isActive: newStatus,
}; };
await updateDoctorApi(doc.doctorId, payload); await updateDoctorApi(doc.doctorId, payload, "toggleStatus");
fetchAll(); fetchAll();
} catch (err) { } catch (err) {
@@ -308,7 +321,10 @@ export default function DoctorPage() {
} }
} }
console.log("Current form state:", form); // Debug log to check form state function handlePreview(doc: any) {
setPreviewDoctor(doc);
setOpenOgPreview(true);
}
async function handleSubmit() { async function handleSubmit() {
try { try {
@@ -324,6 +340,24 @@ 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 ( return (
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
<div className="flex flex-col md:flex-row md:justify-between md:items-center gap-4"> <div className="flex flex-col md:flex-row md:justify-between md:items-center gap-4">
@@ -484,6 +518,14 @@ export default function DoctorPage() {
<TableCell className="text-right"> <TableCell className="text-right">
<div className="flex justify-end gap-2"> <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 <Button
size="icon" size="icon"
variant="ghost" variant="ghost"
@@ -1118,6 +1160,12 @@ export default function DoctorPage() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<SeoPreview
open={openOgPreview}
onOpenChange={setOpenOgPreview}
previewData={previewDoctor}
url={getDoctorUrl(previewDoctor)}
/>
</div> </div>
); );
} }
+62 -14
View File
@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback, useMemo } from "react"; import { useState, useEffect, useCallback, useMemo } from "react";
import toast from "react-hot-toast";
import { AxiosError } from "axios"; import { AxiosError } from "axios";
import { BytescaleUploader } from "@/components/BytescaleUploader/BytescaleUploader"; import { BytescaleUploader } from "@/components/BytescaleUploader/BytescaleUploader";
@@ -164,9 +165,11 @@ export default function HealthPackagePage() {
if (!pkg.id) return; if (!pkg.id) return;
try { try {
await updateHealthPackageApi(pkg.id, { isActive: !pkg.isActive }); await updateHealthPackageApi(pkg.id, { isActive: !pkg.isActive });
toast.success(`Package ${pkg.isActive ? "hidden" : "activated"}`);
fetchData(); fetchData();
} catch (err) { } catch (err) {
console.error("Failed to update status", err); console.error("Failed to update status", err);
toast.error("Failed to update status");
} }
}; };
const handleToggleCategoryStatus = async (cat: HealthCategory) => { const handleToggleCategoryStatus = async (cat: HealthCategory) => {
@@ -180,13 +183,21 @@ export default function HealthPackagePage() {
} }
await updateCategoryApi(cat.id, { isActive: !cat.isActive }); await updateCategoryApi(cat.id, { isActive: !cat.isActive });
toast.success(`Category ${cat.isActive ? "hidden" : "activated"}`);
fetchData(); fetchData();
} catch (err) { } catch (err) {
console.error("Failed to update category status", err); console.error("Failed to update category status", err);
toast.error("Failed to update category status");
} }
}; };
const openAddPackage = () => { const openAddPackage = () => {
if (categories.length === 0) {
toast.error(
"Please create at least one category before attempting to add a health package.",
);
return;
}
setEditingPackage(null); setEditingPackage(null);
setPkgForm({ setPkgForm({
name: "", name: "",
@@ -254,6 +265,23 @@ export default function HealthPackagePage() {
}; };
const savePackage = async () => { 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 { try {
// Convert the dynamic array back into the required JSON object format // Convert the dynamic array back into the required JSON object format
const parsedInclusions: Record<string, string[]> = {}; const parsedInclusions: Record<string, string[]> = {};
@@ -267,8 +295,18 @@ export default function HealthPackagePage() {
} }
}); });
const finalData = { ...pkgForm, inclusions: parsedInclusions }; const finalData: Partial<HealthPackage> = {
...pkgForm,
inclusions: parsedInclusions,
};
if (!finalData.price) {
delete finalData.price;
}
if (!finalData.discountedPrice) {
delete finalData.discountedPrice;
}
if (editingPackage?.id) { if (editingPackage?.id) {
const changedFields: Record<string, any> = {}; const changedFields: Record<string, any> = {};
Object.keys(finalData).forEach((key) => { Object.keys(finalData).forEach((key) => {
@@ -289,18 +327,25 @@ export default function HealthPackagePage() {
} }
await updateHealthPackageApi(editingPackage.id, changedFields); await updateHealthPackageApi(editingPackage.id, changedFields);
toast.success("Package updated successfully!");
} else { } else {
await createHealthPackageApi(finalData); await createHealthPackageApi(finalData);
toast.success("Package created successfully!");
} }
setPackageModal(false); setPackageModal(false);
fetchData(); fetchData();
} catch (err) { } catch (err) {
console.error(err); console.error(err);
toast.error(
"An unexpected system error occurred while trying to save the package.",
);
} }
}; };
const saveCategory = async () => { const saveCategory = async () => {
if (!catForm.name?.trim()) return toast.error("Category Name is required.");
try { try {
if (editingCategory?.id) { if (editingCategory?.id) {
const changedFields: Record<string, any> = {}; const changedFields: Record<string, any> = {};
@@ -324,21 +369,30 @@ export default function HealthPackagePage() {
editingCategory.id, editingCategory.id,
changedFields as Partial<HealthCategory>, changedFields as Partial<HealthCategory>,
); );
toast.success("Category updated successfully!");
} else { } else {
await createCategoryApi(catForm as any); await createCategoryApi(catForm as any);
toast.success("Category created successfully!");
} }
setCategoryModal(false); setCategoryModal(false);
fetchData(); fetchData();
} catch (err) { } catch (err) {
console.error(err); console.error(err);
toast.error("An error occurred while saving the category.");
} }
}; };
const deleteCategory = async (id: number) => { const deleteCategory = async (id: number) => {
if (confirm("Delete this category? Ensure no packages are linked to it.")) { if (confirm("Delete this category? Ensure no packages are linked to it.")) {
try {
await deleteCategoryApi(id); await deleteCategoryApi(id);
toast.success("Category deleted successfully!");
fetchData(); fetchData();
} catch (err) {
console.error(err);
toast.error("Failed to delete category.");
}
} }
}; };
@@ -689,7 +743,7 @@ export default function HealthPackagePage() {
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm font-semibold"> <Label className="text-sm font-semibold">
Package Image Package Image(Dimensions: 650w x 250h)
</Label> </Label>
<BytescaleUploader <BytescaleUploader
@@ -788,7 +842,9 @@ export default function HealthPackagePage() {
onChange={(e) => onChange={(e) =>
setPkgForm({ setPkgForm({
...pkgForm, ...pkgForm,
price: Number(e.target.value), price: e.target.value
? Number(e.target.value)
: undefined,
}) })
} }
className="text-base" className="text-base"
@@ -804,7 +860,9 @@ export default function HealthPackagePage() {
onChange={(e) => onChange={(e) =>
setPkgForm({ setPkgForm({
...pkgForm, ...pkgForm,
discountedPrice: Number(e.target.value), discountedPrice: e.target.value
? Number(e.target.value)
: undefined,
}) })
} }
className="text-base" className="text-base"
@@ -949,16 +1007,6 @@ export default function HealthPackagePage() {
className="text-base" className="text-base"
/> />
</div> </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"> <div className="space-y-1">
<Label className="text-sm font-semibold">Sort Order</Label> <Label className="text-sm font-semibold">Sort Order</Label>