From fc491f4050ac1794fa6e3c84d0a5f644e2bcd0eb Mon Sep 17 00:00:00 2001 From: rishalkv Date: Mon, 25 May 2026 16:20:47 +0530 Subject: [PATCH 1/8] feat: seo preview --- frontend/src/pages/Doctor.tsx | 128 +++++++++++++++++++++++++++++++++- 1 file changed, 126 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/Doctor.tsx b/frontend/src/pages/Doctor.tsx index b84ee08..2b7ce0d 100644 --- a/frontend/src/pages/Doctor.tsx +++ b/frontend/src/pages/Doctor.tsx @@ -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 { @@ -60,6 +60,8 @@ const DAYS = [ ]; export default function DoctorPage() { + const WEBSITE_URL = import.meta.env.VITE_WEBSITE_URL; + const [doctors, setDoctors] = useState([]); const [departments, setDepartments] = useState([]); const [loading, setLoading] = useState(true); @@ -100,6 +102,8 @@ export default function DoctorPage() { slug: "", tags: [], }); + const [openOgPreview, setOpenOgPreview] = useState(false); + const [previewDoctor, setPreviewDoctor] = useState(null); const fetchAll = useCallback(async () => { setLoading(true); @@ -316,7 +320,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() { try { @@ -332,6 +339,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 (
@@ -492,6 +517,14 @@ export default function DoctorPage() {
+
); } -- 2.43.0 From fa06126219b9be6319f2207bd27f2aa353b0859e Mon Sep 17 00:00:00 2001 From: rishalkv Date: Tue, 26 May 2026 11:38:34 +0530 Subject: [PATCH 2/8] chore: add seo reusable component --- .../src/components/SeoPreview/SeoPreview.tsx | 131 ++++++++++++++++++ frontend/src/pages/Doctor.tsx | 98 +------------ 2 files changed, 138 insertions(+), 91 deletions(-) create mode 100644 frontend/src/components/SeoPreview/SeoPreview.tsx diff --git a/frontend/src/components/SeoPreview/SeoPreview.tsx b/frontend/src/components/SeoPreview/SeoPreview.tsx new file mode 100644 index 0000000..84139ca --- /dev/null +++ b/frontend/src/components/SeoPreview/SeoPreview.tsx @@ -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 ( + + + + {title} + + + {previewData ? ( +
+
+

+ Social Media Preview (WhatsApp / Facebook) +

+ + +
+ OG Preview +
+ +
+

+ gg-hospital.com +

+ +

+ {ogTitle} +

+ +

+ {ogDescription} +

+
+
+
+ +
+

+ Google Search Preview +

+ +
+ +

+ {previewUrl} +

+ +

+ {searchTitle} +

+
+ +

+ {searchDescription} +

+
+
+
+ ) : ( +
+ No preview data available. +
+ )} + + + + +
+
+ ); +} diff --git a/frontend/src/pages/Doctor.tsx b/frontend/src/pages/Doctor.tsx index 2b7ce0d..c127f6f 100644 --- a/frontend/src/pages/Doctor.tsx +++ b/frontend/src/pages/Doctor.tsx @@ -28,6 +28,7 @@ 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"; @@ -1159,97 +1160,12 @@ export default function DoctorPage() { - - - - SEO Preview - - - {previewDoctor && ( -
- {/* SOCIAL MEDIA PREVIEW */} - - - {/* GOOGLE SEARCH PREVIEW */} -
-

- Google Search Preview -

- -
- - {/* URL */} -

- {getDoctorUrl(previewDoctor)} -

- - {/* TITLE */} -

- {previewDoctor?.seo?.seoTitle || - previewDoctor?.seo?.ogTitle || - "SEO title preview"} -

-
- - {/* DESCRIPTION */} -

- {previewDoctor?.seo?.metaDescription || - previewDoctor?.seo?.ogDescription || - "No meta description available"} -

-
-
-
- )} -
-
+
); } -- 2.43.0 From 4d73da5ddd527238317a6127f0eb7c7d25d2f15b Mon Sep 17 00:00:00 2001 From: Kailasdevdas Date: Tue, 26 May 2026 11:56:22 +0530 Subject: [PATCH 3/8] feat: health check seo --- .../migration.sql | 14 + backend/prisma/schema.prisma | 7 +- .../src/controllers/healthCheck.controller.js | 103 ++++- frontend/src/api/healthCheck.ts | 10 + .../BytescaleUploader/BytescaleUploader.tsx | 3 +- .../HealthPackageModal/HealthPackageModal.tsx | 436 ++++++++++++++++++ .../src/components/SeoFields/SeoFields.tsx | 211 +++++++++ frontend/src/components/ui/accordion.tsx | 79 ++++ frontend/src/pages/HealthPackagePage.tsx | 348 ++------------ 9 files changed, 886 insertions(+), 325 deletions(-) create mode 100644 backend/prisma/migrations/20260525122431_health_check_seo/migration.sql create mode 100644 frontend/src/components/HealthPackageModal/HealthPackageModal.tsx create mode 100644 frontend/src/components/SeoFields/SeoFields.tsx create mode 100644 frontend/src/components/ui/accordion.tsx diff --git a/backend/prisma/migrations/20260525122431_health_check_seo/migration.sql b/backend/prisma/migrations/20260525122431_health_check_seo/migration.sql new file mode 100644 index 0000000..77e55f5 --- /dev/null +++ b/backend/prisma/migrations/20260525122431_health_check_seo/migration.sql @@ -0,0 +1,14 @@ +/* + 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; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index d8ca932..5b326b5 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -259,6 +259,9 @@ model HealthPackage { inquiries HealthPackageInquiry[] + seoId Int? @unique + seo Seo? @relation(fields: [seoId], references: [id]) + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } @@ -294,7 +297,9 @@ model DoctorSpecialization { model Seo { id Int @id @default(autoincrement()) - doctor Doctor? + doctor Doctor? + healthPackage HealthPackage? + seoTitle String? metaDescription String? @db.Text diff --git a/backend/src/controllers/healthCheck.controller.js b/backend/src/controllers/healthCheck.controller.js index 7ee86bb..ea8d16c 100644 --- a/backend/src/controllers/healthCheck.controller.js +++ b/backend/src/controllers/healthCheck.controller.js @@ -121,7 +121,10 @@ export const getAllPackages = async (req, res) => { categorySlug ? { category: { slug: categorySlug } } : {}, ], }, - include: { category: true }, + include: { + category: true, + seo: true, + }, orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }], }); @@ -148,6 +151,7 @@ export const createPackage = async (req, res) => { isActive, isFeatured, sortOrder, + seo, } = req.body; const healthPackage = await prisma.healthPackage.create({ @@ -163,6 +167,25 @@ 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, }, }); @@ -183,13 +206,58 @@ 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: { + ...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, + }, }); return res @@ -204,11 +272,21 @@ 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", + }); } }; @@ -363,7 +441,11 @@ export const getPackageBySlug = async (req, res) => { const { slug } = req.params; const healthPackage = await prisma.healthPackage.findFirst({ where: { slug, isActive: true }, - include: { category: true }, + + include: { + category: true, + seo: true, + }, }); if (!healthPackage) { @@ -372,7 +454,10 @@ 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 @@ -414,7 +499,9 @@ export const getAllInquiries = async (req, res) => { take: queryLimit, include: { healthPackage: { - include: { category: true }, + include: { + category: true, + }, }, }, orderBy: { createdAt: "desc" }, diff --git a/frontend/src/api/healthCheck.ts b/frontend/src/api/healthCheck.ts index d6eb04a..8d7de22 100644 --- a/frontend/src/api/healthCheck.ts +++ b/frontend/src/api/healthCheck.ts @@ -1,6 +1,15 @@ 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; @@ -14,6 +23,7 @@ export interface HealthPackage { isActive: boolean; isFeatured: boolean; sortOrder: number; + seo?: SeoData | null; category?: { name: string; }; diff --git a/frontend/src/components/BytescaleUploader/BytescaleUploader.tsx b/frontend/src/components/BytescaleUploader/BytescaleUploader.tsx index 04b3f46..e5430e7 100644 --- a/frontend/src/components/BytescaleUploader/BytescaleUploader.tsx +++ b/frontend/src/components/BytescaleUploader/BytescaleUploader.tsx @@ -7,11 +7,12 @@ interface BytescaleUploaderProps { value: string; onChange: (url: string) => void; folderPath: + | "/health-packages" + | "/seo" | "/doctors" | "/departments" | "/news" | "/blog" - | "/health-packages" | "/doctor-og"; } diff --git a/frontend/src/components/HealthPackageModal/HealthPackageModal.tsx b/frontend/src/components/HealthPackageModal/HealthPackageModal.tsx new file mode 100644 index 0000000..4cb70cb --- /dev/null +++ b/frontend/src/components/HealthPackageModal/HealthPackageModal.tsx @@ -0,0 +1,436 @@ +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 ( + + + + + {editingPackage ? "Edit Health Package" : "Create Health Package"} + + + +
+
+ {/* LEFT COLUMN */} +
+
+
+

Profile & Pricing

+ +

+ Main package information +

+
+ +
+
+ + +

+ Recommended size: 650 × 250 +

+ + + setPkgForm({ + ...pkgForm, + image: url, + }) + } + /> +
+ +
+
+

Active Visibility

+ +

+ Show this package publicly +

+
+ + + setPkgForm({ + ...pkgForm, + isActive: val, + }) + } + /> +
+ +
+
+ + + + setPkgForm({ + ...pkgForm, + name: e.target.value, + }) + } + /> +
+ +
+ + + + setPkgForm({ + ...pkgForm, + slug: e.target.value, + }) + } + /> +
+
+ +
+ + + +
+ +
+
+ + + { + const value = e.target.value + ? Number(e.target.value) + : undefined; + + setPkgForm({ + ...pkgForm, + price: value, + }); + }} + /> +
+ +
+ + + + setPkgForm({ + ...pkgForm, + discountedPrice: e.target.value + ? Number(e.target.value) + : undefined, + }) + } + /> +
+ +
+ + + + setPkgForm({ + ...pkgForm, + sortOrder: Number(e.target.value), + }) + } + /> +
+
+ +
+ + +