From 4d73da5ddd527238317a6127f0eb7c7d25d2f15b Mon Sep 17 00:00:00 2001 From: Kailasdevdas Date: Tue, 26 May 2026 11:56:22 +0530 Subject: [PATCH] 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), + }) + } + /> +
+
+ +
+ + +