diff --git a/app/[locale]/(app)/auth/(auth-layout)/layout.tsx b/app/[locale]/(app)/auth/(auth-layout)/layout.tsx index 1063bb88..d1026b8a 100644 --- a/app/[locale]/(app)/auth/(auth-layout)/layout.tsx +++ b/app/[locale]/(app)/auth/(auth-layout)/layout.tsx @@ -1,12 +1,13 @@ import { redirect } from "next/navigation"; import { headers } from "next/headers"; - import { getI18n } from "locales/server"; + +import type { LayoutParams } from "@/shared/types/next"; + import { paths } from "@/shared/constants/paths"; import { auth } from "@/features/auth/lib/better-auth"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import type { LayoutParams } from "@/shared/types/next"; export default async function AuthLayout(props: LayoutParams<{}>) { const t = await getI18n(); diff --git a/app/[locale]/(app)/auth/(auth-layout)/signup/page.tsx b/app/[locale]/(app)/auth/(auth-layout)/signup/page.tsx index 824163b3..a6d5c347 100644 --- a/app/[locale]/(app)/auth/(auth-layout)/signup/page.tsx +++ b/app/[locale]/(app)/auth/(auth-layout)/signup/page.tsx @@ -1,6 +1,6 @@ import Link from "next/link"; - import { getI18n } from "locales/server"; + import { paths } from "@/shared/constants/paths"; import { SignUpForm } from "@/features/auth/signup/ui/signup-form"; diff --git a/app/[locale]/(app)/auth/error.tsx b/app/[locale]/(app)/auth/error.tsx index fddb404b..2bb88142 100644 --- a/app/[locale]/(app)/auth/error.tsx +++ b/app/[locale]/(app)/auth/error.tsx @@ -2,11 +2,12 @@ import { useEffect } from "react"; +import type { ErrorParams } from "@/shared/types/next"; + import { logger } from "@/shared/lib/logger"; import { Card, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; -import type { ErrorParams } from "@/shared/types/next"; export default function RouteError({ error, reset }: ErrorParams) { useEffect(() => { diff --git a/app/[locale]/(app)/auth/verify-email/layout.tsx b/app/[locale]/(app)/auth/verify-email/layout.tsx index 747d8786..7ea6d42b 100644 --- a/app/[locale]/(app)/auth/verify-email/layout.tsx +++ b/app/[locale]/(app)/auth/verify-email/layout.tsx @@ -1,6 +1,7 @@ -import type { ReactNode } from "react"; import { redirect } from "next/navigation"; +import type { ReactNode } from "react"; + import { getServerUrl } from "@/shared/lib/server-url"; import { paths } from "@/shared/constants/paths"; import { serverRequiredUser } from "@/entities/user/model/get-server-session-user"; diff --git a/app/[locale]/(app)/layout.tsx b/app/[locale]/(app)/layout.tsx index 5b6fc978..4a65e292 100644 --- a/app/[locale]/(app)/layout.tsx +++ b/app/[locale]/(app)/layout.tsx @@ -13,7 +13,8 @@ export default async function RootLayout({ children }: RootLayoutProps) { return (
-
{children}
+ {/* pb-16 clears the docked BottomNavigation, which is fixed at every width. */} +
{children}
diff --git a/app/[locale]/(app)/leaderboard/page.tsx b/app/[locale]/(app)/leaderboard/page.tsx index 018cd2d2..02d78fba 100644 --- a/app/[locale]/(app)/leaderboard/page.tsx +++ b/app/[locale]/(app)/leaderboard/page.tsx @@ -1,7 +1,7 @@ import { Metadata } from "next"; - import { Locale } from "locales/types"; import { getI18n } from "locales/server"; + import LeaderboardPage from "@/features/leaderboard/ui/leaderboard-page"; import { Breadcrumbs } from "@/components/seo/breadcrumbs"; diff --git a/app/[locale]/(app)/profile/page.tsx b/app/[locale]/(app)/profile/page.tsx index 356d1f7c..e426d23e 100644 --- a/app/[locale]/(app)/profile/page.tsx +++ b/app/[locale]/(app)/profile/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useRouter } from "next/navigation"; - import { useI18n } from "locales/client"; + import { WorkoutSessionList } from "@/features/workout-session/ui/workout-session-list"; import { WorkoutSessionHeatmap } from "@/features/workout-session/ui/workout-session-heatmap"; import { useWorkoutSessions } from "@/features/workout-session/model/use-workout-sessions"; diff --git a/app/[locale]/(app)/programs/[slug]/page.tsx b/app/[locale]/(app)/programs/[slug]/page.tsx index 81bcb913..08ec98a7 100644 --- a/app/[locale]/(app)/programs/[slug]/page.tsx +++ b/app/[locale]/(app)/programs/[slug]/page.tsx @@ -1,9 +1,9 @@ import { notFound } from "next/navigation"; import { headers } from "next/headers"; import { Metadata } from "next"; - import { Locale } from "locales/types"; import { getI18n } from "locales/server"; + import { generateStructuredData, StructuredDataScript } from "@/shared/lib/structured-data"; import { getLocalizedMetadata } from "@/shared/config/localized-metadata"; import { ProgramDetailPage } from "@/features/programs/ui/program-detail-page"; diff --git a/app/[locale]/(app)/programs/[slug]/session/[sessionSlug]/ProgramSessionClient.tsx b/app/[locale]/(app)/programs/[slug]/session/[sessionSlug]/ProgramSessionClient.tsx index f831e64e..13efd242 100644 --- a/app/[locale]/(app)/programs/[slug]/session/[sessionSlug]/ProgramSessionClient.tsx +++ b/app/[locale]/(app)/programs/[slug]/session/[sessionSlug]/ProgramSessionClient.tsx @@ -3,9 +3,9 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import { ArrowLeft, Play } from "lucide-react"; +import { useCurrentLocale, useI18n } from "locales/client"; import { ExerciseAttributeNameEnum, ProgramWeek } from "@prisma/client"; -import { useCurrentLocale, useI18n } from "locales/client"; import { canStartSession } from "@/shared/lib/access-control"; import { WorkoutSessionSets } from "@/features/workout-session/ui/workout-session-sets"; import { WorkoutSessionHeader } from "@/features/workout-session/ui/workout-session-header"; diff --git a/app/[locale]/(app)/programs/[slug]/session/[sessionSlug]/page.tsx b/app/[locale]/(app)/programs/[slug]/session/[sessionSlug]/page.tsx index 340aff3e..5f19b234 100644 --- a/app/[locale]/(app)/programs/[slug]/session/[sessionSlug]/page.tsx +++ b/app/[locale]/(app)/programs/[slug]/session/[sessionSlug]/page.tsx @@ -1,9 +1,11 @@ import { notFound } from "next/navigation"; import { headers } from "next/headers"; import { Metadata } from "next"; - import { Locale } from "locales/types"; import { getI18n } from "locales/server"; + +import { ProgramSessionClient } from "./ProgramSessionClient"; + import { generateStructuredData, StructuredDataScript } from "@/shared/lib/structured-data"; import { getSessionTitle, getProgramTitle } from "@/features/programs/lib/translations-mapper"; import { generateSessionMetadata } from "@/features/programs/lib/session-metadata"; @@ -12,7 +14,6 @@ import { auth } from "@/features/auth/lib/better-auth"; import { Breadcrumbs } from "@/components/seo/breadcrumbs"; // Import the existing session client component -import { ProgramSessionClient } from "./ProgramSessionClient"; interface SessionDetailPageProps { params: Promise<{ slug: string; sessionSlug: string; locale: Locale }>; diff --git a/app/[locale]/(app)/programs/page.tsx b/app/[locale]/(app)/programs/page.tsx index 8e5631e3..b61b59f9 100644 --- a/app/[locale]/(app)/programs/page.tsx +++ b/app/[locale]/(app)/programs/page.tsx @@ -1,7 +1,7 @@ import { Metadata } from "next"; - import { Locale } from "locales/types"; import { getI18n } from "locales/server"; + import { ProgramsPage } from "@/features/programs/ui/programs-page"; import { Breadcrumbs } from "@/components/seo/breadcrumbs"; diff --git a/app/[locale]/(app)/sponsor/onboarding/page.tsx b/app/[locale]/(app)/sponsor/onboarding/page.tsx index 21f49fc4..b63f6454 100644 --- a/app/[locale]/(app)/sponsor/onboarding/page.tsx +++ b/app/[locale]/(app)/sponsor/onboarding/page.tsx @@ -1,7 +1,7 @@ import { Metadata } from "next"; import { Sparkles } from "lucide-react"; - import { getI18n } from "locales/server"; + import { SponsorOnboardingForm } from "@/features/sponsor/onboarding/SponsorOnboardingForm"; export const metadata: Metadata = { diff --git a/app/[locale]/(app)/statistics/page.tsx b/app/[locale]/(app)/statistics/page.tsx index 035161f3..c75768c2 100644 --- a/app/[locale]/(app)/statistics/page.tsx +++ b/app/[locale]/(app)/statistics/page.tsx @@ -1,6 +1,6 @@ -import React from "react"; import { getI18n } from "locales/server"; + import { ExercisesBrowser } from "@/features/statistics/components/ExercisesBrowser"; import { PremiumGate } from "@/components/ui/premium-gate"; diff --git a/app/[locale]/(app)/tools/bmi-calculator/page.tsx b/app/[locale]/(app)/tools/bmi-calculator/page.tsx index 340e7c3b..3160462c 100644 --- a/app/[locale]/(app)/tools/bmi-calculator/page.tsx +++ b/app/[locale]/(app)/tools/bmi-calculator/page.tsx @@ -1,11 +1,11 @@ -import React from "react"; import { Metadata } from "next"; - import { getI18n } from "locales/server"; import { BmiEducationalContent } from "app/[locale]/(app)/tools/bmi-calculator/shared/components/BmiEducationalContent"; import { BmiCalculatorClient } from "app/[locale]/(app)/tools/bmi-calculator/shared/BmiCalculatorClient"; + import { getServerUrl } from "@/shared/lib/server-url"; import { env } from "@/env"; +import { RelatedTools } from "@/components/tools/RelatedTools"; import { generateSEOMetadata, SEOScripts } from "@/components/seo/SEOHead"; import { HorizontalBottomBanner, HorizontalTopBanner } from "@/components/ads"; @@ -115,6 +115,11 @@ export default async function BmiCalculatorPage({ params }: { params: Promise<{
+ + {(env.NEXT_PUBLIC_BOTTOM_BMI_BANNER_AD_SLOT || env.NEXT_PUBLIC_EZOIC_BOTTOM_BMI_PLACEMENT_ID) && ( }): Promise { const { locale } = await params; const t = await getI18n(); @@ -71,6 +71,11 @@ export default async function CalorieCalculatorPage({ params }: { params: Promis
+ +
diff --git a/app/[locale]/(app)/tools/calorie-calculator/shared/CalorieCalculatorClient.tsx b/app/[locale]/(app)/tools/calorie-calculator/shared/CalorieCalculatorClient.tsx index 84c73adf..8edc0590 100644 --- a/app/[locale]/(app)/tools/calorie-calculator/shared/CalorieCalculatorClient.tsx +++ b/app/[locale]/(app)/tools/calorie-calculator/shared/CalorieCalculatorClient.tsx @@ -1,11 +1,9 @@ "use client"; -import React, { useState } from "react"; +import { useState } from "react"; import { Calculator } from "lucide-react"; - import { useI18n } from "locales/client"; -import { env } from "@/env"; -import { HorizontalBottomBanner } from "@/components/ads"; + import { BodyFatInput } from "./components/BodyFatInput"; import { @@ -22,6 +20,9 @@ import { calculateCalories, type CalorieCalculatorInputs, type CalorieResults } import type { CalculatorConfig } from "./types"; +import { env } from "@/env"; +import { HorizontalBottomBanner } from "@/components/ads"; + interface CalorieCalculatorClientProps { config: CalculatorConfig; } diff --git a/app/[locale]/(app)/tools/calorie-calculator/shared/components/ActivityLevelSelector.tsx b/app/[locale]/(app)/tools/calorie-calculator/shared/components/ActivityLevelSelector.tsx index 63cb3b45..548d1fcd 100644 --- a/app/[locale]/(app)/tools/calorie-calculator/shared/components/ActivityLevelSelector.tsx +++ b/app/[locale]/(app)/tools/calorie-calculator/shared/components/ActivityLevelSelector.tsx @@ -2,7 +2,6 @@ import React from "react"; import { ActivityIcon, BedIcon, BedDoubleIcon, BikeIcon, ZapIcon } from "lucide-react"; - import { useI18n } from "locales/client"; import { ActivityLevel } from "app/[locale]/(app)/tools/calorie-calculator/calorie-calculator.utils"; diff --git a/app/[locale]/(app)/tools/calorie-calculator/shared/components/AgeInput.tsx b/app/[locale]/(app)/tools/calorie-calculator/shared/components/AgeInput.tsx index 6078b4f9..b0e2038b 100644 --- a/app/[locale]/(app)/tools/calorie-calculator/shared/components/AgeInput.tsx +++ b/app/[locale]/(app)/tools/calorie-calculator/shared/components/AgeInput.tsx @@ -1,6 +1,5 @@ "use client"; -import React from "react"; import { useI18n } from "locales/client"; diff --git a/app/[locale]/(app)/tools/calorie-calculator/shared/components/BodyFatInput.tsx b/app/[locale]/(app)/tools/calorie-calculator/shared/components/BodyFatInput.tsx index 382e5b88..5aeea3bd 100644 --- a/app/[locale]/(app)/tools/calorie-calculator/shared/components/BodyFatInput.tsx +++ b/app/[locale]/(app)/tools/calorie-calculator/shared/components/BodyFatInput.tsx @@ -1,6 +1,5 @@ "use client"; -import React from "react"; import { useI18n } from "locales/client"; diff --git a/app/[locale]/(app)/tools/calorie-calculator/shared/components/FAQSection.tsx b/app/[locale]/(app)/tools/calorie-calculator/shared/components/FAQSection.tsx index 2deec522..5147c557 100644 --- a/app/[locale]/(app)/tools/calorie-calculator/shared/components/FAQSection.tsx +++ b/app/[locale]/(app)/tools/calorie-calculator/shared/components/FAQSection.tsx @@ -1,8 +1,7 @@ "use client"; -import React, { useState } from "react"; +import { useState } from "react"; import { ChevronDownIcon, HelpCircleIcon } from "lucide-react"; - import { useI18n } from "locales/client"; export function FAQSection() { diff --git a/app/[locale]/(app)/tools/calorie-calculator/shared/components/GenderSelector.tsx b/app/[locale]/(app)/tools/calorie-calculator/shared/components/GenderSelector.tsx index 687393b7..9ffc0b7b 100644 --- a/app/[locale]/(app)/tools/calorie-calculator/shared/components/GenderSelector.tsx +++ b/app/[locale]/(app)/tools/calorie-calculator/shared/components/GenderSelector.tsx @@ -1,8 +1,6 @@ "use client"; -import React from "react"; import { UserIcon, UsersIcon } from "lucide-react"; - import { useI18n } from "locales/client"; import { Gender } from "app/[locale]/(app)/tools/calorie-calculator/calorie-calculator.utils"; diff --git a/app/[locale]/(app)/tools/calorie-calculator/shared/components/GoalSelector.tsx b/app/[locale]/(app)/tools/calorie-calculator/shared/components/GoalSelector.tsx index 6399220e..78037daa 100644 --- a/app/[locale]/(app)/tools/calorie-calculator/shared/components/GoalSelector.tsx +++ b/app/[locale]/(app)/tools/calorie-calculator/shared/components/GoalSelector.tsx @@ -2,7 +2,6 @@ import React from "react"; import { TrendingDownIcon, TrendingUpIcon, ScaleIcon, RocketIcon, ZapOffIcon } from "lucide-react"; - import { useI18n } from "locales/client"; import { Goal } from "app/[locale]/(app)/tools/calorie-calculator/calorie-calculator.utils"; diff --git a/app/[locale]/(app)/tools/calorie-calculator/shared/components/HeightInput.tsx b/app/[locale]/(app)/tools/calorie-calculator/shared/components/HeightInput.tsx index d6701449..8bb25c3a 100644 --- a/app/[locale]/(app)/tools/calorie-calculator/shared/components/HeightInput.tsx +++ b/app/[locale]/(app)/tools/calorie-calculator/shared/components/HeightInput.tsx @@ -1,6 +1,5 @@ "use client"; -import React from "react"; import { useI18n } from "locales/client"; import { UnitSystem } from "app/[locale]/(app)/tools/calorie-calculator/calorie-calculator.utils"; diff --git a/app/[locale]/(app)/tools/calorie-calculator/shared/components/InfoModal.tsx b/app/[locale]/(app)/tools/calorie-calculator/shared/components/InfoModal.tsx index 71a6f98d..55478597 100644 --- a/app/[locale]/(app)/tools/calorie-calculator/shared/components/InfoModal.tsx +++ b/app/[locale]/(app)/tools/calorie-calculator/shared/components/InfoModal.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useEffect } from "react"; +import { useEffect } from "react"; import { XIcon } from "lucide-react"; interface InfoModalProps { diff --git a/app/[locale]/(app)/tools/calorie-calculator/shared/components/ResultsDisplay.tsx b/app/[locale]/(app)/tools/calorie-calculator/shared/components/ResultsDisplay.tsx index 515ccdc2..dda0071d 100644 --- a/app/[locale]/(app)/tools/calorie-calculator/shared/components/ResultsDisplay.tsx +++ b/app/[locale]/(app)/tools/calorie-calculator/shared/components/ResultsDisplay.tsx @@ -1,9 +1,8 @@ "use client"; -import React, { useState } from "react"; +import { useState } from "react"; import Image from "next/image"; import { BeefIcon, WheatIcon, DropletIcon, FlameIcon, ActivityIcon } from "lucide-react"; - import { useI18n } from "locales/client"; import { CalorieResults } from "app/[locale]/(app)/tools/calorie-calculator/calorie-calculator.utils"; diff --git a/app/[locale]/(app)/tools/calorie-calculator/shared/components/UnitSelector.tsx b/app/[locale]/(app)/tools/calorie-calculator/shared/components/UnitSelector.tsx index 68988f5a..3296eeda 100644 --- a/app/[locale]/(app)/tools/calorie-calculator/shared/components/UnitSelector.tsx +++ b/app/[locale]/(app)/tools/calorie-calculator/shared/components/UnitSelector.tsx @@ -1,8 +1,6 @@ "use client"; -import React from "react"; import { RulerIcon, GlobeIcon } from "lucide-react"; - import { useI18n } from "locales/client"; import { UnitSystem } from "app/[locale]/(app)/tools/calorie-calculator/calorie-calculator.utils"; diff --git a/app/[locale]/(app)/tools/calorie-calculator/shared/components/WeightInput.tsx b/app/[locale]/(app)/tools/calorie-calculator/shared/components/WeightInput.tsx index 2b080f00..15437ea9 100644 --- a/app/[locale]/(app)/tools/calorie-calculator/shared/components/WeightInput.tsx +++ b/app/[locale]/(app)/tools/calorie-calculator/shared/components/WeightInput.tsx @@ -1,6 +1,5 @@ "use client"; -import React from "react"; import { useI18n } from "locales/client"; import { UnitSystem } from "app/[locale]/(app)/tools/calorie-calculator/calorie-calculator.utils"; diff --git a/app/[locale]/(app)/tools/heart-rate-zones/page.tsx b/app/[locale]/(app)/tools/heart-rate-zones/page.tsx index 6e8468bc..956704da 100644 --- a/app/[locale]/(app)/tools/heart-rate-zones/page.tsx +++ b/app/[locale]/(app)/tools/heart-rate-zones/page.tsx @@ -1,6 +1,4 @@ -import React from "react"; import { Metadata } from "next"; - import { Locale } from "locales/types"; import { getI18n } from "locales/server"; import { HeartRateZonesCalculatorClient } from "app/[locale]/(app)/tools/heart-rate-zones/ui/HeartRateZonesCalculatorClient"; @@ -9,6 +7,7 @@ import { EducationalContentServer } from "app/[locale]/(app)/tools/heart-rate-zo import { HEART_RATE_ZONES_CONTENT } from "app/[locale]/(app)/tools/heart-rate-zones/seo/page-content"; import { HEART_RATE_ZONES_SEO } from "app/[locale]/(app)/tools/heart-rate-zones/seo/config"; import { calculateHeartRateZones } from "app/[locale]/(app)/tools/heart-rate-zones/lib/utils"; + import { getServerUrl } from "@/shared/lib/server-url"; import { env } from "@/env"; import { generateSEOMetadata, SEOScripts } from "@/components/seo/SEOHead"; diff --git a/app/[locale]/(app)/tools/heart-rate-zones/ui/HeartRateZonesCalculatorClient.tsx b/app/[locale]/(app)/tools/heart-rate-zones/ui/HeartRateZonesCalculatorClient.tsx index 54c77c21..b3e9096e 100644 --- a/app/[locale]/(app)/tools/heart-rate-zones/ui/HeartRateZonesCalculatorClient.tsx +++ b/app/[locale]/(app)/tools/heart-rate-zones/ui/HeartRateZonesCalculatorClient.tsx @@ -2,10 +2,10 @@ import { z } from "zod"; import { useForm } from "react-hook-form"; -import React, { useState, useEffect } from "react"; +import { useState, useEffect } from "react"; +import { useI18n } from "locales/client"; import { zodResolver } from "@hookform/resolvers/zod"; -import { useI18n } from "locales/client"; import "./styles.css"; import { env } from "@/env"; import { InArticle } from "@/components/ads"; diff --git a/app/[locale]/(app)/tools/heart-rate-zones/ui/components/EducationalContent.tsx b/app/[locale]/(app)/tools/heart-rate-zones/ui/components/EducationalContent.tsx index 5069f0d0..a9276dc5 100644 --- a/app/[locale]/(app)/tools/heart-rate-zones/ui/components/EducationalContent.tsx +++ b/app/[locale]/(app)/tools/heart-rate-zones/ui/components/EducationalContent.tsx @@ -1,8 +1,8 @@ "use client"; -import React from "react"; import { TFunction, useI18n } from "locales/client"; + import { useScrollToTop } from "@/shared/hooks/useScrollToTop"; export function EducationalContent() { diff --git a/app/[locale]/(app)/tools/heart-rate-zones/ui/components/EducationalContentServer.tsx b/app/[locale]/(app)/tools/heart-rate-zones/ui/components/EducationalContentServer.tsx index aa15202f..66cbe70d 100644 --- a/app/[locale]/(app)/tools/heart-rate-zones/ui/components/EducationalContentServer.tsx +++ b/app/[locale]/(app)/tools/heart-rate-zones/ui/components/EducationalContentServer.tsx @@ -1,4 +1,3 @@ -import React from "react"; import { getI18n } from "locales/server"; import { ScrollToTopButton } from "app/[locale]/(app)/tools/heart-rate-zones/ui/components/ScrollToTopButton"; diff --git a/app/[locale]/(app)/tools/heart-rate-zones/ui/components/FAQAccordion.tsx b/app/[locale]/(app)/tools/heart-rate-zones/ui/components/FAQAccordion.tsx index 2528e192..9a1277a2 100644 --- a/app/[locale]/(app)/tools/heart-rate-zones/ui/components/FAQAccordion.tsx +++ b/app/[locale]/(app)/tools/heart-rate-zones/ui/components/FAQAccordion.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState } from "react"; +import { useState } from "react"; interface FAQItem { question: string; diff --git a/app/[locale]/(app)/tools/heart-rate-zones/ui/components/SEOOptimizedContentServer.tsx b/app/[locale]/(app)/tools/heart-rate-zones/ui/components/SEOOptimizedContentServer.tsx index 593f79d3..bad5dc6e 100644 --- a/app/[locale]/(app)/tools/heart-rate-zones/ui/components/SEOOptimizedContentServer.tsx +++ b/app/[locale]/(app)/tools/heart-rate-zones/ui/components/SEOOptimizedContentServer.tsx @@ -1,9 +1,8 @@ -import React from "react"; import Link from "next/link"; - import { getI18n } from "locales/server"; import { ScrollToTopButton } from "app/[locale]/(app)/tools/heart-rate-zones/ui/components/ScrollToTopButton"; import { FAQAccordion } from "app/[locale]/(app)/tools/heart-rate-zones/ui/components/FAQAccordion"; + import { env } from "@/env"; import { InArticle } from "@/components/ads"; diff --git a/app/[locale]/(app)/tools/heart-rate-zones/ui/components/ScrollToTopButton.tsx b/app/[locale]/(app)/tools/heart-rate-zones/ui/components/ScrollToTopButton.tsx index c182ab50..904b1ace 100644 --- a/app/[locale]/(app)/tools/heart-rate-zones/ui/components/ScrollToTopButton.tsx +++ b/app/[locale]/(app)/tools/heart-rate-zones/ui/components/ScrollToTopButton.tsx @@ -1,6 +1,5 @@ "use client"; -import React from "react"; import { useScrollToTop } from "@/shared/hooks/useScrollToTop"; diff --git a/app/[locale]/(app)/tools/page.tsx b/app/[locale]/(app)/tools/page.tsx index 756acd1f..da1c5717 100644 --- a/app/[locale]/(app)/tools/page.tsx +++ b/app/[locale]/(app)/tools/page.tsx @@ -2,9 +2,9 @@ import React from "react"; import Link from "next/link"; import Image from "next/image"; import { Metadata } from "next"; -import { CalculatorIcon, ScaleIcon, HeartIcon, DumbbellIcon, RepeatIcon } from "lucide-react"; - +import { CalculatorIcon, ScaleIcon, HeartIcon, DumbbellIcon, RepeatIcon, SyringeIcon } from "lucide-react"; import { getI18n } from "locales/server"; + import { env } from "@/env"; import { HorizontalBottomBanner, HorizontalTopBanner } from "@/components/ads"; @@ -52,6 +52,14 @@ const fitnessTools: FitnessTool[] = [ gradientTo: "to-[#7C3AED]", href: "/tools/heart-rate-zones", }, + { + id: "peptide-calculator", + icon: , + emoji: "WorkoutCoolMedical.png", + gradientFrom: "from-[#06B6D4]", + gradientTo: "to-[#0891B2]", + href: "/tools/peptide-calculator", + }, { id: "one-rep-max", icon: , diff --git a/app/[locale]/(app)/tools/peptide-calculator/lib/calculate.test.ts b/app/[locale]/(app)/tools/peptide-calculator/lib/calculate.test.ts new file mode 100644 index 00000000..d65a6359 --- /dev/null +++ b/app/[locale]/(app)/tools/peptide-calculator/lib/calculate.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; + +import { calculateDoseFromUnits, calculatePeptideDose } from "./calculate"; + +const BASE = { vialMg: 10, waterMl: 5, doseMcg: 250, syringeCapacity: 50 } as const; + +describe("calculatePeptideDose", () => { + it("reproduit l'exemple de référence : 10 mg / 5 ml / 250 mcg", () => { + const result = calculatePeptideDose(BASE); + + expect(result).not.toBeNull(); + expect(result!.concentrationMgPerMl).toBe(2); + expect(result!.volumeMl).toBeCloseTo(0.125, 10); + expect(result!.units).toBeCloseTo(12.5, 10); + expect(result!.dosesPerVial).toBe(40); + expect(result!.warnings).toEqual([]); + }); + + it("signale EXCEEDS_SYRINGE quand la dose dépasse la seringue", () => { + const result = calculatePeptideDose({ vialMg: 5, waterMl: 1, doseMcg: 3000, syringeCapacity: 50 }); + + expect(result!.units).toBeCloseTo(60, 10); + expect(result!.warnings).toContain("EXCEEDS_SYRINGE"); + }); + + it("signale TOO_SMALL_TO_MEASURE sous 2 graduations", () => { + const result = calculatePeptideDose({ vialMg: 10, waterMl: 1, doseMcg: 100, syringeCapacity: 50 }); + + expect(result!.units).toBeCloseTo(1, 10); + expect(result!.warnings).toContain("TOO_SMALL_TO_MEASURE"); + }); + + it("signale WATER_EXCEEDS_VIAL au-delà de 10 ml", () => { + const result = calculatePeptideDose({ vialMg: 10, waterMl: 15, doseMcg: 250, syringeCapacity: 50 }); + + expect(result!.warnings).toContain("WATER_EXCEEDS_VIAL"); + }); + + it("signale NOT_A_WHOLE_GRADUATION hors multiples de 0,5", () => { + const result = calculatePeptideDose({ vialMg: 10, waterMl: 5, doseMcg: 246, syringeCapacity: 50 }); + + expect(result!.units).toBeCloseTo(12.3, 10); + expect(result!.warnings).toContain("NOT_A_WHOLE_GRADUATION"); + }); + + it("ne signale pas NOT_A_WHOLE_GRADUATION sur un demi-multiple", () => { + expect(calculatePeptideDose(BASE)!.warnings).not.toContain("NOT_A_WHOLE_GRADUATION"); + }); + + it("rend null sur entrée invalide", () => { + expect(calculatePeptideDose({ ...BASE, vialMg: 0 })).toBeNull(); + expect(calculatePeptideDose({ ...BASE, waterMl: -1 })).toBeNull(); + expect(calculatePeptideDose({ ...BASE, doseMcg: Number.NaN })).toBeNull(); + expect(calculatePeptideDose({ ...BASE, vialMg: 5000 })).toBeNull(); + }); +}); + +describe("calculateDoseFromUnits", () => { + it("est l'inverse exact de calculatePeptideDose", () => { + const forward = calculatePeptideDose(BASE)!; + const back = calculateDoseFromUnits({ vialMg: BASE.vialMg, waterMl: BASE.waterMl, units: forward.units }); + + expect(back).toBeCloseTo(BASE.doseMcg, 10); + }); + + it("répond encore au-delà de la plus grande seringue", () => { + // Un prélèvement qui déborde est un avertissement affiché en rouge, pas une entrée refusée. + expect(calculateDoseFromUnits({ vialMg: 5, waterMl: 10, units: 150 })).toBeCloseTo(750, 10); + }); + + it("rend null sur entrée invalide", () => { + expect(calculateDoseFromUnits({ vialMg: 10, waterMl: 5, units: 0 })).toBeNull(); + expect(calculateDoseFromUnits({ vialMg: 0, waterMl: 5, units: 10 })).toBeNull(); + }); +}); diff --git a/app/[locale]/(app)/tools/peptide-calculator/lib/calculate.ts b/app/[locale]/(app)/tools/peptide-calculator/lib/calculate.ts new file mode 100644 index 00000000..525c4bf0 --- /dev/null +++ b/app/[locale]/(app)/tools/peptide-calculator/lib/calculate.ts @@ -0,0 +1,58 @@ +import { PeptideInput, PeptideResult, ReverseInput, WarningCode } from "./types"; + +export const UNITS_PER_ML = 100; + +const MAX_VIAL_MG = 1000; +const MAX_WATER_ML = 100; +const MAX_DOSE_MCG = 100_000; +/** + * Deliberately well above the 100-unit barrel: a draw that overflows the selected syringe is + * a warning the result block renders in red, never a reason to stop answering. This ceiling + * only rejects nonsense. + */ +const MAX_UNITS = 1000; +const MIN_MEASURABLE_UNITS = 2; +const TYPICAL_MAX_WATER_ML = 10; +const EPSILON = 1e-9; + +function isPositiveFinite(value: number, max: number): boolean { + return Number.isFinite(value) && value > 0 && value <= max; +} + +function isWholeGraduation(units: number): boolean { + return Math.abs(units * 2 - Math.round(units * 2)) < EPSILON; +} + +export function calculatePeptideDose(input: PeptideInput): PeptideResult | null { + const { vialMg, waterMl, doseMcg, syringeCapacity } = input; + + if (!isPositiveFinite(vialMg, MAX_VIAL_MG)) return null; + if (!isPositiveFinite(waterMl, MAX_WATER_ML)) return null; + if (!isPositiveFinite(doseMcg, MAX_DOSE_MCG)) return null; + + const concentrationMgPerMl = vialMg / waterMl; + const volumeMl = doseMcg / 1000 / concentrationMgPerMl; + const units = volumeMl * UNITS_PER_ML; + const dosesPerVial = Math.floor((vialMg * 1000) / doseMcg); + + const warnings: WarningCode[] = []; + + if (units > syringeCapacity) warnings.push("EXCEEDS_SYRINGE"); + if (units < MIN_MEASURABLE_UNITS) warnings.push("TOO_SMALL_TO_MEASURE"); + if (waterMl > TYPICAL_MAX_WATER_ML) warnings.push("WATER_EXCEEDS_VIAL"); + if (!isWholeGraduation(units)) warnings.push("NOT_A_WHOLE_GRADUATION"); + + return { concentrationMgPerMl, volumeMl, units, dosesPerVial, warnings }; +} + +export function calculateDoseFromUnits(input: ReverseInput): number | null { + const { vialMg, waterMl, units } = input; + + if (!isPositiveFinite(vialMg, MAX_VIAL_MG)) return null; + if (!isPositiveFinite(waterMl, MAX_WATER_ML)) return null; + if (!isPositiveFinite(units, MAX_UNITS)) return null; + + const concentrationMgPerMl = vialMg / waterMl; + + return (units / UNITS_PER_ML) * concentrationMgPerMl * 1000; +} diff --git a/app/[locale]/(app)/tools/peptide-calculator/lib/classes.ts b/app/[locale]/(app)/tools/peptide-calculator/lib/classes.ts new file mode 100644 index 00000000..66ecb49a --- /dev/null +++ b/app/[locale]/(app)/tools/peptide-calculator/lib/classes.ts @@ -0,0 +1,18 @@ +/** + * Shared class fragments for the calculator widget. + * + * `MONO` exists because this project's Tailwind config replaces `fontFamily` wholesale and + * ships no `mono` family — `font-mono` compiles to nothing here. Geist Mono is already + * loaded by the root layout as a CSS variable, so an arbitrary family utility is the way to + * reach it without changing the global config (46 other `font-mono` usages across the tools + * pages would start rendering differently the day that key is added). + * + * Same reason for the greys: DaisyUI's `base-*` colours are plain CSS variables, so + * `text-base-content/60` and friends compile to nothing. Muted text therefore comes from the + * Tailwind palette, which the design tokens map onto exactly (`gray-700` is the `#6B7280` + * of the handoff). + */ +export const MONO = "font-[family-name:var(--font-geist-mono)]"; + +/** Secondary copy: hints, captions, units, tile keys. */ +export const MUTED = "text-gray-700 dark:text-gray-600"; diff --git a/app/[locale]/(app)/tools/peptide-calculator/lib/formatNumber.test.ts b/app/[locale]/(app)/tools/peptide-calculator/lib/formatNumber.test.ts new file mode 100644 index 00000000..b1f56fd4 --- /dev/null +++ b/app/[locale]/(app)/tools/peptide-calculator/lib/formatNumber.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; + +import { formatLocaleNumber, roundToPrecision } from "./formatNumber"; + +const NARROW_NO_BREAK_SPACE = " "; +const NO_BREAK_SPACE = " "; + +describe("formatLocaleNumber", () => { + it("utilise le point comme séparateur décimal en anglais", () => { + expect(formatLocaleNumber(12.5, 2, "en")).toBe("12.5"); + }); + + it("utilise le point comme séparateur décimal en chinois simplifié", () => { + expect(formatLocaleNumber(12.5, 2, "zh-CN")).toBe("12.5"); + }); + + it("utilise la virgule comme séparateur décimal en français", () => { + expect(formatLocaleNumber(12.5, 2, "fr")).toBe("12,5"); + }); + + it("utilise la virgule comme séparateur décimal en espagnol", () => { + expect(formatLocaleNumber(12.5, 2, "es")).toBe("12,5"); + }); + + it("utilise la virgule comme séparateur décimal en portugais", () => { + expect(formatLocaleNumber(12.5, 2, "pt")).toBe("12,5"); + }); + + it("utilise la virgule comme séparateur décimal en russe", () => { + expect(formatLocaleNumber(12.5, 2, "ru")).toBe("12,5"); + }); + + it("supprime les zéros de fin superflus plutôt que d'afficher une fausse précision", () => { + expect(formatLocaleNumber(12.5, 2, "en")).toBe("12.5"); + expect(formatLocaleNumber(12.5, 2, "fr")).toBe("12,5"); + }); + + it("préserve les 4 décimales utilisées pour le volume en mL", () => { + expect(formatLocaleNumber(0.125, 4, "en")).toBe("0.125"); + expect(formatLocaleNumber(0.125, 4, "fr")).toBe("0,125"); + }); + + it("préserve les 3 décimales utilisées pour la concentration en mg/mL", () => { + expect(formatLocaleNumber(2.333, 3, "en")).toBe("2.333"); + expect(formatLocaleNumber(2.333, 3, "fr")).toBe("2,333"); + }); + + it("préserve les 2 décimales utilisées pour les unités de seringue", () => { + expect(formatLocaleNumber(60, 2, "en")).toBe("60"); + expect(formatLocaleNumber(1, 2, "fr")).toBe("1"); + }); + + it("arrondit une valeur non exacte au lieu de la tronquer (vial 7 mg + eau 3 mL + dose 250 mcg -> 10.714285... unités)", () => { + // concentration = 7 / 3 mg/mL ; volume = 0.25 / (7/3) mL ; units = volume * 100 = 10.714285714... + const units = (250 / 1000 / (7 / 3)) * 100; + + expect(formatLocaleNumber(units, 2, "en")).toBe("10.71"); + expect(formatLocaleNumber(units, 2, "fr")).toBe("10,71"); + }); + + it("ne produit jamais de séparateur de milliers pour une valeur au-dessus de 999", () => { + const en = formatLocaleNumber(2000, 2, "en"); + const fr = formatLocaleNumber(2000, 2, "fr"); + const ru = formatLocaleNumber(2000, 2, "ru"); + + expect(en).toBe("2000"); + expect(fr).toBe("2000"); + expect(ru).toBe("2000"); + + for (const output of [en, fr, ru]) { + expect(output).not.toContain(NARROW_NO_BREAK_SPACE); + expect(output).not.toContain(NO_BREAK_SPACE); + } + }); +}); + +describe("roundToPrecision", () => { + it("retourne un nombre (pas une chaîne) arrondi et sans zéros de fin", () => { + expect(roundToPrecision(12.5, 2)).toBe(12.5); + expect(roundToPrecision(0.125, 4)).toBe(0.125); + }); + + it("reste indépendant de la locale, puisqu'il n'y a pas de mise en forme", () => { + expect(Number.isFinite(roundToPrecision(2000, 2))).toBe(true); + expect(roundToPrecision(2000, 2)).toBe(2000); + }); + + it("arrondit une valeur non exacte plutôt que de la tronquer (vial 7 mg + eau 3 mL + dose 250 mcg -> 10.714285... unités)", () => { + const units = (250 / 1000 / (7 / 3)) * 100; + + expect(roundToPrecision(units, 2)).toBe(10.71); + }); +}); diff --git a/app/[locale]/(app)/tools/peptide-calculator/lib/formatNumber.ts b/app/[locale]/(app)/tools/peptide-calculator/lib/formatNumber.ts new file mode 100644 index 00000000..a4e9f128 --- /dev/null +++ b/app/[locale]/(app)/tools/peptide-calculator/lib/formatNumber.ts @@ -0,0 +1,55 @@ +import type { Locale } from "locales/types"; + +/** Locales that write the decimal separator as a comma rather than a point. */ +const COMMA_DECIMAL_LOCALES: ReadonlySet = new Set(["fr", "es", "pt", "ru"]); + +/** + * Rounds `value` to at most `decimals` fraction digits, strips trailing zeros + * (so `12.50` renders as `12.5`, never as false precision), and renders the + * result using the decimal separator convention of `locale`. + * + * Deliberately avoids `Intl.NumberFormat`'s locale-aware formatting for the + * integer part: `new Intl.NumberFormat("fr").format(2000)` would inject a + * U+202F narrow no-break space (and `ru` a U+00A0 no-break space) as a + * thousands separator. Those codepoints were audited out of every locale + * content string; grouping is not wanted here either way (a reverse-mode dose + * in mcg can exceed 999, and `2000 mcg` reads better than `2 000 mcg`). + * Formatting via `toFixed`/`toString` and swapping only the decimal + * separator keeps the output free of grouping characters by construction. + */ +export function formatLocaleNumber(value: number, decimals: number, locale: Locale): string { + const rounded = roundToPrecision(value, decimals).toString(); + + return COMMA_DECIMAL_LOCALES.has(locale) ? rounded.replace(".", ",") : rounded; +} + +/** + * Rounds `value` to at most `decimals` fraction digits and returns it as a + * plain `number` (not a locale-formatted string). Use this when the value + * feeds a numeric prop — e.g. the syringe ruler's `units` — rather than + * being rendered as text. + */ +export function roundToPrecision(value: number, decimals: number): number { + return Number(value.toFixed(decimals)); +} + +/** Micrograms below the thousand read as `mcg`; past it, `8 mg` beats `8000 mcg`. */ +export const DOSE_UNIT_SWITCH_MCG = 1000; + +export interface FormattedDose { + value: string; + unit: "mcg" | "mg"; +} + +/** + * Renders a dose in whichever unit keeps it readable, as `{value, unit}` so the + * caller can style the two parts separately (the result headline colours the unit). + */ +export function formatDose(doseMcg: number, locale: Locale): FormattedDose { + const asMg = doseMcg >= DOSE_UNIT_SWITCH_MCG; + + return { + value: formatLocaleNumber(asMg ? doseMcg / 1000 : doseMcg, asMg ? 3 : 0, locale), + unit: asMg ? "mg" : "mcg", + }; +} diff --git a/app/[locale]/(app)/tools/peptide-calculator/lib/labels.ts b/app/[locale]/(app)/tools/peptide-calculator/lib/labels.ts new file mode 100644 index 00000000..434c97e8 --- /dev/null +++ b/app/[locale]/(app)/tools/peptide-calculator/lib/labels.ts @@ -0,0 +1,23 @@ +import { WarningCode } from "./types"; + +/** + * Every string the result block needs, resolved once by the client component. + * + * Passing one object rather than a dozen props keeps the three result surfaces (desktop + * panel, mobile sheet, sticky bar) interchangeable: they render the same readout, so they + * take the same labels. + */ +export interface ResultLabels { + /** Eyebrow above the headline: what the big number answers. */ + eyebrowForward: string; + eyebrowReverse: string; + units: string; + concentration: string; + volumePerDose: string; + dosesPerVial: string; + invalid: string; + vialCaption: string; + /** Overflow alert, with `{units}`, `{syringe}` and `{capacity}` placeholders already filled. */ + overflow: string; + warnings: Record; +} diff --git a/app/[locale]/(app)/tools/peptide-calculator/lib/motion.ts b/app/[locale]/(app)/tools/peptide-calculator/lib/motion.ts new file mode 100644 index 00000000..dcbca111 --- /dev/null +++ b/app/[locale]/(app)/tools/peptide-calculator/lib/motion.ts @@ -0,0 +1,31 @@ +import type { Transition } from "framer-motion"; + +/** + * Motion vocabulary for the peptide calculator widget. + * + * Springs, not fixed-duration keyframes, for anything a user can touch: + * framer-motion's `bounce` + `duration` map onto Apple's damping + response. + * `bounce: 0` is critically damped — it settles without overshoot, which is + * what a value readout wants. Nothing on this page is a flick or a drag, so + * no gesture here carries momentum and no animation is given bounce. + */ +export const SPRING: Transition = { type: "spring", bounce: 0, duration: 0.35 }; + +/** + * Same spring, longer response. Reserved for the vial liquid level, which is a + * large slow-moving mass rather than a control responding to a press. + */ +export const SPRING_SLOW: Transition = { type: "spring", bounce: 0, duration: 0.55 }; + +/** Press feedback: fires on pointer-down, ~100 ms, deliberately not a spring. */ +export const PRESS: Transition = { duration: 0.1, ease: "easeOut" }; +export const PRESS_SCALE = 0.97; + +/** `prefers-reduced-motion` fallback: a short opacity cross-fade, no transform, no spring. */ +export const FADE: Transition = { duration: 0.15, ease: "linear" }; + +/** Instant, for values that must still be positioned correctly under reduced motion. */ +export const INSTANT: Transition = { duration: 0 }; + +/** Seconds for one full loop of the ambient vial label marquee. Slow on purpose. */ +export const MARQUEE_SECONDS = 24; diff --git a/app/[locale]/(app)/tools/peptide-calculator/lib/presets.test.ts b/app/[locale]/(app)/tools/peptide-calculator/lib/presets.test.ts new file mode 100644 index 00000000..95f8e1d5 --- /dev/null +++ b/app/[locale]/(app)/tools/peptide-calculator/lib/presets.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; + +import { SYRINGE_OPTIONS } from "./presets"; + +describe("SYRINGE_OPTIONS", () => { + it("expose trois seringues U-100 standard", () => { + expect(SYRINGE_OPTIONS.map((option) => option.capacity)).toEqual([30, 50, 100]); + }); + + it("associe chaque capacité au volume qu'elle représente sur une échelle U-100", () => { + // 1 mL = 100 unités, donc la capacité en unités vaut cent fois le volume en mL. + for (const option of SYRINGE_OPTIONS) { + expect(option.volumeMl * 100).toBeCloseTo(option.capacity, 10); + } + }); + + it("pointe chaque seringue vers son propre visuel", () => { + const images = SYRINGE_OPTIONS.map((option) => option.image); + + expect(new Set(images).size).toBe(images.length); + + for (const option of SYRINGE_OPTIONS) { + expect(option.image).toBe(`/images/syringes/syringe-${option.capacity}-units.png`); + } + }); +}); diff --git a/app/[locale]/(app)/tools/peptide-calculator/lib/presets.ts b/app/[locale]/(app)/tools/peptide-calculator/lib/presets.ts new file mode 100644 index 00000000..1aa255ee --- /dev/null +++ b/app/[locale]/(app)/tools/peptide-calculator/lib/presets.ts @@ -0,0 +1,34 @@ +import { SyringeCapacity } from "./types"; + +export interface SyringeOption { + capacity: SyringeCapacity; + volumeMl: number; + image: string; +} + +export const SYRINGE_OPTIONS: SyringeOption[] = [ + { capacity: 30, volumeMl: 0.3, image: "/images/syringes/syringe-30-units.png" }, + { capacity: 50, volumeMl: 0.5, image: "/images/syringes/syringe-50-units.png" }, + { capacity: 100, volumeMl: 1, image: "/images/syringes/syringe-100-units.png" }, +]; + +export const VIAL_MG_OPTIONS = [5, 10, 20, 50, 100]; +export const WATER_ML_OPTIONS = [1, 2, 3, 5, 10]; + +/** Dose presets, one list per display unit. The stored dose stays in micrograms either way. */ +export const DOSE_MCG_OPTIONS = [50, 100, 250, 500, 750]; +export const DOSE_MG_OPTIONS = [1, 2, 2.5, 5, 8]; + +/** Step of the free-entry field, per quantity: small enough to be useful, coarse enough to be quick. */ +export const STEP_VIAL_MG = 1; +export const STEP_WATER_ML = 0.5; +export const STEP_DOSE_MCG = 10; +export const STEP_DOSE_MG = 0.1; +export const STEP_UNITS = 0.5; + +export const DEFAULT_INPUT = { + vialMg: 10, + waterMl: 5, + doseMcg: 250, + syringeCapacity: 50 as SyringeCapacity, +}; diff --git a/app/[locale]/(app)/tools/peptide-calculator/lib/readout.ts b/app/[locale]/(app)/tools/peptide-calculator/lib/readout.ts new file mode 100644 index 00000000..754e95b3 --- /dev/null +++ b/app/[locale]/(app)/tools/peptide-calculator/lib/readout.ts @@ -0,0 +1,54 @@ +import { PeptideInput, WarningCode } from "./types"; +import { calculateDoseFromUnits, calculatePeptideDose, UNITS_PER_ML } from "./calculate"; + +/** Absorbs the float noise of `units * 100 / 100` so an exact 50 on a 50-unit syringe is not "over". */ +const EPSILON = 1e-3; + +export type CalculatorMode = "forward" | "reverse"; + +/** + * Everything the result block shows, whichever direction the calculation ran in. + * + * Both modes describe the same vial, so they collapse into one shape here rather than into + * two result components: only the headline differs downstream (units drawn vs dose obtained). + */ +export interface Readout { + concentrationMgPerMl: number; + doseMcg: number; + volumeMl: number; + units: number; + dosesPerVial: number; + /** The draw does not fit the selected syringe. Never blocks input — it colours the answer. */ + over: boolean; + warnings: WarningCode[]; +} + +export function buildReadout(mode: CalculatorMode, input: PeptideInput, drawnUnits: number): Readout | null { + if (mode === "forward") { + const result = calculatePeptideDose(input); + if (result === null) return null; + + return { + concentrationMgPerMl: result.concentrationMgPerMl, + doseMcg: input.doseMcg, + volumeMl: result.volumeMl, + units: result.units, + dosesPerVial: result.dosesPerVial, + over: result.units > input.syringeCapacity + EPSILON, + warnings: result.warnings, + }; + } + + const doseMcg = calculateDoseFromUnits({ vialMg: input.vialMg, waterMl: input.waterMl, units: drawnUnits }); + if (doseMcg === null) return null; + + return { + concentrationMgPerMl: input.vialMg / input.waterMl, + doseMcg, + volumeMl: drawnUnits / UNITS_PER_ML, + units: drawnUnits, + dosesPerVial: Math.floor((input.vialMg * 1000) / doseMcg), + over: drawnUnits > input.syringeCapacity + EPSILON, + warnings: [], + }; +} diff --git a/app/[locale]/(app)/tools/peptide-calculator/lib/types.ts b/app/[locale]/(app)/tools/peptide-calculator/lib/types.ts new file mode 100644 index 00000000..a51a29cd --- /dev/null +++ b/app/[locale]/(app)/tools/peptide-calculator/lib/types.ts @@ -0,0 +1,24 @@ +export type SyringeCapacity = 30 | 50 | 100; + +export type WarningCode = "EXCEEDS_SYRINGE" | "TOO_SMALL_TO_MEASURE" | "WATER_EXCEEDS_VIAL" | "NOT_A_WHOLE_GRADUATION"; + +export interface PeptideInput { + vialMg: number; + waterMl: number; + doseMcg: number; + syringeCapacity: SyringeCapacity; +} + +export interface ReverseInput { + vialMg: number; + waterMl: number; + units: number; +} + +export interface PeptideResult { + concentrationMgPerMl: number; + volumeMl: number; + units: number; + dosesPerVial: number; + warnings: WarningCode[]; +} diff --git a/app/[locale]/(app)/tools/peptide-calculator/page.tsx b/app/[locale]/(app)/tools/peptide-calculator/page.tsx new file mode 100644 index 00000000..ebbb755b --- /dev/null +++ b/app/[locale]/(app)/tools/peptide-calculator/page.tsx @@ -0,0 +1,130 @@ +import { Metadata } from "next"; +import { Locale } from "locales/types"; +import { getI18n } from "locales/server"; + +import { PeptideCalculatorClient } from "./ui/PeptideCalculatorClient"; +import { SEOContentServer } from "./ui/components/SEOContentServer"; +import { PEPTIDE_CALCULATOR_CONTENT, PEPTIDE_CALCULATOR_CONTENT_FALLBACK } from "./seo/page-content"; +import { PEPTIDE_CALCULATOR_SEO } from "./seo/config"; +import { DEFAULT_INPUT } from "./lib/presets"; + +import { getServerUrl } from "@/shared/lib/server-url"; +import { env } from "@/env"; +import { RelatedTools } from "@/components/tools/RelatedTools"; +import { generateSEOMetadata, SEOScripts } from "@/components/seo/SEOHead"; +import { HorizontalBottomBanner, HorizontalTopBanner } from "@/components/ads"; + +const PATH = "/tools/peptide-calculator"; + +export async function generateMetadata({ params }: { params: Promise<{ locale: Locale }> }): Promise { + const { locale } = await params; + const seo = PEPTIDE_CALCULATOR_SEO[locale] || PEPTIDE_CALCULATOR_SEO.en; + + return generateSEOMetadata({ + title: seo.title, + description: seo.description, + keywords: seo.keywords, + locale, + canonical: `${getServerUrl()}/${locale}${PATH}`, + }); +} + +export default async function PeptideCalculatorPage({ params }: { params: Promise<{ locale: Locale }> }) { + const { locale } = await params; + const t = await getI18n(); + const seo = PEPTIDE_CALCULATOR_SEO[locale] || PEPTIDE_CALCULATOR_SEO.en; + const content = PEPTIDE_CALCULATOR_CONTENT[locale] ?? PEPTIDE_CALCULATOR_CONTENT_FALLBACK; + const url = `${getServerUrl()}/${locale}${PATH}`; + + return ( + <> + + +