Spaces:
Sleeping
Sleeping
| import React, { useMemo, useState } from "react"; | |
| import { motion } from "framer-motion"; | |
| import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; | |
| import { Button } from "@/components/ui/button"; | |
| import { Input } from "@/components/ui/input"; | |
| import { Label } from "@/components/ui/label"; | |
| import { Textarea } from "@/components/ui/textarea"; | |
| import { Checkbox } from "@/components/ui/checkbox"; | |
| import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; | |
| import { Separator } from "@/components/ui/separator"; | |
| import { AlertCircle, CheckCircle2, Dumbbell, FileDown, ShieldAlert } from "lucide-react"; | |
| type EquipmentKey = "gym" | "bands" | "freeWeights" | "bodyWeight"; | |
| type FmsKey = | |
| | "deepSquat" | |
| | "hurdleStep" | |
| | "inlineLunge" | |
| | "shoulderMobility" | |
| | "activeStraightLegRaise" | |
| | "trunkStabilityPushUp" | |
| | "rotaryStability"; | |
| type ContraKey = | |
| | "highBloodPressure" | |
| | "cardiacHistory" | |
| | "lowBackPain" | |
| | "shoulderPain" | |
| | "kneePain" | |
| | "anklePain" | |
| | "pregnant" | |
| | "postSurgery" | |
| | "otherMedical"; | |
| type JointKey = | |
| | "footAnkle" | |
| | "knee" | |
| | "hip" | |
| | "lumbar" | |
| | "thoracic" | |
| | "cervical" | |
| | "shoulder" | |
| | "elbowWrist"; | |
| type Intake = { | |
| clientName: string; | |
| trainerName: string; | |
| age: string; | |
| sex: string; | |
| weightKg: string; | |
| heightCm: string; | |
| bodyFat: string; | |
| goal: string; | |
| trainingAge: string; | |
| frequencyPerWeek: string; | |
| sessionDurationMin: string; | |
| healthHistory: string; | |
| currentMedications: string; | |
| injuryNotes: string; | |
| equipment: Record<EquipmentKey, boolean>; | |
| contraindications: Record<ContraKey, boolean>; | |
| kineticChain: Record<JointKey, string>; | |
| fms: Record<FmsKey, number>; | |
| clearingTests: { | |
| shoulderPainWithClearing: boolean; | |
| extensionPainWithClearing: boolean; | |
| flexionPainWithClearing: boolean; | |
| rotaryPainWithClearing: boolean; | |
| }; | |
| }; | |
| type SubmissionPayload = { | |
| intake: Intake; | |
| rules: { | |
| training_less_than_3_days_is_total_body: boolean; | |
| include_all_7_patterns: boolean; | |
| include_primary_and_assistance_exercises: boolean; | |
| pdf_only_output: boolean; | |
| deload_every_6_to_8_weeks: boolean; | |
| progress_supported_to_integrated_then_more_closed_chain_when_tolerated: boolean; | |
| }; | |
| }; | |
| const APP_CONFIG = { | |
| appName: "Program Designer", | |
| organizationName: "The Fitclub Academy", | |
| apiBaseUrl: "/api/generate-program-pdf", | |
| pdfFilenamePrefix: "training-program", | |
| }; | |
| const equipmentLabels: Record<EquipmentKey, string> = { | |
| gym: "Gym Machines", | |
| bands: "Resistance Bands", | |
| freeWeights: "Free Weights", | |
| bodyWeight: "Body Weight", | |
| }; | |
| const contraLabels: Record<ContraKey, string> = { | |
| highBloodPressure: "High Blood Pressure", | |
| cardiacHistory: "Cardiac History", | |
| lowBackPain: "Low Back Pain", | |
| shoulderPain: "Shoulder Pain", | |
| kneePain: "Knee Pain", | |
| anklePain: "Ankle Pain", | |
| pregnant: "Pregnancy", | |
| postSurgery: "Post Surgery", | |
| otherMedical: "Other Medical Concern", | |
| }; | |
| const jointLabels: Record<JointKey, string> = { | |
| footAnkle: "Foot / Ankle", | |
| knee: "Knee", | |
| hip: "Hip", | |
| lumbar: "Lumbar Spine", | |
| thoracic: "Thoracic Spine", | |
| cervical: "Cervical Spine", | |
| shoulder: "Shoulder", | |
| elbowWrist: "Elbow / Wrist", | |
| }; | |
| const fmsFields: { key: FmsKey; label: string }[] = [ | |
| { key: "deepSquat", label: "Deep Squat" }, | |
| { key: "hurdleStep", label: "Hurdle Step" }, | |
| { key: "inlineLunge", label: "Inline Lunge" }, | |
| { key: "shoulderMobility", label: "Shoulder Mobility" }, | |
| { key: "activeStraightLegRaise", label: "ASLR" }, | |
| { key: "trunkStabilityPushUp", label: "Trunk Stability Push-Up" }, | |
| { key: "rotaryStability", label: "Rotary Stability" }, | |
| ]; | |
| const defaultIntake: Intake = { | |
| clientName: "", | |
| trainerName: "", | |
| age: "", | |
| sex: "", | |
| weightKg: "", | |
| heightCm: "", | |
| bodyFat: "", | |
| goal: "General fitness and movement quality", | |
| trainingAge: "beginner", | |
| frequencyPerWeek: "3", | |
| sessionDurationMin: "60", | |
| healthHistory: "", | |
| currentMedications: "", | |
| injuryNotes: "", | |
| equipment: { gym: true, bands: true, freeWeights: true, bodyWeight: true }, | |
| contraindications: { | |
| highBloodPressure: false, | |
| cardiacHistory: false, | |
| lowBackPain: false, | |
| shoulderPain: false, | |
| kneePain: false, | |
| anklePain: false, | |
| pregnant: false, | |
| postSurgery: false, | |
| otherMedical: false, | |
| }, | |
| kineticChain: { | |
| footAnkle: "", | |
| knee: "", | |
| hip: "", | |
| lumbar: "", | |
| thoracic: "", | |
| cervical: "", | |
| shoulder: "", | |
| elbowWrist: "", | |
| }, | |
| fms: { | |
| deepSquat: 1, | |
| hurdleStep: 1, | |
| inlineLunge: 1, | |
| shoulderMobility: 1, | |
| activeStraightLegRaise: 1, | |
| trunkStabilityPushUp: 1, | |
| rotaryStability: 1, | |
| }, | |
| clearingTests: { | |
| shoulderPainWithClearing: false, | |
| extensionPainWithClearing: false, | |
| flexionPainWithClearing: false, | |
| rotaryPainWithClearing: false, | |
| }, | |
| }; | |
| function num(value: string, fallback = 0) { | |
| const parsed = Number(value); | |
| return Number.isFinite(parsed) ? parsed : fallback; | |
| } | |
| function fmsTotal(intake: Intake) { | |
| return Object.values(intake.fms).reduce((sum, value) => sum + value, 0); | |
| } | |
| function bmi(intake: Intake) { | |
| const w = num(intake.weightKg); | |
| const h = num(intake.heightCm) / 100; | |
| if (!w || !h) return null; | |
| return +(w / (h * h)).toFixed(1); | |
| } | |
| function riskMessages(intake: Intake) { | |
| const flags: string[] = []; | |
| if (intake.clearingTests.shoulderPainWithClearing) flags.push("Shoulder clearing test pain recorded."); | |
| if (intake.clearingTests.extensionPainWithClearing) flags.push("Extension clearing test pain recorded."); | |
| if (intake.clearingTests.flexionPainWithClearing) flags.push("Flexion clearing test pain recorded."); | |
| if (intake.clearingTests.rotaryPainWithClearing) flags.push("Rotary clearing test pain recorded."); | |
| if (intake.contraindications.highBloodPressure) flags.push("High blood pressure flagged."); | |
| if (intake.contraindications.cardiacHistory) flags.push("Cardiac history flagged."); | |
| if (intake.contraindications.postSurgery) flags.push("Post-surgical history flagged."); | |
| return flags; | |
| } | |
| function buildPayload(intake: Intake): SubmissionPayload { | |
| return { | |
| intake, | |
| rules: { | |
| training_less_than_3_days_is_total_body: true, | |
| include_all_7_patterns: true, | |
| include_primary_and_assistance_exercises: true, | |
| pdf_only_output: true, | |
| deload_every_6_to_8_weeks: true, | |
| progress_supported_to_integrated_then_more_closed_chain_when_tolerated: true, | |
| }, | |
| }; | |
| } | |
| async function requestProgramPdf(payload: SubmissionPayload) { | |
| const response = await fetch(APP_CONFIG.apiBaseUrl, { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify(payload), | |
| }); | |
| if (!response.ok) { | |
| throw new Error("PDF generation failed"); | |
| } | |
| const blob = await response.blob(); | |
| const contentType = response.headers.get("content-type") || ""; | |
| if (!contentType.includes("pdf")) { | |
| throw new Error("Server did not return a PDF file"); | |
| } | |
| return blob; | |
| } | |
| function downloadBlob(blob: Blob, filename: string) { | |
| const url = URL.createObjectURL(blob); | |
| const anchor = document.createElement("a"); | |
| anchor.href = url; | |
| anchor.download = filename; | |
| document.body.appendChild(anchor); | |
| anchor.click(); | |
| anchor.remove(); | |
| URL.revokeObjectURL(url); | |
| } | |
| function buildFilename(intake: Intake) { | |
| const safeName = (intake.clientName || "client") | |
| .toLowerCase() | |
| .replace(/[^a-z0-9]+/g, "-") | |
| .replace(/^-+|-+$/g, ""); | |
| return `${APP_CONFIG.pdfFilenamePrefix}-${safeName || "client"}.pdf`; | |
| } | |
| function SectionHeader({ title, description }: { title: string; description: string }) { | |
| return ( | |
| <div className="space-y-1"> | |
| <h3 className="text-lg font-semibold">{title}</h3> | |
| <p className="text-sm text-muted-foreground">{description}</p> | |
| </div> | |
| ); | |
| } | |
| export default function TrainingProgramDesignerEmbed() { | |
| const [intake, setIntake] = useState<Intake>(defaultIntake); | |
| const [isSubmitting, setIsSubmitting] = useState(false); | |
| const [status, setStatus] = useState<"idle" | "success" | "error">("idle"); | |
| const [message, setMessage] = useState(""); | |
| const currentBmi = useMemo(() => bmi(intake), [intake]); | |
| const currentFmsTotal = useMemo(() => fmsTotal(intake), [intake]); | |
| const currentRiskMessages = useMemo(() => riskMessages(intake), [intake]); | |
| function setField<K extends keyof Intake>(key: K, value: Intake[K]) { | |
| setIntake((prev) => ({ ...prev, [key]: value })); | |
| } | |
| function setEquipment(key: EquipmentKey, value: boolean) { | |
| setIntake((prev) => ({ ...prev, equipment: { ...prev.equipment, [key]: value } })); | |
| } | |
| function setContra(key: ContraKey, value: boolean) { | |
| setIntake((prev) => ({ ...prev, contraindications: { ...prev.contraindications, [key]: value } })); | |
| } | |
| function setFms(key: FmsKey, value: number) { | |
| setIntake((prev) => ({ ...prev, fms: { ...prev.fms, [key]: value } })); | |
| } | |
| function setJoint(key: JointKey, value: string) { | |
| setIntake((prev) => ({ ...prev, kineticChain: { ...prev.kineticChain, [key]: value } })); | |
| } | |
| function setClearing<K extends keyof Intake["clearingTests"]>(key: K, value: boolean) { | |
| setIntake((prev) => ({ ...prev, clearingTests: { ...prev.clearingTests, [key]: value } })); | |
| } | |
| async function handleGenerate() { | |
| setIsSubmitting(true); | |
| setStatus("idle"); | |
| setMessage(""); | |
| try { | |
| const payload = buildPayload(intake); | |
| const pdfBlob = await requestProgramPdf(payload); | |
| downloadBlob(pdfBlob, buildFilename(intake)); | |
| setStatus("success"); | |
| setMessage("Your PDF program has been generated."); | |
| } catch (error) { | |
| setStatus("error"); | |
| setMessage(error instanceof Error ? error.message : "Unable to generate the PDF program."); | |
| } finally { | |
| setIsSubmitting(false); | |
| } | |
| } | |
| return ( | |
| <div className="min-h-screen bg-background p-4 md:p-6"> | |
| <div className="mx-auto max-w-5xl"> | |
| <motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }}> | |
| <Card className="rounded-3xl shadow-sm"> | |
| <CardHeader className="space-y-4"> | |
| <div className="flex items-start gap-3"> | |
| <div className="rounded-2xl border p-3"> | |
| <Dumbbell className="h-6 w-6" /> | |
| </div> | |
| <div> | |
| <CardTitle className="text-2xl">{APP_CONFIG.appName}</CardTitle> | |
| <CardDescription> | |
| Complete the form below and generate the client program PDF. | |
| </CardDescription> | |
| </div> | |
| </div> | |
| <div className="grid gap-3 sm:grid-cols-3"> | |
| <div className="rounded-2xl border p-4"> | |
| <div className="text-xs uppercase tracking-wide text-muted-foreground">BMI</div> | |
| <div className="mt-1 text-2xl font-semibold">{currentBmi ?? "—"}</div> | |
| </div> | |
| <div className="rounded-2xl border p-4"> | |
| <div className="text-xs uppercase tracking-wide text-muted-foreground">FMS Total</div> | |
| <div className="mt-1 text-2xl font-semibold">{currentFmsTotal}</div> | |
| </div> | |
| <div className="rounded-2xl border p-4"> | |
| <div className="text-xs uppercase tracking-wide text-muted-foreground">Training Split Rule</div> | |
| <div className="mt-1 text-sm font-semibold"> | |
| {num(intake.frequencyPerWeek, 3) < 3 ? "Total body sessions" : "Frequency-based split"} | |
| </div> | |
| </div> | |
| </div> | |
| </CardHeader> | |
| <CardContent className="space-y-8"> | |
| <div className="space-y-4"> | |
| <SectionHeader | |
| title="Client Details" | |
| description="Basic client information and training setup." | |
| /> | |
| <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4"> | |
| <div className="space-y-2"><Label>Client Name</Label><Input value={intake.clientName} onChange={(e) => setField("clientName", e.target.value)} /></div> | |
| <div className="space-y-2"><Label>Trainer Name</Label><Input value={intake.trainerName} onChange={(e) => setField("trainerName", e.target.value)} /></div> | |
| <div className="space-y-2"><Label>Age</Label><Input value={intake.age} onChange={(e) => setField("age", e.target.value)} /></div> | |
| <div className="space-y-2"><Label>Sex</Label><Input value={intake.sex} onChange={(e) => setField("sex", e.target.value)} /></div> | |
| <div className="space-y-2"><Label>Weight (kg)</Label><Input value={intake.weightKg} onChange={(e) => setField("weightKg", e.target.value)} /></div> | |
| <div className="space-y-2"><Label>Height (cm)</Label><Input value={intake.heightCm} onChange={(e) => setField("heightCm", e.target.value)} /></div> | |
| <div className="space-y-2"><Label>Body Fat %</Label><Input value={intake.bodyFat} onChange={(e) => setField("bodyFat", e.target.value)} /></div> | |
| <div className="space-y-2"> | |
| <Label>Training Age</Label> | |
| <Select value={intake.trainingAge} onValueChange={(value) => setField("trainingAge", value)}> | |
| <SelectTrigger><SelectValue /></SelectTrigger> | |
| <SelectContent> | |
| <SelectItem value="beginner">Beginner</SelectItem> | |
| <SelectItem value="intermediate">Intermediate</SelectItem> | |
| <SelectItem value="advanced">Advanced</SelectItem> | |
| </SelectContent> | |
| </Select> | |
| </div> | |
| <div className="space-y-2"><Label>Training Days / Week</Label><Input value={intake.frequencyPerWeek} onChange={(e) => setField("frequencyPerWeek", e.target.value)} /></div> | |
| <div className="space-y-2"><Label>Session Duration (min)</Label><Input value={intake.sessionDurationMin} onChange={(e) => setField("sessionDurationMin", e.target.value)} /></div> | |
| <div className="space-y-2 md:col-span-2"><Label>Primary Goal</Label><Input value={intake.goal} onChange={(e) => setField("goal", e.target.value)} /></div> | |
| </div> | |
| </div> | |
| <Separator /> | |
| <div className="space-y-4"> | |
| <SectionHeader | |
| title="Health and Injury History" | |
| description="Use these notes to account for medical history, symptoms, and local tissue concerns." | |
| /> | |
| <div className="grid gap-4 xl:grid-cols-3"> | |
| <div className="space-y-2"><Label>Health History</Label><Textarea rows={6} value={intake.healthHistory} onChange={(e) => setField("healthHistory", e.target.value)} /></div> | |
| <div className="space-y-2"><Label>Current Medications</Label><Textarea rows={6} value={intake.currentMedications} onChange={(e) => setField("currentMedications", e.target.value)} /></div> | |
| <div className="space-y-2"><Label>General Injury Notes</Label><Textarea rows={6} value={intake.injuryNotes} onChange={(e) => setField("injuryNotes", e.target.value)} /></div> | |
| </div> | |
| </div> | |
| <Separator /> | |
| <div className="space-y-4"> | |
| <SectionHeader | |
| title="Available Equipment" | |
| description="Only selected equipment should be used in the final program PDF." | |
| /> | |
| <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4"> | |
| {(Object.keys(equipmentLabels) as EquipmentKey[]).map((key) => ( | |
| <label key={key} className="flex items-start gap-3 rounded-2xl border p-4"> | |
| <Checkbox checked={intake.equipment[key]} onCheckedChange={(checked) => setEquipment(key, Boolean(checked))} /> | |
| <div className="font-medium">{equipmentLabels[key]}</div> | |
| </label> | |
| ))} | |
| </div> | |
| </div> | |
| <Separator /> | |
| <div className="space-y-4"> | |
| <SectionHeader | |
| title="FMS and Clearing Tests" | |
| description="Movement quality and pain findings should constrain exercise selection and progression." | |
| /> | |
| <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4"> | |
| {fmsFields.map(({ key, label }) => ( | |
| <div key={key} className="space-y-2 rounded-2xl border p-4"> | |
| <Label>{label}</Label> | |
| <Select value={String(intake.fms[key])} onValueChange={(value) => setFms(key, Number(value))}> | |
| <SelectTrigger><SelectValue /></SelectTrigger> | |
| <SelectContent> | |
| <SelectItem value="0">0</SelectItem> | |
| <SelectItem value="1">1</SelectItem> | |
| <SelectItem value="2">2</SelectItem> | |
| <SelectItem value="3">3</SelectItem> | |
| </SelectContent> | |
| </Select> | |
| </div> | |
| ))} | |
| </div> | |
| <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4"> | |
| <label className="flex items-start gap-3 rounded-2xl border p-4"><Checkbox checked={intake.clearingTests.shoulderPainWithClearing} onCheckedChange={(checked) => setClearing("shoulderPainWithClearing", Boolean(checked))} /><span className="text-sm">Shoulder clearing test pain</span></label> | |
| <label className="flex items-start gap-3 rounded-2xl border p-4"><Checkbox checked={intake.clearingTests.extensionPainWithClearing} onCheckedChange={(checked) => setClearing("extensionPainWithClearing", Boolean(checked))} /><span className="text-sm">Extension clearing test pain</span></label> | |
| <label className="flex items-start gap-3 rounded-2xl border p-4"><Checkbox checked={intake.clearingTests.flexionPainWithClearing} onCheckedChange={(checked) => setClearing("flexionPainWithClearing", Boolean(checked))} /><span className="text-sm">Flexion clearing test pain</span></label> | |
| <label className="flex items-start gap-3 rounded-2xl border p-4"><Checkbox checked={intake.clearingTests.rotaryPainWithClearing} onCheckedChange={(checked) => setClearing("rotaryPainWithClearing", Boolean(checked))} /><span className="text-sm">Rotary clearing test pain</span></label> | |
| </div> | |
| </div> | |
| <Separator /> | |
| <div className="space-y-4"> | |
| <SectionHeader | |
| title="Contraindications and Kinetic Chain Checkpoints" | |
| description="Flag contraindications and note site-specific issues." | |
| /> | |
| <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3"> | |
| {(Object.keys(contraLabels) as ContraKey[]).map((key) => ( | |
| <label key={key} className="flex items-start gap-3 rounded-2xl border p-4"> | |
| <Checkbox checked={intake.contraindications[key]} onCheckedChange={(checked) => setContra(key, Boolean(checked))} /> | |
| <div className="font-medium">{contraLabels[key]}</div> | |
| </label> | |
| ))} | |
| </div> | |
| <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4"> | |
| {(Object.keys(jointLabels) as JointKey[]).map((key) => ( | |
| <div key={key} className="space-y-2 rounded-2xl border p-4"> | |
| <Label>{jointLabels[key]}</Label> | |
| <Textarea rows={4} value={intake.kineticChain[key]} onChange={(e) => setJoint(key, e.target.value)} /> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| <Separator /> | |
| <div className="rounded-2xl border p-4"> | |
| <div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between"> | |
| <div> | |
| <div className="text-sm font-medium">Readiness Summary</div> | |
| <p className="text-sm text-muted-foreground"> | |
| {currentRiskMessages.length | |
| ? `${currentRiskMessages.length} caution flag${currentRiskMessages.length === 1 ? "" : "s"} recorded.` | |
| : "No major caution flags currently selected."} | |
| </p> | |
| </div> | |
| <Button onClick={handleGenerate} disabled={isSubmitting} className="rounded-2xl px-6"> | |
| <FileDown className="mr-2 h-4 w-4" /> | |
| {isSubmitting ? "Generating PDF..." : "Generate PDF"} | |
| </Button> | |
| </div> | |
| </div> | |
| {status !== "idle" && ( | |
| <div | |
| className={`rounded-2xl border p-4 text-sm ${ | |
| status === "success" | |
| ? "border-emerald-200 bg-emerald-50 text-emerald-800" | |
| : "border-red-200 bg-red-50 text-red-800" | |
| }`} | |
| > | |
| <div className="flex items-start gap-2"> | |
| {status === "success" ? <CheckCircle2 className="mt-0.5 h-4 w-4" /> : <AlertCircle className="mt-0.5 h-4 w-4" />} | |
| <span>{message}</span> | |
| </div> | |
| </div> | |
| )} | |
| {currentRiskMessages.length > 0 && ( | |
| <div className="rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900"> | |
| <div className="mb-2 flex items-center gap-2 font-medium"> | |
| <ShieldAlert className="h-4 w-4" /> Caution Flags | |
| </div> | |
| <ul className="list-disc space-y-1 pl-5"> | |
| {currentRiskMessages.map((item) => ( | |
| <li key={item}>{item}</li> | |
| ))} | |
| </ul> | |
| </div> | |
| )} | |
| </CardContent> | |
| </Card> | |
| </motion.div> | |
| </div> | |
| </div> | |
| ); | |
| } | |