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 (
+ <>
+
+
+