From 6db371c6e9a7764df763b48c50d772ad075fc213 Mon Sep 17 00:00:00 2001 From: Lisa <238136103+srxly888-creator@users.noreply.github.com> Date: Thu, 9 Jul 2026 19:02:59 +0800 Subject: [PATCH] Initial commit --- .cursorrules | 165 + .env.example | 77 + .github/FUNDING.yml | 3 + .github/ISSUE_TEMPLATE/bug_report.md | 35 + .github/ISSUE_TEMPLATE/config.yml | 5 + .github/ISSUE_TEMPLATE/feature_request.md | 16 + .github/pull_request_template.md | 22 + .github/workflows/ci.yml | 148 + .github/workflows/notify-discord-issues.yml | 157 + .github/workflows/notify-discord-pr.yml | 220 + .github/workflows/notify-discord.yml | 138 + .github/workflows/publish-ghcr-image.yml | 55 + .gitignore | 73 + .npmrc | 2 + .prettierrc | 6 + .vscode/settings.json | 7 + AGENTS.md | 46 + CLAUDE.md | 60 + CONTRIBUTING.md | 38 + Dockerfile | 40 + LICENSE | 15 + Makefile | 36 + NOTICE | 71 + README.md | 437 + .../(admin)/admin/[...catchAll]/not-found.tsx | 5 + .../(admin)/admin/[...catchAll]/page.tsx | 5 + app/[locale]/(admin)/admin/dashboard/page.tsx | 225 + app/[locale]/(admin)/admin/layout.tsx | 39 + app/[locale]/(admin)/admin/not-found.tsx | 5 + .../(admin)/admin/programs/[id]/edit/page.tsx | 20 + app/[locale]/(admin)/admin/programs/page.tsx | 22 + app/[locale]/(admin)/admin/settings/page.tsx | 10 + app/[locale]/(admin)/admin/users/page.tsx | 55 + .../(app)/(legal-and-payment)/layout.tsx | 19 + .../legal/privacy/page.tsx | 30 + .../legal/sales-terms/page.tsx | 33 + .../(legal-and-payment)/legal/terms/page.tsx | 33 + app/[locale]/(app)/[slug]/layout.tsx | 15 + app/[locale]/(app)/about/page.tsx | 18 + app/[locale]/(app)/analytics/page.tsx | 28 + .../(auth-layout)/forgot-password/page.tsx | 5 + .../(app)/auth/(auth-layout)/layout.tsx | 40 + .../(auth-layout)/reset-password/page.tsx | 5 + .../(app)/auth/(auth-layout)/signin/page.tsx | 5 + .../(app)/auth/(auth-layout)/signup/page.tsx | 35 + app/[locale]/(app)/auth/error.tsx | 27 + app/[locale]/(app)/auth/error/page.tsx | 24 + app/[locale]/(app)/auth/layout.tsx | 3 + app/[locale]/(app)/auth/signout/page.tsx | 3 + .../(app)/auth/verify-email/layout.tsx | 21 + app/[locale]/(app)/auth/verify-email/page.tsx | 7 + .../(app)/auth/verify-request/page.tsx | 33 + app/[locale]/(app)/layout.tsx | 23 + app/[locale]/(app)/leaderboard/page.tsx | 34 + app/[locale]/(app)/onboarding/layout.tsx | 7 + app/[locale]/(app)/onboarding/page.tsx | 7 + app/[locale]/(app)/page.tsx | 38 + app/[locale]/(app)/plan/page.tsx | 12 + app/[locale]/(app)/premium/page.tsx | 34 + app/[locale]/(app)/profile/page.tsx | 67 + app/[locale]/(app)/programs/[slug]/page.tsx | 106 + .../[sessionSlug]/ProgramSessionClient.tsx | 290 + .../[slug]/session/[sessionSlug]/page.tsx | 158 + app/[locale]/(app)/programs/page.tsx | 34 + .../(app)/sponsor/onboarding/page.tsx | 33 + app/[locale]/(app)/statistics/page.tsx | 49 + .../bmi-calculator/bmi-calculator.utils.ts | 304 + .../(app)/tools/bmi-calculator/page.tsx | 128 + .../shared/BmiCalculatorClient.tsx | 81 + .../components/BmiEducationalContent.tsx | 407 + .../shared/components/BmiHeightInput.tsx | 90 + .../shared/components/BmiResultsDisplay.tsx | 279 + .../shared/components/BmiUnitSelector.tsx | 45 + .../shared/components/BmiWeightInput.tsx | 42 + .../shared/components/MathEquation.tsx | 72 + .../CalorieCalculatorHub.tsx | 247 + .../CalorieCalculatorComparison.tsx | 274 + .../calorie-calculator-comparison/page.tsx | 120 + .../calorie-calculator.utils.ts | 122 + .../cunningham-calculator/page.tsx | 125 + .../harris-benedict-calculator/page.tsx | 125 + .../katch-mcardle-calculator/page.tsx | 125 + .../mifflin-st-jeor-calculator/page.tsx | 125 + .../oxford-calculator/page.tsx | 123 + .../(app)/tools/calorie-calculator/page.tsx | 78 + .../shared/CalorieCalculatorClient.tsx | 129 + .../shared/calculator-configs.ts | 55 + .../shared/calorie-formulas.utils.ts | 165 + .../components/ActivityLevelSelector.tsx | 90 + .../shared/components/AgeInput.tsx | 55 + .../shared/components/BodyFatInput.tsx | 57 + .../shared/components/FAQSection.tsx | 63 + .../shared/components/GenderSelector.tsx | 81 + .../shared/components/GoalSelector.tsx | 90 + .../shared/components/HeightInput.tsx | 90 + .../shared/components/InfoButton.tsx | 32 + .../shared/components/InfoModal.tsx | 57 + .../shared/components/ResultsDisplay.tsx | 171 + .../shared/components/UnitSelector.tsx | 81 + .../shared/components/WeightInput.tsx | 42 + .../shared/components/index.ts | 12 + .../calorie-calculator/shared/types/index.ts | 13 + .../(app)/tools/calorie-calculator/styles.css | 123 + .../(app)/tools/heart-rate-zones/lib/utils.ts | 75 + .../(app)/tools/heart-rate-zones/page.tsx | 213 + .../tools/heart-rate-zones/seo/config.ts | 133 + .../heart-rate-zones/seo/page-content.ts | 31 + .../ui/HeartRateZonesCalculatorClient.tsx | 265 + .../ui/components/EducationalContent.tsx | 237 + .../components/EducationalContentServer.tsx | 229 + .../ui/components/FAQAccordion.tsx | 52 + .../components/SEOOptimizedContentServer.tsx | 369 + .../ui/components/ScrollToTopButton.tsx | 22 + .../tools/heart-rate-zones/ui/styles.css | 121 + app/[locale]/(app)/tools/page.tsx | 248 + app/[locale]/@modal/(.)auth/login/page.tsx | 12 + app/[locale]/@modal/default.tsx | 3 + app/[locale]/layout.tsx | 374 + app/[locale]/manifest.json/route.ts | 58 + app/[locale]/not-found.tsx | 5 + app/[locale]/providers.tsx | 44 + app/ads.txt/route.ts | 44 + app/api/analytics/premium/route.ts | 79 + app/api/auth/[...all]/route.ts | 5 + app/api/auth/signup/route.ts | 63 + app/api/billing/status/route.ts | 58 + .../statistics/one-rep-max/route.ts | 182 + .../[exerciseId]/statistics/route.ts | 92 + .../[exerciseId]/statistics/volume/route.ts | 199 + .../statistics/weight-progression/route.ts | 171 + app/api/exercises/all/route.ts | 156 + app/api/exercises/route.ts | 36 + app/api/exercises/shuffle/route.ts | 40 + app/api/premium/billing-portal/route.ts | 35 + app/api/premium/checkout/route.ts | 37 + app/api/premium/plans/route.ts | 250 + app/api/premium/status/route.ts | 90 + app/api/programs/[slug]/enroll/route.ts | 105 + app/api/programs/[slug]/progress/route.ts | 72 + app/api/programs/[slug]/route.ts | 19 + .../[slug]/sessions/[sessionSlug]/route.ts | 22 + app/api/programs/route.ts | 13 + .../[progressId]/complete/route.ts | 98 + .../programs/session-progress/start/route.ts | 89 + app/api/revenuecat/link-user/route.ts | 40 + app/api/revenuecat/sync-status/route.ts | 159 + app/api/revenuecat/webhook/route.ts | 242 + app/api/user/password/route.ts | 46 + app/api/user/preferences/route.ts | 137 + app/api/user/profile/route.ts | 82 + app/api/webhooks/revenuecat/route.ts | 341 + app/api/webhooks/stripe/route.ts | 32 + .../[sessionId]/feedback/route.ts | 65 + .../[sessionId]/rating/route.ts | 66 + app/api/workout-sessions/[sessionId]/route.ts | 38 + .../[sessionId]/summary/route.ts | 123 + app/api/workout-sessions/sync/route.ts | 41 + .../workout-sessions/user/[userId]/route.ts | 76 + app/robots.txt | 34 + app/sitemap.ts | 222 + components.json | 21 + content/about/en.mdx | 48 + content/about/es.mdx | 48 + content/about/fr.mdx | 48 + content/about/pt.mdx | 48 + content/about/ru.mdx | 48 + content/about/zh-CN.mdx | 48 + content/privacy-policy/en.mdx | 90 + content/privacy-policy/es.mdx | 90 + content/privacy-policy/fr.mdx | 90 + content/privacy-policy/pt.mdx | 90 + content/privacy-policy/ru.mdx | 90 + content/privacy-policy/zh-CN.mdx | 90 + content/sales-terms/en.mdx | 93 + content/sales-terms/es.mdx | 93 + content/sales-terms/fr.mdx | 94 + content/sales-terms/pt.mdx | 93 + content/sales-terms/ru.mdx | 93 + content/sales-terms/zh-CN.mdx | 93 + content/terms/en.mdx | 119 + content/terms/es.mdx | 119 + content/terms/fr.mdx | 113 + content/terms/pt.mdx | 119 + content/terms/ru.mdx | 119 + content/terms/zh-CN.mdx | 119 + data/sample-exercises.csv | 20 + docker-compose.yml | 29 + docs/SELF-HOSTING.md | 335 + docs/WORKOUT-ANALYTICS-DEPLOY.md | 611 + emails/ContactSupportEmail.tsx | 37 + emails/DeleteAccountEmail.tsx | 28 + emails/ResetPasswordEmail.tsx | 56 + emails/SponsorOnboardingEmail.tsx | 61 + emails/VerifyEmail.tsx | 51 + emails/utils/BaseEmailLayout.tsx | 82 + eslint.config.mjs | 170 + locales/client.ts | 45 + locales/en.ts | 2164 ++++ locales/es.ts | 2154 ++++ locales/fr.ts | 2185 ++++ locales/heart-rate-zones-translations.ts | 112 + locales/pt.ts | 2154 ++++ locales/ru.ts | 2147 ++++ locales/server.ts | 10 + locales/types.ts | 2 + locales/zh-CN.ts | 2098 +++ next.config.ts | 39 + nextauth.d.ts | 13 + package.json | 154 + pnpm-lock.yaml | 10566 ++++++++++++++++ pnpm-workspace.yaml | 7 + postcss.config.mjs | 8 + prisma/migrations/0_init/migration.sql | 644 + .../migrations/1_add_split_day/migration.sql | 3 + .../2_user_training_plan/migration.sql | 23 + .../migration.sql | 5 + .../20250101000000_baseline/migration.sql | 644 + .../migration.sql | 15 + .../20250414120436_init/migration.sql | 69 + .../migration.sql | 15 + .../migration.sql | 27 + .../migration.sql | 3 + .../20250416160303_add_plans/migration.sql | 49 + .../20250416160502_map/migration.sql | 75 + .../migration.sql | 11 + .../migration.sql | 19 + .../migration.sql | 121 + .../migration.sql | 28 + .../migration.sql | 47 + .../migration.sql | 39 + .../migration.sql | 69 + .../migration.sql | 52 + .../migration.sql | 5 + .../migration.sql | 79 + .../migration.sql | 12 + .../migration.sql | 2 + .../migration.sql | 11 + .../migration.sql | 144 + .../migration.sql | 11 + .../migration.sql | 28 + .../20250625155932_add_admin/migration.sql | 205 + .../migration.sql | 5 + .../migration.sql | 74 + .../migration.sql | 8 + .../migration.sql | 60 + .../migration.sql | 14 + .../migration.sql | 10 + .../migration.sql | 2 + .../migration.sql | 18 + .../migration.sql | 3 + .../migration.sql | 30 + prisma/migrations_backup/migration_lock.toml | 3 + prisma/schema.prisma | 761 ++ proxy.ts | 107 + public/_ads.txt | 1 + public/android-chrome-192x192.png | Bin 0 -> 15355 bytes public/android-chrome-512x512.png | Bin 0 -> 48686 bytes public/apple-touch-icon.png | Bin 0 -> 13641 bytes public/favicon.ico | Bin 0 -> 15406 bytes public/images/calculator-og.jpg | Bin 0 -> 65080 bytes public/images/coachs/mathias.png | Bin 0 -> 115232 bytes public/images/coachs/mathias2.jpg | Bin 0 -> 68488 bytes public/images/countries.png | Bin 0 -> 179382 bytes public/images/default-og-image_en.jpg | Bin 0 -> 79850 bytes public/images/default-og-image_es.jpg | Bin 0 -> 95002 bytes public/images/default-og-image_fr.jpg | Bin 0 -> 84328 bytes public/images/default-og-image_pt.jpg | Bin 0 -> 96880 bytes public/images/default-og-image_ru.jpg | Bin 0 -> 102372 bytes public/images/default-og-image_zh.jpg | Bin 0 -> 77679 bytes public/images/default-workout.jpg | Bin 0 -> 57427 bytes public/images/edwardsoyfit.png | Bin 0 -> 2523862 bytes public/images/emojis/WorkoutCoolBiceps.png | Bin 0 -> 48494 bytes public/images/emojis/WorkoutCoolChief.png | Bin 0 -> 50828 bytes public/images/emojis/WorkoutCoolCry.png | Bin 0 -> 41798 bytes .../images/emojis/WorkoutCoolDisapointed.png | Bin 0 -> 36871 bytes .../images/emojis/WorkoutCoolEmbarassed.png | Bin 0 -> 51024 bytes public/images/emojis/WorkoutCoolExhausted.png | Bin 0 -> 44119 bytes public/images/emojis/WorkoutCoolHappy.png | Bin 0 -> 35436 bytes public/images/emojis/WorkoutCoolHuuuu.png | Bin 0 -> 41015 bytes public/images/emojis/WorkoutCoolKittyGirl.png | Bin 0 -> 39051 bytes public/images/emojis/WorkoutCoolLoL.png | Bin 0 -> 66535 bytes public/images/emojis/WorkoutCoolLove.png | Bin 0 -> 35347 bytes public/images/emojis/WorkoutCoolMedical.png | Bin 0 -> 53857 bytes public/images/emojis/WorkoutCoolMeeeh.png | Bin 0 -> 38231 bytes public/images/emojis/WorkoutCoolPolice.png | Bin 0 -> 59996 bytes public/images/emojis/WorkoutCoolRich.png | Bin 0 -> 88510 bytes public/images/emojis/WorkoutCoolShoked.png | Bin 0 -> 52514 bytes public/images/emojis/WorkoutCoolShoked2.png | Bin 0 -> 49226 bytes public/images/emojis/WorkoutCoolSwag.png | Bin 0 -> 73467 bytes public/images/emojis/WorkoutCoolTeeths.png | Bin 0 -> 39456 bytes public/images/emojis/WorkoutCoolTong.png | Bin 0 -> 39920 bytes public/images/emojis/WorkoutCoolWooow.png | Bin 0 -> 51388 bytes public/images/emojis/WorkoutCoolYeahOk.png | Bin 0 -> 47085 bytes public/images/emojis/WorkoutCoolhaphap.png | Bin 0 -> 35210 bytes public/images/equipment/band.png | Bin 0 -> 6723 bytes public/images/equipment/barbell.png | Bin 0 -> 6398 bytes public/images/equipment/bench.png | Bin 0 -> 9557 bytes public/images/equipment/bodyweight.png | Bin 0 -> 8519 bytes public/images/equipment/dumbbell.png | Bin 0 -> 3112 bytes public/images/equipment/kettlebell.png | Bin 0 -> 8665 bytes public/images/equipment/plate.png | Bin 0 -> 12480 bytes public/images/equipment/pull-up-bar.png | Bin 0 -> 2550 bytes public/images/equipment/swiss-ball.png | Bin 0 -> 93498 bytes public/images/favicon-16x16.png | Bin 0 -> 760 bytes public/images/favicon-32x32.png | Bin 0 -> 1786 bytes public/images/favicon.ico | Bin 0 -> 15406 bytes public/images/nutripure-gellules-2.png | Bin 0 -> 444557 bytes public/images/nutripure-gellules.png | Bin 0 -> 499055 bytes public/images/nutripure-logo.webp | Bin 0 -> 15882 bytes public/images/old-nutripure-gellules.png | Bin 0 -> 567880 bytes public/images/placeholders/coach-avatar.png | Bin 0 -> 115659 bytes public/images/placeholders/no-image.jpg | Bin 0 -> 19969 bytes .../screenshots/heart-rate-zones/en.jpg | Bin 0 -> 66021 bytes .../screenshots/heart-rate-zones/es.jpg | Bin 0 -> 69737 bytes .../screenshots/heart-rate-zones/fr.jpg | Bin 0 -> 69335 bytes .../screenshots/heart-rate-zones/og.jpg | Bin 0 -> 74593 bytes .../screenshots/heart-rate-zones/pt.jpg | Bin 0 -> 69302 bytes .../screenshots/heart-rate-zones/ru.jpg | Bin 0 -> 66975 bytes .../screenshots/heart-rate-zones/zh-CN.jpg | Bin 0 -> 62869 bytes public/images/sponsorship/fd-with-padding.png | Bin 0 -> 11652 bytes public/images/sponsorship/nutri-and-co.png | Bin 0 -> 15043 bytes public/images/sponsorship/nutripure.png | Bin 0 -> 19369 bytes public/images/trophy.png | Bin 0 -> 38864 bytes public/logo.png | Bin 0 -> 21531 bytes public/manifest.json | 43 + public/sw.js | 59 + scripts/check-pricing-config.ts | 105 + scripts/convert-exercisedb-dataset.ts | 391 + scripts/dev-safe.sh | 37 + ...import-exercises-with-attributes.prompt.md | 27 + scripts/import-exercises-with-attributes.ts | 248 + scripts/mem-watchdog.sh | 48 + scripts/seed-demo-user.ts | 88 + scripts/seed-leaderboard-data.ts | 161 + scripts/seed-multi-region-plans.ts | 192 + scripts/seed-subscription-plans-simple.ts | 137 + scripts/seed-workout-data-advanced.ts | 227 + scripts/setup.sh | 26 + src/components/ads/AdBlockerForPremium.tsx | 63 + src/components/ads/AdPlaceholder.tsx | 18 + src/components/ads/AdSenseAutoAds.tsx | 58 + src/components/ads/AdWrapper.tsx | 43 + src/components/ads/EzoicAd.tsx | 49 + src/components/ads/GoogleAdSense.tsx | 115 + src/components/ads/HorizontalAdBanner.tsx | 120 + src/components/ads/HorizontalBottomBanner.tsx | 12 + src/components/ads/HorizontalTopBanner.tsx | 10 + src/components/ads/InArticle.tsx | 58 + src/components/ads/ResponsiveAdBanner.tsx | 75 + src/components/ads/VerticalAdBanner.tsx | 48 + src/components/ads/VerticalLeftBanner.tsx | 39 + src/components/ads/VerticalRightBanner.tsx | 39 + src/components/ads/custom/RewardedAdGate.tsx | 113 + src/components/ads/custom/index.ts | 5 + src/components/ads/custom/sponsor-card.tsx | 103 + src/components/ads/custom/sponsor-config.ts | 90 + src/components/ads/custom/sponsor-dialog.tsx | 241 + .../ads/custom/sponsor-horizontal-banner.tsx | 33 + src/components/ads/custom/sponsor-sidebar.tsx | 53 + src/components/ads/index.ts | 9 + .../ads/nutripure-affiliate-banner.tsx | 571 + src/components/lan-access-qr.tsx | 121 + src/components/premium/RemoveAdsText.tsx | 25 + .../pwa/ServiceWorkerRegistration.tsx | 22 + src/components/seo/SEOHead.tsx | 221 + src/components/seo/breadcrumbs.tsx | 62 + src/components/seo/duration-badge.tsx | 49 + src/components/seo/rich-snippet-rating.tsx | 55 + src/components/seo/session-rich-snippets.tsx | 49 + src/components/svg/BrokenLink.tsx | 69 + src/components/svg/Calendly.tsx | 14 + src/components/svg/CircleSvg.tsx | 20 + src/components/svg/DiscordSvg.tsx | 5 + src/components/svg/DotPattern.tsx | 21 + src/components/svg/GoogleSvg.tsx | 27 + src/components/svg/IconCheckboxCheck.tsx | 14 + src/components/svg/LogoSvg.tsx | 21 + src/components/svg/UnderlineSvg.tsx | 14 + src/components/svg/VerifiedBadge.tsx | 9 + src/components/svg/Youtube.tsx | 13 + src/components/ui/404-page-not-found.tsx | 36 + src/components/ui/Bento.tsx | 37 + src/components/ui/ToastSonner.tsx | 33 + src/components/ui/accordion.tsx | 62 + src/components/ui/alert-dialog.tsx | 98 + src/components/ui/alert.tsx | 64 + .../ui/animated-button/ShinyButton.tsx | 47 + src/components/ui/aspect-ratio.tsx | 7 + src/components/ui/avatar.tsx | 35 + src/components/ui/badge.tsx | 49 + src/components/ui/bottom-sheet-vaul.tsx | 96 + src/components/ui/bottom-sheet.tsx | 163 + src/components/ui/button.tsx | 50 + src/components/ui/card-styled.tsx | 229 + src/components/ui/card.tsx | 35 + src/components/ui/collapsible.tsx | 11 + src/components/ui/dialog-stack.tsx | 366 + src/components/ui/dialog.tsx | 93 + src/components/ui/divider.tsx | 15 + src/components/ui/donation-alert.tsx | 46 + src/components/ui/dropdown-menu.tsx | 177 + src/components/ui/form.tsx | 154 + src/components/ui/hover-card.tsx | 29 + src/components/ui/input-password-strength.tsx | 102 + src/components/ui/input.tsx | 93 + src/components/ui/iphone-mockup.tsx | 44 + src/components/ui/label.tsx | 15 + src/components/ui/link.tsx | 36 + src/components/ui/loader.tsx | 9 + src/components/ui/local-alert.tsx | 32 + src/components/ui/moving-border.tsx | 111 + src/components/ui/navigation-menu.tsx | 120 + src/components/ui/next-top-loader.tsx | 171 + src/components/ui/pagination.tsx | 85 + src/components/ui/phone-frame-preview.tsx | 28 + src/components/ui/popover.tsx | 31 + src/components/ui/premium-gate.tsx | 155 + src/components/ui/premium-upsell-alert.tsx | 67 + src/components/ui/radio-group.tsx | 110 + src/components/ui/scroll-area.tsx | 47 + src/components/ui/select.tsx | 142 + src/components/ui/separator.tsx | 22 + src/components/ui/sheet.tsx | 95 + src/components/ui/shine-border.tsx | 61 + src/components/ui/simple-select.tsx | 65 + src/components/ui/skeleton.tsx | 23 + src/components/ui/slider.tsx | 92 + src/components/ui/sonner.tsx | 32 + src/components/ui/star-button.tsx | 24 + src/components/ui/switch.tsx | 102 + src/components/ui/table.tsx | 79 + src/components/ui/tabs.tsx | 47 + src/components/ui/textarea.tsx | 21 + src/components/ui/theme-provider.tsx | 20 + src/components/ui/timer.tsx | 47 + src/components/ui/title-with-dot.tsx | 10 + src/components/ui/toast.tsx | 171 + src/components/ui/toaster.tsx | 34 + src/components/ui/tooltip.tsx | 62 + src/components/ui/typography.tsx | 69 + src/components/ui/use-toast.ts | 193 + src/components/ui/workout-lol.tsx | 41 + src/components/utils/ErrorBoundaries.tsx | 76 + src/components/utils/TailwindIndicator.tsx | 14 + src/components/version.tsx | 10 + src/entities/exercise/shared/exercise-type.ts | 19 + src/entities/exercise/shared/muscles.tsx | 27 + src/entities/exercise/types/exercise.types.ts | 43 + .../types/program-session.types.ts | 38 + src/entities/program/types/program.types.ts | 99 + src/entities/user/lib/display-name.ts | 26 + .../user/model/get-server-session-user.ts | 29 + src/entities/user/model/get-users.actions.ts | 105 + src/entities/user/model/update-user-locale.ts | 34 + src/entities/user/model/update-user.action.ts | 39 + src/entities/user/model/use-auto-locale.ts | 34 + src/entities/user/model/use-body-weight.ts | 22 + src/entities/user/model/useCurrentSession.ts | 12 + src/entities/user/model/useCurrentUser.ts | 12 + src/entities/user/schemas/get-user.schema.ts | 7 + .../user/schemas/update-user.schema.ts | 11 + src/entities/user/types/session-user.ts | 9 + src/env.ts | 206 + src/features/admin/layout/admin-header.tsx | 79 + .../layout/admin-sidebar/ui/admin-header.tsx | 79 + .../layout/admin-sidebar/ui/admin-sidebar.tsx | 91 + .../programs/actions/add-exercise.action.ts | 118 + .../programs/actions/add-session.action.ts | 91 + .../admin/programs/actions/add-week.action.ts | 73 + .../programs/actions/create-program.action.ts | 117 + .../programs/actions/delete-program.action.ts | 45 + .../programs/actions/get-programs.action.ts | 113 + .../actions/update-exercise-sets.action.ts | 63 + .../update-program-visibility.action.ts | 52 + .../programs/actions/update-program.action.ts | 146 + .../programs/actions/update-session.action.ts | 118 + .../programs/actions/update-week.action.ts | 63 + .../admin/programs/types/program.types.ts | 76 + .../admin/programs/ui/add-exercise-modal.tsx | 445 + .../admin/programs/ui/add-session-modal.tsx | 303 + .../admin/programs/ui/add-week-modal.tsx | 325 + .../programs/ui/create-program-button.tsx | 27 + .../admin/programs/ui/create-program-form.tsx | 500 + .../programs/ui/create-program-modal.tsx | 112 + .../programs/ui/delete-program-button.tsx | 80 + .../admin/programs/ui/edit-program-modal.tsx | 546 + .../admin/programs/ui/edit-session-modal.tsx | 304 + .../admin/programs/ui/edit-sets-modal.tsx | 341 + .../admin/programs/ui/edit-week-modal.tsx | 318 + .../admin/programs/ui/program-builder.tsx | 187 + .../admin/programs/ui/programs-list.tsx | 100 + .../admin/programs/ui/session-card.tsx | 127 + .../admin/programs/ui/visibility-badge.tsx | 85 + src/features/admin/programs/ui/week-card.tsx | 84 + .../admin/users/list/ui/users-table.tsx | 308 + src/features/ads/hooks/useUserSubscription.ts | 12 + .../forgot-password/forgot-password.schema.ts | 7 + .../model/useForgotPassword.tsx | 42 + .../ui/forgot-password-form.tsx | 79 + src/features/auth/lib/auth-client.ts | 18 + src/features/auth/lib/better-auth.ts | 142 + src/features/auth/model/useLogout.ts | 19 + .../reset-password/model/useResetPassword.ts | 50 + .../schema/reset-password.schema.ts | 18 + .../reset-password/ui/reset-password-form.tsx | 81 + src/features/auth/signin/model/useSignIn.ts | 32 + .../auth/signin/schema/signin.schema.ts | 8 + .../auth/signin/ui/CredentialsLoginForm.tsx | 111 + .../auth/signup/model/signup.action.ts | 46 + src/features/auth/signup/model/useSignUp.ts | 48 + .../auth/signup/schema/signup.schema.ts | 11 + src/features/auth/signup/ui/signup-form.tsx | 147 + src/features/auth/ui/AuthButtonServer.tsx | 19 + src/features/auth/ui/LoggedInButton.tsx | 19 + src/features/auth/ui/ProviderButton.tsx | 66 + src/features/auth/ui/SignInButton.tsx | 15 + src/features/auth/ui/SignUpButton.tsx | 14 + src/features/auth/verify-email/constants.ts | 1 + .../auth/verify-email/model/useResendEmail.ts | 44 + .../verify-email/ui/verify-email-page.tsx | 57 + .../model/tracking-consent.action.ts | 22 + .../schema/tracking-consent.schema.ts | 3 + .../consent-banner/ui/consent-banner.tsx | 41 + .../model/contact-feedback.action.ts | 32 + .../model/contact-feedback.schema.ts | 9 + .../contact-feedback/ui/ReviewInput.tsx | 34 + .../ui/contact-feedback-popover.tsx | 110 + .../contact/support/ContactSupportDialog.tsx | 115 + .../contact/support/contact-support.action.ts | 19 + .../contact/support/contact-support.schema.ts | 9 + .../dialogs-provider/DialogProvider.tsx | 95 + .../dialogs-provider/DialogProviderDialog.tsx | 59 + src/features/email/EmailForm.tsx | 107 + src/features/email/email.action.ts | 32 + src/features/email/email.schema.ts | 7 + src/features/form/SubmitButton.tsx | 39 + src/features/layout/BottomNavigation.tsx | 203 + src/features/layout/Footer.tsx | 85 + src/features/layout/Header.tsx | 181 + .../layout/model/use-sidebar.store.tsx | 11 + src/features/layout/nav-link.tsx | 75 + src/features/layout/page-heading.tsx | 17 + src/features/layout/useSidebarToggle.ts | 24 + src/features/layout/workout-streak-header.tsx | 224 + .../actions/get-top-workout-users.action.ts | 95 + .../actions/get-user-position.action.ts | 65 + .../hooks/use-top-workout-users.ts | 26 + .../leaderboard/hooks/use-user-position.ts | 26 + src/features/leaderboard/lib/utils.ts | 40 + src/features/leaderboard/models/types.ts | 9 + .../leaderboard/ui/leaderboard-item.tsx | 86 + .../leaderboard/ui/leaderboard-page.tsx | 214 + .../leaderboard/ui/leaderboard-skeleton.tsx | 22 + .../ui/user-leaderboard-position.tsx | 114 + src/features/page/layout.tsx | 28 + src/features/premium/ui/README.md | 226 + .../ui/conversion-flow-notification.tsx | 42 + .../premium/ui/feature-comparison-table.tsx | 206 + src/features/premium/ui/index.ts | 11 + .../premium/ui/premium-upgrade-card.tsx | 443 + src/features/premium/ui/pricing-faq.tsx | 122 + .../premium/ui/pricing-hero-section.tsx | 88 + .../premium/ui/pricing-testimonials.tsx | 270 + src/features/profile/ui/body-weight-input.tsx | 72 + .../profile/ui/workout-equipment-input.tsx | 371 + .../complete-program-session.action.ts | 114 + .../programs/actions/enroll-program.action.ts | 79 + .../actions/get-program-by-slug.action.ts | 202 + .../get-program-progress-by-slug.action.ts | 94 + .../actions/get-program-progress.action.ts | 77 + .../actions/get-public-programs.action.ts | 126 + .../actions/get-session-by-slug.action.ts | 220 + .../actions/get-sitemap-data.action.ts | 76 + .../actions/start-program-session.action.ts | 128 + .../programs/hooks/use-program-share.ts | 62 + src/features/programs/lib/program-metadata.ts | 33 + src/features/programs/lib/session-metadata.ts | 83 + .../programs/lib/suggested-sets-helpers.ts | 56 + .../programs/lib/translations-mapper.ts | 57 + src/features/programs/ui/program-card.tsx | 215 + .../programs/ui/program-detail-page.tsx | 588 + src/features/programs/ui/program-progress.tsx | 79 + src/features/programs/ui/programs-page.tsx | 166 + .../programs/ui/session-access-guard.tsx | 138 + src/features/programs/ui/share-button.tsx | 89 + src/features/programs/ui/welcome-modal.tsx | 165 + src/features/release-notes/hooks/index.ts | 2 + .../hooks/use-changelog-notification.ts | 125 + src/features/release-notes/index.ts | 6 + src/features/release-notes/lib/date-utils.ts | 70 + .../model/changelog-notification.local.ts | 152 + src/features/release-notes/model/notes.ts | 63 + .../release-notes/types/notification.ts | 128 + .../ui/changelog-notification-badge.tsx | 58 + src/features/release-notes/ui/index.ts | 2 + .../release-notes/ui/release-notes-dialog.tsx | 72 + .../onboarding/SponsorOnboardingForm.tsx | 149 + .../onboarding/sponsor-onboarding.action.ts | 20 + .../onboarding/sponsor-onboarding.schema.ts | 12 + .../actions/get-overall-stats.action.ts | 163 + .../components/ExerciseSelection.tsx | 198 + .../components/ExerciseStatisticsTab.tsx | 111 + .../components/ExercisesBrowser.tsx | 414 + .../statistics/components/OneRepMaxChart.tsx | 154 + .../components/OverallStatsSummary.tsx | 143 + .../components/StatisticsPreviewOverlay.tsx | 292 + .../components/TimeframeSelector.tsx | 40 + .../statistics/components/VolumeChart.tsx | 158 + .../components/WeightProgressionChart.tsx | 137 + src/features/statistics/components/index.ts | 6 + .../statistics/hooks/use-chart-theme.ts | 22 + .../hooks/use-exercise-statistics.ts | 86 + src/features/statistics/types/index.ts | 24 + src/features/theme/ThemeProviders.tsx | 8 + src/features/theme/ThemeToggle.tsx | 40 + src/features/theme/ui/ThemeSynchronizer.tsx | 25 + .../actions/apply-weekly-adjustment.action.ts | 42 + .../get-progression-aware-plan.action.ts | 56 + .../actions/get-quick-session.action.ts | 169 + .../actions/get-stretches.action.ts | 63 + .../actions/get-weekly-adjustment.action.ts | 132 + .../actions/training-plan.action.ts | 277 + .../model/__tests__/data-confidence.test.ts | 78 + .../model/__tests__/day-recommender.test.ts | 67 + .../__tests__/exercise-selection.test.ts | 103 + .../__tests__/progression-overlay.test.ts | 115 + .../model/__tests__/quick-session.test.ts | 136 + .../model/__tests__/session-finisher.test.ts | 55 + .../model/__tests__/set-rep-schemes.test.ts | 98 + .../model/__tests__/split-generator.test.ts | 106 + .../model/__tests__/user-intent.test.ts | 63 + .../model/__tests__/weekly-adjustment.test.ts | 198 + .../model/__tests__/weekly-volume.test.ts | 73 + .../training-science/model/data-confidence.ts | 61 + .../training-science/model/day-recommender.ts | 77 + .../model/exercise-selection.ts | 63 + .../model/progression-overlay.ts | 122 + .../training-science/model/quick-session.ts | 254 + .../model/session-finisher.ts | 100 + .../training-science/model/set-rep-schemes.ts | 94 + .../training-science/model/split-generator.ts | 251 + .../training-science/model/stretch-routine.ts | 229 + src/features/training-science/model/types.ts | 54 + .../training-science/model/user-intent.ts | 66 + .../model/weekly-adjustment.ts | 192 + .../training-science/model/weekly-volume.ts | 62 + .../training-science/ui/plan-page.tsx | 352 + .../ui/weekly-adjustment-card.tsx | 103 + src/features/update-password/lib/hash.ts | 12 + .../update-password/lib/validate-password.ts | 5 + .../model/update-password.action.ts | 70 + .../model/update-password.schema.ts | 7 + .../update-password/ui/password-form.tsx | 121 + src/features/user/ui/UserDropdown.tsx | 62 + .../actions/get-acwr-data.action.ts | 31 + .../actions/get-muscle-volume.action.ts | 27 + .../get-progressive-overload.action.ts | 28 + .../actions/load-set-entries.ts | 74 + .../components/ACWRGauge.tsx | 62 + .../components/AnalyticsDashboard.tsx | 15 + .../components/MuscleVolumeBars.tsx | 59 + .../components/ProgressiveOverloadCard.tsx | 37 + .../workout-analytics/components/index.ts | 4 + .../workout-analytics/hooks/use-acwr.ts | 21 + .../hooks/use-muscle-volume.ts | 19 + .../hooks/use-progressive-overload.ts | 22 + src/features/workout-analytics/index.ts | 2 + .../model/__tests__/calculate-acwr.test.ts | 80 + .../model/__tests__/get-weekly-volume.test.ts | 60 + .../__tests__/progressive-overload.test.ts | 38 + .../workout-analytics/model/calculate-acwr.ts | 102 + .../workout-analytics/model/date-utils.ts | 26 + .../model/get-weekly-volume.ts | 79 + src/features/workout-analytics/model/index.ts | 5 + .../model/progressive-overload.ts | 64 + src/features/workout-analytics/model/types.ts | 68 + .../actions/get-exercises-by-muscle.action.ts | 116 + .../actions/get-exercises.action.ts | 141 + .../actions/get-favorite-exercises.action.ts | 34 + .../actions/pick-exercise.action.ts | 28 + .../actions/shuffle-exercise.action.ts | 161 + .../actions/sync-favorite-exercises.action.ts | 41 + .../workout-builder/hooks/use-exercises.ts | 37 + .../hooks/use-favorite-exercises.service.ts | 87 + .../hooks/use-favorites-modal.ts | 107 + .../hooks/use-sync-favorite-exercises.ts | 92 + .../hooks/use-workout-session.ts | 9 + .../hooks/use-workout-stepper.ts | 130 + src/features/workout-builder/index.ts | 5 + .../workout-builder/model/equipment-config.ts | 87 + .../model/favorite-exercises-synchronizer.tsx | 20 + .../model/favorite-exercises.local.ts | 68 + .../model/workout-builder.store.ts | 376 + .../schema/get-exercises.schema.ts | 15 + src/features/workout-builder/types/index.ts | 46 + .../workout-builder/ui/add-exercise-modal.tsx | 380 + .../ui/equipment-selection.tsx | 188 + .../workout-builder/ui/exercise-card.tsx | 177 + .../workout-builder/ui/exercise-list-item.tsx | 201 + .../ui/exercise-pick-modal.tsx | 172 + .../ui/exercise-video-modal.tsx | 225 + .../ui/exercises-selection.tsx | 206 + .../workout-builder/ui/favorite-button.tsx | 27 + .../ui/favorite-exercise-button.tsx | 72 + .../ui/goal-split-selection.tsx | 205 + .../workout-builder/ui/muscle-selection.tsx | 1220 ++ .../workout-builder/ui/muscles.module.css | 51 + .../ui/muscles/abdominals-group.tsx | 221 + .../workout-builder/ui/muscles/back-group.tsx | 327 + .../ui/muscles/biceps-group.tsx | 142 + .../ui/muscles/calves-group.tsx | 414 + .../ui/muscles/chest-group.tsx | 125 + .../ui/muscles/forearms-group.tsx | 353 + .../ui/muscles/glutes-group.tsx | 100 + .../ui/muscles/hamstrings-group.tsx | 262 + .../ui/muscles/obliques-group.tsx | 212 + .../ui/muscles/quadriceps-group.tsx | 259 + .../ui/muscles/shoulders-group.tsx | 188 + .../ui/muscles/traps-group.tsx | 243 + .../ui/muscles/triceps-group.tsx | 165 + .../ui/quick-plan-session-card.tsx | 158 + .../ui/quick-training-card.tsx | 99 + .../ui/quit-workout-dialog.tsx | 81 + .../ui/session-finisher-card.tsx | 53 + .../workout-builder/ui/stepper-header.tsx | 169 + .../ui/today-training-card.tsx | 176 + .../ui/workout-stepper-footer.tsx | 81 + .../workout-builder/ui/workout-stepper.tsx | 1184 ++ .../actions/delete-workout-session.action.ts | 40 + .../actions/get-workout-sessions.action.ts | 48 + .../actions/sync-workout-sessions.action.ts | 130 + .../hooks/use-donation-modal.ts | 21 + .../workout-session/lib/workout-set-labels.ts | 13 + .../model/use-sync-workout-sessions.ts | 98 + .../model/use-workout-session.ts | 7 + .../model/use-workout-sessions.ts | 19 + .../model/workout-session.store.ts | 607 + .../workout-session/types/workout-set.ts | 20 + .../workout-session/ui/donation-modal.tsx | 191 + .../workout-session/ui/rest-timer.tsx | 259 + .../ui/stretch-routine-section.tsx | 214 + .../ui/workout-session-header.tsx | 264 + .../ui/workout-session-heatmap.tsx | 242 + .../ui/workout-session-list.tsx | 212 + .../ui/workout-session-set.tsx | 283 + .../ui/workout-session-sets.tsx | 478 + .../ui/workout-session-timer.tsx | 566 + .../ui/workout-sessions-synchronizer.tsx | 20 + src/index.d.ts | 6 + src/shared/api/README.md | 84 + src/shared/api/createHandler.ts | 151 + src/shared/api/handlers.ts | 62 + src/shared/api/mobile-auth.ts | 52 + src/shared/api/mobile-cookie-utils.ts | 46 + src/shared/api/mobile-safe-actions.ts | 69 + src/shared/api/safe-actions.ts | 45 + src/shared/config/localized-metadata.ts | 123 + src/shared/config/site-config.ts | 50 + src/shared/constants/cookies.ts | 3 + src/shared/constants/errors.ts | 10 + src/shared/constants/paths.ts | 14 + src/shared/constants/placeholders.ts | 6 + src/shared/constants/regexs.ts | 1 + src/shared/constants/screen.ts | 6 + src/shared/constants/social-platforms.tsx | 63 + src/shared/constants/statistics.ts | 30 + src/shared/constants/success.ts | 3 + src/shared/constants/workout-set-types.ts | 4 + src/shared/hooks/use-clipboard.ts | 88 + src/shared/hooks/use-drag-feedback.ts | 89 + src/shared/hooks/use-premium-plans.ts | 43 + src/shared/hooks/use-workout-feedback.ts | 108 + src/shared/hooks/useBoolean.ts | 21 + src/shared/hooks/useIsMobile.ts | 27 + src/shared/hooks/useScrollToTop.ts | 70 + src/shared/lib/access-control.ts | 52 + src/shared/lib/analytics/client.tsx | 40 + src/shared/lib/analytics/events.ts | 30 + src/shared/lib/analytics/server.ts | 50 + src/shared/lib/attribute-value-translation.ts | 97 + src/shared/lib/audio-feedback.ts | 42 + src/shared/lib/date.ts | 97 + src/shared/lib/format.ts | 3 + src/shared/lib/guards.ts | 3 + src/shared/lib/i18n-mapper.ts | 37 + src/shared/lib/locale-slug.ts | 61 + src/shared/lib/location/eu-countries.ts | 60 + src/shared/lib/location/location.ts | 19 + src/shared/lib/logger.ts | 7 + src/shared/lib/mail/sendEmail.ts | 39 + src/shared/lib/mdx/load-mdx.ts | 27 + src/shared/lib/network/use-network-status.ts | 19 + src/shared/lib/premium/premium.manager.ts | 235 + src/shared/lib/premium/premium.service.ts | 495 + .../lib/premium/providers/base-provider.ts | 45 + .../lib/premium/providers/stripe-provider.ts | 424 + .../lib/premium/use-pending-checkout.ts | 57 + .../lib/premium/use-premium-redirect.ts | 25 + src/shared/lib/premium/use-premium.ts | 59 + src/shared/lib/prisma.ts | 15 + src/shared/lib/revenuecat/index.ts | 8 + src/shared/lib/revenuecat/revenuecat.api.ts | 250 + .../lib/revenuecat/revenuecat.config.ts | 80 + .../lib/revenuecat/revenuecat.mapping.ts | 120 + src/shared/lib/server-url.ts | 23 + src/shared/lib/slug.ts | 75 + src/shared/lib/structured-data.ts | 703 + src/shared/lib/user-preferences.ts | 200 + src/shared/lib/utils.ts | 6 + src/shared/lib/version.ts | 3 + src/shared/lib/web-share.ts | 77 + src/shared/lib/weight-conversion.ts | 59 + src/shared/lib/workout-session/equipments.ts | 30 + .../workout-session/quick-training.local.ts | 25 + .../workout-session/types/workout-session.ts | 34 + .../use-workout-session.service.ts | 109 + .../workout-session/workout-session.api.ts | 19 + .../workout-session/workout-session.local.ts | 58 + src/shared/lib/youtube.ts | 106 + src/shared/schemas/url.ts | 4 + .../styles/additional-styles/highlights.css | 55 + .../additional-styles/utility-patterns.css | 38 + src/shared/styles/globals.css | 685 + src/shared/types/i18n.types.ts | 54 + src/shared/types/next.ts | 51 + src/shared/types/premium.types.ts | 75 + src/shared/types/statistics.types.ts | 105 + src/shared/types/storage.ts | 7 + src/widgets/404.tsx | 34 + .../language-selector/language-selector.tsx | 83 + tsconfig.json | 54 + vitest.config.ts | 16 + workout-cool.code-workspace | 13 + 833 files changed, 96772 insertions(+) create mode 100644 .cursorrules create mode 100644 .env.example create mode 100644 .github/FUNDING.yml create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/notify-discord-issues.yml create mode 100644 .github/workflows/notify-discord-pr.yml create mode 100644 .github/workflows/notify-discord.yml create mode 100644 .github/workflows/publish-ghcr-image.yml create mode 100644 .gitignore create mode 100644 .npmrc create mode 100644 .prettierrc create mode 100644 .vscode/settings.json create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 CONTRIBUTING.md create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 NOTICE create mode 100644 README.md create mode 100644 app/[locale]/(admin)/admin/[...catchAll]/not-found.tsx create mode 100644 app/[locale]/(admin)/admin/[...catchAll]/page.tsx create mode 100644 app/[locale]/(admin)/admin/dashboard/page.tsx create mode 100644 app/[locale]/(admin)/admin/layout.tsx create mode 100644 app/[locale]/(admin)/admin/not-found.tsx create mode 100644 app/[locale]/(admin)/admin/programs/[id]/edit/page.tsx create mode 100644 app/[locale]/(admin)/admin/programs/page.tsx create mode 100644 app/[locale]/(admin)/admin/settings/page.tsx create mode 100644 app/[locale]/(admin)/admin/users/page.tsx create mode 100644 app/[locale]/(app)/(legal-and-payment)/layout.tsx create mode 100644 app/[locale]/(app)/(legal-and-payment)/legal/privacy/page.tsx create mode 100644 app/[locale]/(app)/(legal-and-payment)/legal/sales-terms/page.tsx create mode 100644 app/[locale]/(app)/(legal-and-payment)/legal/terms/page.tsx create mode 100644 app/[locale]/(app)/[slug]/layout.tsx create mode 100644 app/[locale]/(app)/about/page.tsx create mode 100644 app/[locale]/(app)/analytics/page.tsx create mode 100644 app/[locale]/(app)/auth/(auth-layout)/forgot-password/page.tsx create mode 100644 app/[locale]/(app)/auth/(auth-layout)/layout.tsx create mode 100644 app/[locale]/(app)/auth/(auth-layout)/reset-password/page.tsx create mode 100644 app/[locale]/(app)/auth/(auth-layout)/signin/page.tsx create mode 100644 app/[locale]/(app)/auth/(auth-layout)/signup/page.tsx create mode 100644 app/[locale]/(app)/auth/error.tsx create mode 100644 app/[locale]/(app)/auth/error/page.tsx create mode 100644 app/[locale]/(app)/auth/layout.tsx create mode 100644 app/[locale]/(app)/auth/signout/page.tsx create mode 100644 app/[locale]/(app)/auth/verify-email/layout.tsx create mode 100644 app/[locale]/(app)/auth/verify-email/page.tsx create mode 100644 app/[locale]/(app)/auth/verify-request/page.tsx create mode 100644 app/[locale]/(app)/layout.tsx create mode 100644 app/[locale]/(app)/leaderboard/page.tsx create mode 100644 app/[locale]/(app)/onboarding/layout.tsx create mode 100644 app/[locale]/(app)/onboarding/page.tsx create mode 100644 app/[locale]/(app)/page.tsx create mode 100644 app/[locale]/(app)/plan/page.tsx create mode 100644 app/[locale]/(app)/premium/page.tsx create mode 100644 app/[locale]/(app)/profile/page.tsx create mode 100644 app/[locale]/(app)/programs/[slug]/page.tsx create mode 100644 app/[locale]/(app)/programs/[slug]/session/[sessionSlug]/ProgramSessionClient.tsx create mode 100644 app/[locale]/(app)/programs/[slug]/session/[sessionSlug]/page.tsx create mode 100644 app/[locale]/(app)/programs/page.tsx create mode 100644 app/[locale]/(app)/sponsor/onboarding/page.tsx create mode 100644 app/[locale]/(app)/statistics/page.tsx create mode 100644 app/[locale]/(app)/tools/bmi-calculator/bmi-calculator.utils.ts create mode 100644 app/[locale]/(app)/tools/bmi-calculator/page.tsx create mode 100644 app/[locale]/(app)/tools/bmi-calculator/shared/BmiCalculatorClient.tsx create mode 100644 app/[locale]/(app)/tools/bmi-calculator/shared/components/BmiEducationalContent.tsx create mode 100644 app/[locale]/(app)/tools/bmi-calculator/shared/components/BmiHeightInput.tsx create mode 100644 app/[locale]/(app)/tools/bmi-calculator/shared/components/BmiResultsDisplay.tsx create mode 100644 app/[locale]/(app)/tools/bmi-calculator/shared/components/BmiUnitSelector.tsx create mode 100644 app/[locale]/(app)/tools/bmi-calculator/shared/components/BmiWeightInput.tsx create mode 100644 app/[locale]/(app)/tools/bmi-calculator/shared/components/MathEquation.tsx create mode 100644 app/[locale]/(app)/tools/calorie-calculator/CalorieCalculatorHub.tsx create mode 100644 app/[locale]/(app)/tools/calorie-calculator/calorie-calculator-comparison/CalorieCalculatorComparison.tsx create mode 100644 app/[locale]/(app)/tools/calorie-calculator/calorie-calculator-comparison/page.tsx create mode 100644 app/[locale]/(app)/tools/calorie-calculator/calorie-calculator.utils.ts create mode 100644 app/[locale]/(app)/tools/calorie-calculator/cunningham-calculator/page.tsx create mode 100644 app/[locale]/(app)/tools/calorie-calculator/harris-benedict-calculator/page.tsx create mode 100644 app/[locale]/(app)/tools/calorie-calculator/katch-mcardle-calculator/page.tsx create mode 100644 app/[locale]/(app)/tools/calorie-calculator/mifflin-st-jeor-calculator/page.tsx create mode 100644 app/[locale]/(app)/tools/calorie-calculator/oxford-calculator/page.tsx create mode 100644 app/[locale]/(app)/tools/calorie-calculator/page.tsx create mode 100644 app/[locale]/(app)/tools/calorie-calculator/shared/CalorieCalculatorClient.tsx create mode 100644 app/[locale]/(app)/tools/calorie-calculator/shared/calculator-configs.ts create mode 100644 app/[locale]/(app)/tools/calorie-calculator/shared/calorie-formulas.utils.ts create mode 100644 app/[locale]/(app)/tools/calorie-calculator/shared/components/ActivityLevelSelector.tsx create mode 100644 app/[locale]/(app)/tools/calorie-calculator/shared/components/AgeInput.tsx create mode 100644 app/[locale]/(app)/tools/calorie-calculator/shared/components/BodyFatInput.tsx create mode 100644 app/[locale]/(app)/tools/calorie-calculator/shared/components/FAQSection.tsx create mode 100644 app/[locale]/(app)/tools/calorie-calculator/shared/components/GenderSelector.tsx create mode 100644 app/[locale]/(app)/tools/calorie-calculator/shared/components/GoalSelector.tsx create mode 100644 app/[locale]/(app)/tools/calorie-calculator/shared/components/HeightInput.tsx create mode 100644 app/[locale]/(app)/tools/calorie-calculator/shared/components/InfoButton.tsx create mode 100644 app/[locale]/(app)/tools/calorie-calculator/shared/components/InfoModal.tsx create mode 100644 app/[locale]/(app)/tools/calorie-calculator/shared/components/ResultsDisplay.tsx create mode 100644 app/[locale]/(app)/tools/calorie-calculator/shared/components/UnitSelector.tsx create mode 100644 app/[locale]/(app)/tools/calorie-calculator/shared/components/WeightInput.tsx create mode 100644 app/[locale]/(app)/tools/calorie-calculator/shared/components/index.ts create mode 100644 app/[locale]/(app)/tools/calorie-calculator/shared/types/index.ts create mode 100644 app/[locale]/(app)/tools/calorie-calculator/styles.css create mode 100644 app/[locale]/(app)/tools/heart-rate-zones/lib/utils.ts create mode 100644 app/[locale]/(app)/tools/heart-rate-zones/page.tsx create mode 100644 app/[locale]/(app)/tools/heart-rate-zones/seo/config.ts create mode 100644 app/[locale]/(app)/tools/heart-rate-zones/seo/page-content.ts create mode 100644 app/[locale]/(app)/tools/heart-rate-zones/ui/HeartRateZonesCalculatorClient.tsx create mode 100644 app/[locale]/(app)/tools/heart-rate-zones/ui/components/EducationalContent.tsx create mode 100644 app/[locale]/(app)/tools/heart-rate-zones/ui/components/EducationalContentServer.tsx create mode 100644 app/[locale]/(app)/tools/heart-rate-zones/ui/components/FAQAccordion.tsx create mode 100644 app/[locale]/(app)/tools/heart-rate-zones/ui/components/SEOOptimizedContentServer.tsx create mode 100644 app/[locale]/(app)/tools/heart-rate-zones/ui/components/ScrollToTopButton.tsx create mode 100644 app/[locale]/(app)/tools/heart-rate-zones/ui/styles.css create mode 100644 app/[locale]/(app)/tools/page.tsx create mode 100644 app/[locale]/@modal/(.)auth/login/page.tsx create mode 100644 app/[locale]/@modal/default.tsx create mode 100644 app/[locale]/layout.tsx create mode 100644 app/[locale]/manifest.json/route.ts create mode 100644 app/[locale]/not-found.tsx create mode 100644 app/[locale]/providers.tsx create mode 100644 app/ads.txt/route.ts create mode 100644 app/api/analytics/premium/route.ts create mode 100644 app/api/auth/[...all]/route.ts create mode 100644 app/api/auth/signup/route.ts create mode 100644 app/api/billing/status/route.ts create mode 100644 app/api/exercises/[exerciseId]/statistics/one-rep-max/route.ts create mode 100644 app/api/exercises/[exerciseId]/statistics/route.ts create mode 100644 app/api/exercises/[exerciseId]/statistics/volume/route.ts create mode 100644 app/api/exercises/[exerciseId]/statistics/weight-progression/route.ts create mode 100644 app/api/exercises/all/route.ts create mode 100644 app/api/exercises/route.ts create mode 100644 app/api/exercises/shuffle/route.ts create mode 100644 app/api/premium/billing-portal/route.ts create mode 100644 app/api/premium/checkout/route.ts create mode 100644 app/api/premium/plans/route.ts create mode 100644 app/api/premium/status/route.ts create mode 100644 app/api/programs/[slug]/enroll/route.ts create mode 100644 app/api/programs/[slug]/progress/route.ts create mode 100644 app/api/programs/[slug]/route.ts create mode 100644 app/api/programs/[slug]/sessions/[sessionSlug]/route.ts create mode 100644 app/api/programs/route.ts create mode 100644 app/api/programs/session-progress/[progressId]/complete/route.ts create mode 100644 app/api/programs/session-progress/start/route.ts create mode 100644 app/api/revenuecat/link-user/route.ts create mode 100644 app/api/revenuecat/sync-status/route.ts create mode 100644 app/api/revenuecat/webhook/route.ts create mode 100644 app/api/user/password/route.ts create mode 100644 app/api/user/preferences/route.ts create mode 100644 app/api/user/profile/route.ts create mode 100644 app/api/webhooks/revenuecat/route.ts create mode 100644 app/api/webhooks/stripe/route.ts create mode 100644 app/api/workout-sessions/[sessionId]/feedback/route.ts create mode 100644 app/api/workout-sessions/[sessionId]/rating/route.ts create mode 100644 app/api/workout-sessions/[sessionId]/route.ts create mode 100644 app/api/workout-sessions/[sessionId]/summary/route.ts create mode 100644 app/api/workout-sessions/sync/route.ts create mode 100644 app/api/workout-sessions/user/[userId]/route.ts create mode 100644 app/robots.txt create mode 100644 app/sitemap.ts create mode 100644 components.json create mode 100644 content/about/en.mdx create mode 100644 content/about/es.mdx create mode 100644 content/about/fr.mdx create mode 100644 content/about/pt.mdx create mode 100644 content/about/ru.mdx create mode 100644 content/about/zh-CN.mdx create mode 100644 content/privacy-policy/en.mdx create mode 100644 content/privacy-policy/es.mdx create mode 100644 content/privacy-policy/fr.mdx create mode 100644 content/privacy-policy/pt.mdx create mode 100644 content/privacy-policy/ru.mdx create mode 100644 content/privacy-policy/zh-CN.mdx create mode 100644 content/sales-terms/en.mdx create mode 100644 content/sales-terms/es.mdx create mode 100644 content/sales-terms/fr.mdx create mode 100644 content/sales-terms/pt.mdx create mode 100644 content/sales-terms/ru.mdx create mode 100644 content/sales-terms/zh-CN.mdx create mode 100644 content/terms/en.mdx create mode 100644 content/terms/es.mdx create mode 100644 content/terms/fr.mdx create mode 100644 content/terms/pt.mdx create mode 100644 content/terms/ru.mdx create mode 100644 content/terms/zh-CN.mdx create mode 100644 data/sample-exercises.csv create mode 100644 docker-compose.yml create mode 100644 docs/SELF-HOSTING.md create mode 100644 docs/WORKOUT-ANALYTICS-DEPLOY.md create mode 100644 emails/ContactSupportEmail.tsx create mode 100644 emails/DeleteAccountEmail.tsx create mode 100644 emails/ResetPasswordEmail.tsx create mode 100644 emails/SponsorOnboardingEmail.tsx create mode 100644 emails/VerifyEmail.tsx create mode 100644 emails/utils/BaseEmailLayout.tsx create mode 100644 eslint.config.mjs create mode 100644 locales/client.ts create mode 100644 locales/en.ts create mode 100644 locales/es.ts create mode 100644 locales/fr.ts create mode 100644 locales/heart-rate-zones-translations.ts create mode 100644 locales/pt.ts create mode 100644 locales/ru.ts create mode 100644 locales/server.ts create mode 100644 locales/types.ts create mode 100644 locales/zh-CN.ts create mode 100644 next.config.ts create mode 100644 nextauth.d.ts create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 postcss.config.mjs create mode 100644 prisma/migrations/0_init/migration.sql create mode 100644 prisma/migrations/1_add_split_day/migration.sql create mode 100644 prisma/migrations/2_user_training_plan/migration.sql create mode 100644 prisma/migrations_backup/20240726_simplify_subscription_model/migration.sql create mode 100644 prisma/migrations_backup/20250101000000_baseline/migration.sql create mode 100644 prisma/migrations_backup/20250117000000_add_statistics_indexes/migration.sql create mode 100644 prisma/migrations_backup/20250414120436_init/migration.sql create mode 100644 prisma/migrations_backup/20250414170807_add_feedbacks/migration.sql create mode 100644 prisma/migrations_backup/20250414174246_rename_feedbacks/migration.sql create mode 100644 prisma/migrations_backup/20250414232816_add_first_name_and_last_name/migration.sql create mode 100644 prisma/migrations_backup/20250416160303_add_plans/migration.sql create mode 100644 prisma/migrations_backup/20250416160502_map/migration.sql create mode 100644 prisma/migrations_backup/20250505114841_add_user_role/migration.sql create mode 100644 prisma/migrations_backup/20250505191954_admin_and_user_lowercase/migration.sql create mode 100644 prisma/migrations_backup/20250610182024_add_exercises_and_attributes/migration.sql create mode 100644 prisma/migrations_backup/20250610182815_add_exercise_enums/migration.sql create mode 100644 prisma/migrations_backup/20250610184725_simplified_exercises/migration.sql create mode 100644 prisma/migrations_backup/20250611190228_convert_text_to_enums/migration.sql create mode 100644 prisma/migrations_backup/20250611210106_add_enum_values/migration.sql create mode 100644 prisma/migrations_backup/20250612213546_workout_session_sets/migration.sql create mode 100644 prisma/migrations_backup/20250613095031_add_multi_column_support/migration.sql create mode 100644 prisma/migrations_backup/20250614125347_add_table_maps/migration.sql create mode 100644 prisma/migrations_backup/20250614153656_remove_value_int_value_sec_unit_from_workoutset/migration.sql create mode 100644 prisma/migrations_backup/20250615160343_add_muscle_to_a_workout_session/migration.sql create mode 100644 prisma/migrations_backup/20250615170916_add_cascade_delete_workout_sessions/migration.sql create mode 100644 prisma/migrations_backup/20250623142458_add_billing_and_subscriptions/migration.sql create mode 100644 prisma/migrations_backup/20250623143952_remove_webhook_events/migration.sql create mode 100644 prisma/migrations_backup/20250623144324_add_webhook_events/migration.sql create mode 100644 prisma/migrations_backup/20250625155932_add_admin/migration.sql create mode 100644 prisma/migrations_backup/20250625195907_add_program_visibility/migration.sql create mode 100644 prisma/migrations_backup/20250626102058_add_i18n_slugs_on_program/migration.sql create mode 100644 prisma/migrations_backup/20250626134345_remove_emoji_on_program/migration.sql create mode 100644 prisma/migrations_backup/20250626182857_cleanup_billing_system/migration.sql create mode 100644 prisma/migrations_backup/20250626204136_remove_payment_table/migration.sql create mode 100644 prisma/migrations_backup/20250626205121_remove_legacy_premium_fields/migration.sql create mode 100644 prisma/migrations_backup/20250626205904_remove_payment_table_keep_ispremium/migration.sql create mode 100644 prisma/migrations_backup/20250707114920_add_user_favorite_exercises/migration.sql create mode 100644 prisma/migrations_backup/20250708214116_add_rating_to_wkt_sessions/migration.sql create mode 100644 prisma/migrations_backup/20250709_add_revenuecat_fields/migration.sql create mode 100644 prisma/migrations_backup/migration_lock.toml create mode 100644 prisma/schema.prisma create mode 100644 proxy.ts create mode 100644 public/_ads.txt create mode 100644 public/android-chrome-192x192.png create mode 100644 public/android-chrome-512x512.png create mode 100644 public/apple-touch-icon.png create mode 100644 public/favicon.ico create mode 100644 public/images/calculator-og.jpg create mode 100644 public/images/coachs/mathias.png create mode 100644 public/images/coachs/mathias2.jpg create mode 100644 public/images/countries.png create mode 100644 public/images/default-og-image_en.jpg create mode 100644 public/images/default-og-image_es.jpg create mode 100644 public/images/default-og-image_fr.jpg create mode 100644 public/images/default-og-image_pt.jpg create mode 100644 public/images/default-og-image_ru.jpg create mode 100644 public/images/default-og-image_zh.jpg create mode 100644 public/images/default-workout.jpg create mode 100644 public/images/edwardsoyfit.png create mode 100644 public/images/emojis/WorkoutCoolBiceps.png create mode 100644 public/images/emojis/WorkoutCoolChief.png create mode 100644 public/images/emojis/WorkoutCoolCry.png create mode 100644 public/images/emojis/WorkoutCoolDisapointed.png create mode 100644 public/images/emojis/WorkoutCoolEmbarassed.png create mode 100644 public/images/emojis/WorkoutCoolExhausted.png create mode 100644 public/images/emojis/WorkoutCoolHappy.png create mode 100644 public/images/emojis/WorkoutCoolHuuuu.png create mode 100644 public/images/emojis/WorkoutCoolKittyGirl.png create mode 100644 public/images/emojis/WorkoutCoolLoL.png create mode 100644 public/images/emojis/WorkoutCoolLove.png create mode 100644 public/images/emojis/WorkoutCoolMedical.png create mode 100644 public/images/emojis/WorkoutCoolMeeeh.png create mode 100644 public/images/emojis/WorkoutCoolPolice.png create mode 100644 public/images/emojis/WorkoutCoolRich.png create mode 100644 public/images/emojis/WorkoutCoolShoked.png create mode 100644 public/images/emojis/WorkoutCoolShoked2.png create mode 100644 public/images/emojis/WorkoutCoolSwag.png create mode 100644 public/images/emojis/WorkoutCoolTeeths.png create mode 100644 public/images/emojis/WorkoutCoolTong.png create mode 100644 public/images/emojis/WorkoutCoolWooow.png create mode 100644 public/images/emojis/WorkoutCoolYeahOk.png create mode 100644 public/images/emojis/WorkoutCoolhaphap.png create mode 100644 public/images/equipment/band.png create mode 100644 public/images/equipment/barbell.png create mode 100644 public/images/equipment/bench.png create mode 100644 public/images/equipment/bodyweight.png create mode 100644 public/images/equipment/dumbbell.png create mode 100644 public/images/equipment/kettlebell.png create mode 100644 public/images/equipment/plate.png create mode 100644 public/images/equipment/pull-up-bar.png create mode 100644 public/images/equipment/swiss-ball.png create mode 100644 public/images/favicon-16x16.png create mode 100644 public/images/favicon-32x32.png create mode 100644 public/images/favicon.ico create mode 100644 public/images/nutripure-gellules-2.png create mode 100644 public/images/nutripure-gellules.png create mode 100644 public/images/nutripure-logo.webp create mode 100644 public/images/old-nutripure-gellules.png create mode 100644 public/images/placeholders/coach-avatar.png create mode 100644 public/images/placeholders/no-image.jpg create mode 100644 public/images/screenshots/heart-rate-zones/en.jpg create mode 100644 public/images/screenshots/heart-rate-zones/es.jpg create mode 100644 public/images/screenshots/heart-rate-zones/fr.jpg create mode 100644 public/images/screenshots/heart-rate-zones/og.jpg create mode 100644 public/images/screenshots/heart-rate-zones/pt.jpg create mode 100644 public/images/screenshots/heart-rate-zones/ru.jpg create mode 100644 public/images/screenshots/heart-rate-zones/zh-CN.jpg create mode 100644 public/images/sponsorship/fd-with-padding.png create mode 100644 public/images/sponsorship/nutri-and-co.png create mode 100644 public/images/sponsorship/nutripure.png create mode 100644 public/images/trophy.png create mode 100644 public/logo.png create mode 100644 public/manifest.json create mode 100644 public/sw.js create mode 100644 scripts/check-pricing-config.ts create mode 100644 scripts/convert-exercisedb-dataset.ts create mode 100755 scripts/dev-safe.sh create mode 100644 scripts/import-exercises-with-attributes.prompt.md create mode 100644 scripts/import-exercises-with-attributes.ts create mode 100755 scripts/mem-watchdog.sh create mode 100644 scripts/seed-demo-user.ts create mode 100644 scripts/seed-leaderboard-data.ts create mode 100644 scripts/seed-multi-region-plans.ts create mode 100644 scripts/seed-subscription-plans-simple.ts create mode 100644 scripts/seed-workout-data-advanced.ts create mode 100755 scripts/setup.sh create mode 100644 src/components/ads/AdBlockerForPremium.tsx create mode 100644 src/components/ads/AdPlaceholder.tsx create mode 100644 src/components/ads/AdSenseAutoAds.tsx create mode 100644 src/components/ads/AdWrapper.tsx create mode 100644 src/components/ads/EzoicAd.tsx create mode 100644 src/components/ads/GoogleAdSense.tsx create mode 100644 src/components/ads/HorizontalAdBanner.tsx create mode 100644 src/components/ads/HorizontalBottomBanner.tsx create mode 100644 src/components/ads/HorizontalTopBanner.tsx create mode 100644 src/components/ads/InArticle.tsx create mode 100644 src/components/ads/ResponsiveAdBanner.tsx create mode 100644 src/components/ads/VerticalAdBanner.tsx create mode 100644 src/components/ads/VerticalLeftBanner.tsx create mode 100644 src/components/ads/VerticalRightBanner.tsx create mode 100644 src/components/ads/custom/RewardedAdGate.tsx create mode 100644 src/components/ads/custom/index.ts create mode 100644 src/components/ads/custom/sponsor-card.tsx create mode 100644 src/components/ads/custom/sponsor-config.ts create mode 100644 src/components/ads/custom/sponsor-dialog.tsx create mode 100644 src/components/ads/custom/sponsor-horizontal-banner.tsx create mode 100644 src/components/ads/custom/sponsor-sidebar.tsx create mode 100644 src/components/ads/index.ts create mode 100644 src/components/ads/nutripure-affiliate-banner.tsx create mode 100644 src/components/lan-access-qr.tsx create mode 100644 src/components/premium/RemoveAdsText.tsx create mode 100644 src/components/pwa/ServiceWorkerRegistration.tsx create mode 100644 src/components/seo/SEOHead.tsx create mode 100644 src/components/seo/breadcrumbs.tsx create mode 100644 src/components/seo/duration-badge.tsx create mode 100644 src/components/seo/rich-snippet-rating.tsx create mode 100644 src/components/seo/session-rich-snippets.tsx create mode 100644 src/components/svg/BrokenLink.tsx create mode 100644 src/components/svg/Calendly.tsx create mode 100644 src/components/svg/CircleSvg.tsx create mode 100644 src/components/svg/DiscordSvg.tsx create mode 100644 src/components/svg/DotPattern.tsx create mode 100644 src/components/svg/GoogleSvg.tsx create mode 100644 src/components/svg/IconCheckboxCheck.tsx create mode 100644 src/components/svg/LogoSvg.tsx create mode 100644 src/components/svg/UnderlineSvg.tsx create mode 100644 src/components/svg/VerifiedBadge.tsx create mode 100644 src/components/svg/Youtube.tsx create mode 100644 src/components/ui/404-page-not-found.tsx create mode 100644 src/components/ui/Bento.tsx create mode 100644 src/components/ui/ToastSonner.tsx create mode 100644 src/components/ui/accordion.tsx create mode 100644 src/components/ui/alert-dialog.tsx create mode 100644 src/components/ui/alert.tsx create mode 100644 src/components/ui/animated-button/ShinyButton.tsx create mode 100644 src/components/ui/aspect-ratio.tsx create mode 100644 src/components/ui/avatar.tsx create mode 100644 src/components/ui/badge.tsx create mode 100644 src/components/ui/bottom-sheet-vaul.tsx create mode 100644 src/components/ui/bottom-sheet.tsx create mode 100644 src/components/ui/button.tsx create mode 100644 src/components/ui/card-styled.tsx create mode 100644 src/components/ui/card.tsx create mode 100644 src/components/ui/collapsible.tsx create mode 100644 src/components/ui/dialog-stack.tsx create mode 100644 src/components/ui/dialog.tsx create mode 100644 src/components/ui/divider.tsx create mode 100644 src/components/ui/donation-alert.tsx create mode 100644 src/components/ui/dropdown-menu.tsx create mode 100644 src/components/ui/form.tsx create mode 100644 src/components/ui/hover-card.tsx create mode 100644 src/components/ui/input-password-strength.tsx create mode 100644 src/components/ui/input.tsx create mode 100644 src/components/ui/iphone-mockup.tsx create mode 100644 src/components/ui/label.tsx create mode 100644 src/components/ui/link.tsx create mode 100644 src/components/ui/loader.tsx create mode 100644 src/components/ui/local-alert.tsx create mode 100644 src/components/ui/moving-border.tsx create mode 100644 src/components/ui/navigation-menu.tsx create mode 100644 src/components/ui/next-top-loader.tsx create mode 100644 src/components/ui/pagination.tsx create mode 100644 src/components/ui/phone-frame-preview.tsx create mode 100644 src/components/ui/popover.tsx create mode 100644 src/components/ui/premium-gate.tsx create mode 100644 src/components/ui/premium-upsell-alert.tsx create mode 100644 src/components/ui/radio-group.tsx create mode 100644 src/components/ui/scroll-area.tsx create mode 100644 src/components/ui/select.tsx create mode 100644 src/components/ui/separator.tsx create mode 100644 src/components/ui/sheet.tsx create mode 100644 src/components/ui/shine-border.tsx create mode 100644 src/components/ui/simple-select.tsx create mode 100644 src/components/ui/skeleton.tsx create mode 100644 src/components/ui/slider.tsx create mode 100644 src/components/ui/sonner.tsx create mode 100644 src/components/ui/star-button.tsx create mode 100644 src/components/ui/switch.tsx create mode 100644 src/components/ui/table.tsx create mode 100644 src/components/ui/tabs.tsx create mode 100644 src/components/ui/textarea.tsx create mode 100644 src/components/ui/theme-provider.tsx create mode 100644 src/components/ui/timer.tsx create mode 100644 src/components/ui/title-with-dot.tsx create mode 100644 src/components/ui/toast.tsx create mode 100644 src/components/ui/toaster.tsx create mode 100644 src/components/ui/tooltip.tsx create mode 100644 src/components/ui/typography.tsx create mode 100644 src/components/ui/use-toast.ts create mode 100644 src/components/ui/workout-lol.tsx create mode 100644 src/components/utils/ErrorBoundaries.tsx create mode 100644 src/components/utils/TailwindIndicator.tsx create mode 100644 src/components/version.tsx create mode 100644 src/entities/exercise/shared/exercise-type.ts create mode 100644 src/entities/exercise/shared/muscles.tsx create mode 100644 src/entities/exercise/types/exercise.types.ts create mode 100644 src/entities/program-session/types/program-session.types.ts create mode 100644 src/entities/program/types/program.types.ts create mode 100644 src/entities/user/lib/display-name.ts create mode 100644 src/entities/user/model/get-server-session-user.ts create mode 100644 src/entities/user/model/get-users.actions.ts create mode 100644 src/entities/user/model/update-user-locale.ts create mode 100644 src/entities/user/model/update-user.action.ts create mode 100644 src/entities/user/model/use-auto-locale.ts create mode 100644 src/entities/user/model/use-body-weight.ts create mode 100644 src/entities/user/model/useCurrentSession.ts create mode 100644 src/entities/user/model/useCurrentUser.ts create mode 100644 src/entities/user/schemas/get-user.schema.ts create mode 100644 src/entities/user/schemas/update-user.schema.ts create mode 100644 src/entities/user/types/session-user.ts create mode 100644 src/env.ts create mode 100644 src/features/admin/layout/admin-header.tsx create mode 100644 src/features/admin/layout/admin-sidebar/ui/admin-header.tsx create mode 100644 src/features/admin/layout/admin-sidebar/ui/admin-sidebar.tsx create mode 100644 src/features/admin/programs/actions/add-exercise.action.ts create mode 100644 src/features/admin/programs/actions/add-session.action.ts create mode 100644 src/features/admin/programs/actions/add-week.action.ts create mode 100644 src/features/admin/programs/actions/create-program.action.ts create mode 100644 src/features/admin/programs/actions/delete-program.action.ts create mode 100644 src/features/admin/programs/actions/get-programs.action.ts create mode 100644 src/features/admin/programs/actions/update-exercise-sets.action.ts create mode 100644 src/features/admin/programs/actions/update-program-visibility.action.ts create mode 100644 src/features/admin/programs/actions/update-program.action.ts create mode 100644 src/features/admin/programs/actions/update-session.action.ts create mode 100644 src/features/admin/programs/actions/update-week.action.ts create mode 100644 src/features/admin/programs/types/program.types.ts create mode 100644 src/features/admin/programs/ui/add-exercise-modal.tsx create mode 100644 src/features/admin/programs/ui/add-session-modal.tsx create mode 100644 src/features/admin/programs/ui/add-week-modal.tsx create mode 100644 src/features/admin/programs/ui/create-program-button.tsx create mode 100644 src/features/admin/programs/ui/create-program-form.tsx create mode 100644 src/features/admin/programs/ui/create-program-modal.tsx create mode 100644 src/features/admin/programs/ui/delete-program-button.tsx create mode 100644 src/features/admin/programs/ui/edit-program-modal.tsx create mode 100644 src/features/admin/programs/ui/edit-session-modal.tsx create mode 100644 src/features/admin/programs/ui/edit-sets-modal.tsx create mode 100644 src/features/admin/programs/ui/edit-week-modal.tsx create mode 100644 src/features/admin/programs/ui/program-builder.tsx create mode 100644 src/features/admin/programs/ui/programs-list.tsx create mode 100644 src/features/admin/programs/ui/session-card.tsx create mode 100644 src/features/admin/programs/ui/visibility-badge.tsx create mode 100644 src/features/admin/programs/ui/week-card.tsx create mode 100644 src/features/admin/users/list/ui/users-table.tsx create mode 100644 src/features/ads/hooks/useUserSubscription.ts create mode 100644 src/features/auth/forgot-password/forgot-password.schema.ts create mode 100644 src/features/auth/forgot-password/model/useForgotPassword.tsx create mode 100644 src/features/auth/forgot-password/ui/forgot-password-form.tsx create mode 100644 src/features/auth/lib/auth-client.ts create mode 100644 src/features/auth/lib/better-auth.ts create mode 100644 src/features/auth/model/useLogout.ts create mode 100644 src/features/auth/reset-password/model/useResetPassword.ts create mode 100644 src/features/auth/reset-password/schema/reset-password.schema.ts create mode 100644 src/features/auth/reset-password/ui/reset-password-form.tsx create mode 100644 src/features/auth/signin/model/useSignIn.ts create mode 100644 src/features/auth/signin/schema/signin.schema.ts create mode 100644 src/features/auth/signin/ui/CredentialsLoginForm.tsx create mode 100644 src/features/auth/signup/model/signup.action.ts create mode 100644 src/features/auth/signup/model/useSignUp.ts create mode 100644 src/features/auth/signup/schema/signup.schema.ts create mode 100644 src/features/auth/signup/ui/signup-form.tsx create mode 100644 src/features/auth/ui/AuthButtonServer.tsx create mode 100644 src/features/auth/ui/LoggedInButton.tsx create mode 100644 src/features/auth/ui/ProviderButton.tsx create mode 100644 src/features/auth/ui/SignInButton.tsx create mode 100644 src/features/auth/ui/SignUpButton.tsx create mode 100644 src/features/auth/verify-email/constants.ts create mode 100644 src/features/auth/verify-email/model/useResendEmail.ts create mode 100644 src/features/auth/verify-email/ui/verify-email-page.tsx create mode 100644 src/features/consent-banner/model/tracking-consent.action.ts create mode 100644 src/features/consent-banner/schema/tracking-consent.schema.ts create mode 100644 src/features/consent-banner/ui/consent-banner.tsx create mode 100644 src/features/contact-feedback/model/contact-feedback.action.ts create mode 100644 src/features/contact-feedback/model/contact-feedback.schema.ts create mode 100644 src/features/contact-feedback/ui/ReviewInput.tsx create mode 100644 src/features/contact-feedback/ui/contact-feedback-popover.tsx create mode 100644 src/features/contact/support/ContactSupportDialog.tsx create mode 100644 src/features/contact/support/contact-support.action.ts create mode 100644 src/features/contact/support/contact-support.schema.ts create mode 100644 src/features/dialogs-provider/DialogProvider.tsx create mode 100644 src/features/dialogs-provider/DialogProviderDialog.tsx create mode 100644 src/features/email/EmailForm.tsx create mode 100644 src/features/email/email.action.ts create mode 100644 src/features/email/email.schema.ts create mode 100644 src/features/form/SubmitButton.tsx create mode 100644 src/features/layout/BottomNavigation.tsx create mode 100644 src/features/layout/Footer.tsx create mode 100644 src/features/layout/Header.tsx create mode 100644 src/features/layout/model/use-sidebar.store.tsx create mode 100644 src/features/layout/nav-link.tsx create mode 100644 src/features/layout/page-heading.tsx create mode 100644 src/features/layout/useSidebarToggle.ts create mode 100644 src/features/layout/workout-streak-header.tsx create mode 100644 src/features/leaderboard/actions/get-top-workout-users.action.ts create mode 100644 src/features/leaderboard/actions/get-user-position.action.ts create mode 100644 src/features/leaderboard/hooks/use-top-workout-users.ts create mode 100644 src/features/leaderboard/hooks/use-user-position.ts create mode 100644 src/features/leaderboard/lib/utils.ts create mode 100644 src/features/leaderboard/models/types.ts create mode 100644 src/features/leaderboard/ui/leaderboard-item.tsx create mode 100644 src/features/leaderboard/ui/leaderboard-page.tsx create mode 100644 src/features/leaderboard/ui/leaderboard-skeleton.tsx create mode 100644 src/features/leaderboard/ui/user-leaderboard-position.tsx create mode 100644 src/features/page/layout.tsx create mode 100644 src/features/premium/ui/README.md create mode 100644 src/features/premium/ui/conversion-flow-notification.tsx create mode 100644 src/features/premium/ui/feature-comparison-table.tsx create mode 100644 src/features/premium/ui/index.ts create mode 100644 src/features/premium/ui/premium-upgrade-card.tsx create mode 100644 src/features/premium/ui/pricing-faq.tsx create mode 100644 src/features/premium/ui/pricing-hero-section.tsx create mode 100644 src/features/premium/ui/pricing-testimonials.tsx create mode 100644 src/features/profile/ui/body-weight-input.tsx create mode 100644 src/features/profile/ui/workout-equipment-input.tsx create mode 100644 src/features/programs/actions/complete-program-session.action.ts create mode 100644 src/features/programs/actions/enroll-program.action.ts create mode 100644 src/features/programs/actions/get-program-by-slug.action.ts create mode 100644 src/features/programs/actions/get-program-progress-by-slug.action.ts create mode 100644 src/features/programs/actions/get-program-progress.action.ts create mode 100644 src/features/programs/actions/get-public-programs.action.ts create mode 100644 src/features/programs/actions/get-session-by-slug.action.ts create mode 100644 src/features/programs/actions/get-sitemap-data.action.ts create mode 100644 src/features/programs/actions/start-program-session.action.ts create mode 100644 src/features/programs/hooks/use-program-share.ts create mode 100644 src/features/programs/lib/program-metadata.ts create mode 100644 src/features/programs/lib/session-metadata.ts create mode 100644 src/features/programs/lib/suggested-sets-helpers.ts create mode 100644 src/features/programs/lib/translations-mapper.ts create mode 100644 src/features/programs/ui/program-card.tsx create mode 100644 src/features/programs/ui/program-detail-page.tsx create mode 100644 src/features/programs/ui/program-progress.tsx create mode 100644 src/features/programs/ui/programs-page.tsx create mode 100644 src/features/programs/ui/session-access-guard.tsx create mode 100644 src/features/programs/ui/share-button.tsx create mode 100644 src/features/programs/ui/welcome-modal.tsx create mode 100644 src/features/release-notes/hooks/index.ts create mode 100644 src/features/release-notes/hooks/use-changelog-notification.ts create mode 100644 src/features/release-notes/index.ts create mode 100644 src/features/release-notes/lib/date-utils.ts create mode 100644 src/features/release-notes/model/changelog-notification.local.ts create mode 100644 src/features/release-notes/model/notes.ts create mode 100644 src/features/release-notes/types/notification.ts create mode 100644 src/features/release-notes/ui/changelog-notification-badge.tsx create mode 100644 src/features/release-notes/ui/index.ts create mode 100644 src/features/release-notes/ui/release-notes-dialog.tsx create mode 100644 src/features/sponsor/onboarding/SponsorOnboardingForm.tsx create mode 100644 src/features/sponsor/onboarding/sponsor-onboarding.action.ts create mode 100644 src/features/sponsor/onboarding/sponsor-onboarding.schema.ts create mode 100644 src/features/statistics/actions/get-overall-stats.action.ts create mode 100644 src/features/statistics/components/ExerciseSelection.tsx create mode 100644 src/features/statistics/components/ExerciseStatisticsTab.tsx create mode 100644 src/features/statistics/components/ExercisesBrowser.tsx create mode 100644 src/features/statistics/components/OneRepMaxChart.tsx create mode 100644 src/features/statistics/components/OverallStatsSummary.tsx create mode 100644 src/features/statistics/components/StatisticsPreviewOverlay.tsx create mode 100644 src/features/statistics/components/TimeframeSelector.tsx create mode 100644 src/features/statistics/components/VolumeChart.tsx create mode 100644 src/features/statistics/components/WeightProgressionChart.tsx create mode 100644 src/features/statistics/components/index.ts create mode 100644 src/features/statistics/hooks/use-chart-theme.ts create mode 100644 src/features/statistics/hooks/use-exercise-statistics.ts create mode 100644 src/features/statistics/types/index.ts create mode 100644 src/features/theme/ThemeProviders.tsx create mode 100644 src/features/theme/ThemeToggle.tsx create mode 100644 src/features/theme/ui/ThemeSynchronizer.tsx create mode 100644 src/features/training-science/actions/apply-weekly-adjustment.action.ts create mode 100644 src/features/training-science/actions/get-progression-aware-plan.action.ts create mode 100644 src/features/training-science/actions/get-quick-session.action.ts create mode 100644 src/features/training-science/actions/get-stretches.action.ts create mode 100644 src/features/training-science/actions/get-weekly-adjustment.action.ts create mode 100644 src/features/training-science/actions/training-plan.action.ts create mode 100644 src/features/training-science/model/__tests__/data-confidence.test.ts create mode 100644 src/features/training-science/model/__tests__/day-recommender.test.ts create mode 100644 src/features/training-science/model/__tests__/exercise-selection.test.ts create mode 100644 src/features/training-science/model/__tests__/progression-overlay.test.ts create mode 100644 src/features/training-science/model/__tests__/quick-session.test.ts create mode 100644 src/features/training-science/model/__tests__/session-finisher.test.ts create mode 100644 src/features/training-science/model/__tests__/set-rep-schemes.test.ts create mode 100644 src/features/training-science/model/__tests__/split-generator.test.ts create mode 100644 src/features/training-science/model/__tests__/user-intent.test.ts create mode 100644 src/features/training-science/model/__tests__/weekly-adjustment.test.ts create mode 100644 src/features/training-science/model/__tests__/weekly-volume.test.ts create mode 100644 src/features/training-science/model/data-confidence.ts create mode 100644 src/features/training-science/model/day-recommender.ts create mode 100644 src/features/training-science/model/exercise-selection.ts create mode 100644 src/features/training-science/model/progression-overlay.ts create mode 100644 src/features/training-science/model/quick-session.ts create mode 100644 src/features/training-science/model/session-finisher.ts create mode 100644 src/features/training-science/model/set-rep-schemes.ts create mode 100644 src/features/training-science/model/split-generator.ts create mode 100644 src/features/training-science/model/stretch-routine.ts create mode 100644 src/features/training-science/model/types.ts create mode 100644 src/features/training-science/model/user-intent.ts create mode 100644 src/features/training-science/model/weekly-adjustment.ts create mode 100644 src/features/training-science/model/weekly-volume.ts create mode 100644 src/features/training-science/ui/plan-page.tsx create mode 100644 src/features/training-science/ui/weekly-adjustment-card.tsx create mode 100644 src/features/update-password/lib/hash.ts create mode 100644 src/features/update-password/lib/validate-password.ts create mode 100644 src/features/update-password/model/update-password.action.ts create mode 100644 src/features/update-password/model/update-password.schema.ts create mode 100644 src/features/update-password/ui/password-form.tsx create mode 100644 src/features/user/ui/UserDropdown.tsx create mode 100644 src/features/workout-analytics/actions/get-acwr-data.action.ts create mode 100644 src/features/workout-analytics/actions/get-muscle-volume.action.ts create mode 100644 src/features/workout-analytics/actions/get-progressive-overload.action.ts create mode 100644 src/features/workout-analytics/actions/load-set-entries.ts create mode 100644 src/features/workout-analytics/components/ACWRGauge.tsx create mode 100644 src/features/workout-analytics/components/AnalyticsDashboard.tsx create mode 100644 src/features/workout-analytics/components/MuscleVolumeBars.tsx create mode 100644 src/features/workout-analytics/components/ProgressiveOverloadCard.tsx create mode 100644 src/features/workout-analytics/components/index.ts create mode 100644 src/features/workout-analytics/hooks/use-acwr.ts create mode 100644 src/features/workout-analytics/hooks/use-muscle-volume.ts create mode 100644 src/features/workout-analytics/hooks/use-progressive-overload.ts create mode 100644 src/features/workout-analytics/index.ts create mode 100644 src/features/workout-analytics/model/__tests__/calculate-acwr.test.ts create mode 100644 src/features/workout-analytics/model/__tests__/get-weekly-volume.test.ts create mode 100644 src/features/workout-analytics/model/__tests__/progressive-overload.test.ts create mode 100644 src/features/workout-analytics/model/calculate-acwr.ts create mode 100644 src/features/workout-analytics/model/date-utils.ts create mode 100644 src/features/workout-analytics/model/get-weekly-volume.ts create mode 100644 src/features/workout-analytics/model/index.ts create mode 100644 src/features/workout-analytics/model/progressive-overload.ts create mode 100644 src/features/workout-analytics/model/types.ts create mode 100644 src/features/workout-builder/actions/get-exercises-by-muscle.action.ts create mode 100644 src/features/workout-builder/actions/get-exercises.action.ts create mode 100644 src/features/workout-builder/actions/get-favorite-exercises.action.ts create mode 100644 src/features/workout-builder/actions/pick-exercise.action.ts create mode 100644 src/features/workout-builder/actions/shuffle-exercise.action.ts create mode 100644 src/features/workout-builder/actions/sync-favorite-exercises.action.ts create mode 100644 src/features/workout-builder/hooks/use-exercises.ts create mode 100644 src/features/workout-builder/hooks/use-favorite-exercises.service.ts create mode 100644 src/features/workout-builder/hooks/use-favorites-modal.ts create mode 100644 src/features/workout-builder/hooks/use-sync-favorite-exercises.ts create mode 100644 src/features/workout-builder/hooks/use-workout-session.ts create mode 100644 src/features/workout-builder/hooks/use-workout-stepper.ts create mode 100644 src/features/workout-builder/index.ts create mode 100644 src/features/workout-builder/model/equipment-config.ts create mode 100644 src/features/workout-builder/model/favorite-exercises-synchronizer.tsx create mode 100644 src/features/workout-builder/model/favorite-exercises.local.ts create mode 100644 src/features/workout-builder/model/workout-builder.store.ts create mode 100644 src/features/workout-builder/schema/get-exercises.schema.ts create mode 100644 src/features/workout-builder/types/index.ts create mode 100644 src/features/workout-builder/ui/add-exercise-modal.tsx create mode 100644 src/features/workout-builder/ui/equipment-selection.tsx create mode 100644 src/features/workout-builder/ui/exercise-card.tsx create mode 100644 src/features/workout-builder/ui/exercise-list-item.tsx create mode 100644 src/features/workout-builder/ui/exercise-pick-modal.tsx create mode 100644 src/features/workout-builder/ui/exercise-video-modal.tsx create mode 100644 src/features/workout-builder/ui/exercises-selection.tsx create mode 100644 src/features/workout-builder/ui/favorite-button.tsx create mode 100644 src/features/workout-builder/ui/favorite-exercise-button.tsx create mode 100644 src/features/workout-builder/ui/goal-split-selection.tsx create mode 100644 src/features/workout-builder/ui/muscle-selection.tsx create mode 100644 src/features/workout-builder/ui/muscles.module.css create mode 100644 src/features/workout-builder/ui/muscles/abdominals-group.tsx create mode 100644 src/features/workout-builder/ui/muscles/back-group.tsx create mode 100644 src/features/workout-builder/ui/muscles/biceps-group.tsx create mode 100644 src/features/workout-builder/ui/muscles/calves-group.tsx create mode 100644 src/features/workout-builder/ui/muscles/chest-group.tsx create mode 100644 src/features/workout-builder/ui/muscles/forearms-group.tsx create mode 100644 src/features/workout-builder/ui/muscles/glutes-group.tsx create mode 100644 src/features/workout-builder/ui/muscles/hamstrings-group.tsx create mode 100644 src/features/workout-builder/ui/muscles/obliques-group.tsx create mode 100644 src/features/workout-builder/ui/muscles/quadriceps-group.tsx create mode 100644 src/features/workout-builder/ui/muscles/shoulders-group.tsx create mode 100644 src/features/workout-builder/ui/muscles/traps-group.tsx create mode 100644 src/features/workout-builder/ui/muscles/triceps-group.tsx create mode 100644 src/features/workout-builder/ui/quick-plan-session-card.tsx create mode 100644 src/features/workout-builder/ui/quick-training-card.tsx create mode 100644 src/features/workout-builder/ui/quit-workout-dialog.tsx create mode 100644 src/features/workout-builder/ui/session-finisher-card.tsx create mode 100644 src/features/workout-builder/ui/stepper-header.tsx create mode 100644 src/features/workout-builder/ui/today-training-card.tsx create mode 100644 src/features/workout-builder/ui/workout-stepper-footer.tsx create mode 100644 src/features/workout-builder/ui/workout-stepper.tsx create mode 100644 src/features/workout-session/actions/delete-workout-session.action.ts create mode 100644 src/features/workout-session/actions/get-workout-sessions.action.ts create mode 100644 src/features/workout-session/actions/sync-workout-sessions.action.ts create mode 100644 src/features/workout-session/hooks/use-donation-modal.ts create mode 100644 src/features/workout-session/lib/workout-set-labels.ts create mode 100644 src/features/workout-session/model/use-sync-workout-sessions.ts create mode 100644 src/features/workout-session/model/use-workout-session.ts create mode 100644 src/features/workout-session/model/use-workout-sessions.ts create mode 100644 src/features/workout-session/model/workout-session.store.ts create mode 100644 src/features/workout-session/types/workout-set.ts create mode 100644 src/features/workout-session/ui/donation-modal.tsx create mode 100644 src/features/workout-session/ui/rest-timer.tsx create mode 100644 src/features/workout-session/ui/stretch-routine-section.tsx create mode 100644 src/features/workout-session/ui/workout-session-header.tsx create mode 100644 src/features/workout-session/ui/workout-session-heatmap.tsx create mode 100644 src/features/workout-session/ui/workout-session-list.tsx create mode 100644 src/features/workout-session/ui/workout-session-set.tsx create mode 100644 src/features/workout-session/ui/workout-session-sets.tsx create mode 100644 src/features/workout-session/ui/workout-session-timer.tsx create mode 100644 src/features/workout-session/ui/workout-sessions-synchronizer.tsx create mode 100644 src/index.d.ts create mode 100644 src/shared/api/README.md create mode 100644 src/shared/api/createHandler.ts create mode 100644 src/shared/api/handlers.ts create mode 100644 src/shared/api/mobile-auth.ts create mode 100644 src/shared/api/mobile-cookie-utils.ts create mode 100644 src/shared/api/mobile-safe-actions.ts create mode 100644 src/shared/api/safe-actions.ts create mode 100644 src/shared/config/localized-metadata.ts create mode 100644 src/shared/config/site-config.ts create mode 100644 src/shared/constants/cookies.ts create mode 100644 src/shared/constants/errors.ts create mode 100644 src/shared/constants/paths.ts create mode 100644 src/shared/constants/placeholders.ts create mode 100644 src/shared/constants/regexs.ts create mode 100644 src/shared/constants/screen.ts create mode 100644 src/shared/constants/social-platforms.tsx create mode 100644 src/shared/constants/statistics.ts create mode 100644 src/shared/constants/success.ts create mode 100644 src/shared/constants/workout-set-types.ts create mode 100644 src/shared/hooks/use-clipboard.ts create mode 100644 src/shared/hooks/use-drag-feedback.ts create mode 100644 src/shared/hooks/use-premium-plans.ts create mode 100644 src/shared/hooks/use-workout-feedback.ts create mode 100644 src/shared/hooks/useBoolean.ts create mode 100644 src/shared/hooks/useIsMobile.ts create mode 100644 src/shared/hooks/useScrollToTop.ts create mode 100644 src/shared/lib/access-control.ts create mode 100644 src/shared/lib/analytics/client.tsx create mode 100644 src/shared/lib/analytics/events.ts create mode 100644 src/shared/lib/analytics/server.ts create mode 100644 src/shared/lib/attribute-value-translation.ts create mode 100644 src/shared/lib/audio-feedback.ts create mode 100644 src/shared/lib/date.ts create mode 100644 src/shared/lib/format.ts create mode 100644 src/shared/lib/guards.ts create mode 100644 src/shared/lib/i18n-mapper.ts create mode 100644 src/shared/lib/locale-slug.ts create mode 100644 src/shared/lib/location/eu-countries.ts create mode 100644 src/shared/lib/location/location.ts create mode 100644 src/shared/lib/logger.ts create mode 100644 src/shared/lib/mail/sendEmail.ts create mode 100644 src/shared/lib/mdx/load-mdx.ts create mode 100644 src/shared/lib/network/use-network-status.ts create mode 100644 src/shared/lib/premium/premium.manager.ts create mode 100644 src/shared/lib/premium/premium.service.ts create mode 100644 src/shared/lib/premium/providers/base-provider.ts create mode 100644 src/shared/lib/premium/providers/stripe-provider.ts create mode 100644 src/shared/lib/premium/use-pending-checkout.ts create mode 100644 src/shared/lib/premium/use-premium-redirect.ts create mode 100644 src/shared/lib/premium/use-premium.ts create mode 100644 src/shared/lib/prisma.ts create mode 100644 src/shared/lib/revenuecat/index.ts create mode 100644 src/shared/lib/revenuecat/revenuecat.api.ts create mode 100644 src/shared/lib/revenuecat/revenuecat.config.ts create mode 100644 src/shared/lib/revenuecat/revenuecat.mapping.ts create mode 100644 src/shared/lib/server-url.ts create mode 100644 src/shared/lib/slug.ts create mode 100644 src/shared/lib/structured-data.ts create mode 100644 src/shared/lib/user-preferences.ts create mode 100644 src/shared/lib/utils.ts create mode 100644 src/shared/lib/version.ts create mode 100644 src/shared/lib/web-share.ts create mode 100644 src/shared/lib/weight-conversion.ts create mode 100644 src/shared/lib/workout-session/equipments.ts create mode 100644 src/shared/lib/workout-session/quick-training.local.ts create mode 100644 src/shared/lib/workout-session/types/workout-session.ts create mode 100644 src/shared/lib/workout-session/use-workout-session.service.ts create mode 100644 src/shared/lib/workout-session/workout-session.api.ts create mode 100644 src/shared/lib/workout-session/workout-session.local.ts create mode 100644 src/shared/lib/youtube.ts create mode 100644 src/shared/schemas/url.ts create mode 100644 src/shared/styles/additional-styles/highlights.css create mode 100644 src/shared/styles/additional-styles/utility-patterns.css create mode 100644 src/shared/styles/globals.css create mode 100644 src/shared/types/i18n.types.ts create mode 100644 src/shared/types/next.ts create mode 100644 src/shared/types/premium.types.ts create mode 100644 src/shared/types/statistics.types.ts create mode 100644 src/shared/types/storage.ts create mode 100644 src/widgets/404.tsx create mode 100644 src/widgets/language-selector/language-selector.tsx create mode 100644 tsconfig.json create mode 100644 vitest.config.ts create mode 100644 workout-cool.code-workspace diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 00000000..9248ef69 --- /dev/null +++ b/.cursorrules @@ -0,0 +1,165 @@ +## 🧑‍💻 Development Guidelines + +This project follows **Next.js (App Router)** and is structured using **Feature-Sliced Design (FSD)** for modularity, scalability, and clear +separation of concerns. + +Use this prompt and coding standards to ensure consistency across the codebase: + +--- + +### 🔧 Code Style and Structure + +- Write concise, expressive, and idiomatic **TypeScript** +- Use **functional programming** patterns (avoid classes and side effects) +- Prefer **composition** over inheritance, and modularization over duplication +- Organize each `feature/`, `entity/`, or `widget/` with: + + - model/ → logic (React Query, actions, hooks) + - schema/ → Zod schemas for validation ui/ → client components (TSX) + - lib/ → pure helper functions + - types/ → interfaces & TS types + +- All external dependencies (**API**, `localStorage`, `Date`) must be **abstracted** in `shared/lib/` +- Avoid direct calls to: +- `fetch` → use actions or `shared/api/` +- `new Date()` → use `shared/lib/date` abstraction +- `localStorage` → wrap in `shared/lib/storage` + +--- + +### 🧠 Naming Conventions + +- Use `kebab-case` for **directories** (e.g. `features/auth/signup`) +- Use **named exports** (no default exports for components) +- Use descriptive names with **auxiliary verbs** (e.g. `isLoading`, `hasError`, `canSubmit`) +- Components: +- Pure UI: `src/components/ui/` +- Shared logic: `src/shared/lib/` +- Composition: `src/widgets/` + +--- + +### 📐 TypeScript Usage + +- Use `interface` over `type` for objects +- Avoid `enum`; use `as const` object maps instead +- Use `infer` and `z.infer` for accurate form types +- Types live in `types/` or colocated with usage + +--- + +### 📦 Feature Architecture + +**Keep React component logic inside the relevant feature:** + +features/auth/signup/ ├── model/ → useSignUp.ts, signup.action.ts ├── schema/ → signup.schema.ts ├── ui/ → signup-form.tsx + +If reusable between many features (e.g. `User`, `Link`, `Session`), move logic to `entities/`. + +--- + +### 🧪 Error Handling & Validation + +- Use **Zod** for schema validation +- Prefer early returns & guard clauses +- Use `ActionError` in server actions and handle them with `next-safe-action` +- Wrap React components in `ErrorBoundary` (or `shared/ui/ErrorBoundaries.tsx`) +- Display user-friendly errors via `toast()` or `` + +--- + +### 💅 UI & Styling + +- Use **Shadcn UI**, **Radix**, and **Tailwind CSS** with **mobile-first** responsive design +- Design theme: + + - **Minimal**, professional with a **slightly playful touch** + - Inspired by **Apple**, tailored to fitness coaches + - Emphasize visuals: badges, progress bars, illustrations + - Use `lucide-react` icons, subtle borders, hover feedback + - Avoid drop shadows; prefer light borders and soft hover effects + +- Animations: + + - Elegant and performant (use `framer-motion` if needed) + - Use `transition`, `duration-xxx`, and `ease-xxx` from Tailwind + +- UX Principles: + + - Clear hierarchy + - Responsive: no overflow, no overlap + - All buttons and interactive elements should provide feedback + - Use @tailwind.config.ts for the theme. + +- **UI Stack**: + + - **Shadcn UI**, **Radix UI**, and **Tailwind CSS** (mobile-first approach) + - Icons: **lucide-react** + +- **Design Language**: + + - 🎨 **Modern & minimalist**, inspired by **Apple’s design system**, with a **slightly more colorful palette** + - Interface should be **clean**, **cohesive**, and **functional** without sacrificing features + - Avoid drop shadows; prefer **subtle borders** where relevant + - Ensure a **clear visual hierarchy** and **intuitive navigation** + +- **Interactive Components**: + + - Buttons and inputs must be **elegant**, with **subtle visual feedback** (hover, click, validation) + - Use **addictive micro-interactions** sparingly to enhance engagement without clutter + +- **Animations**: + + - Use Tailwind’s built-in utilities: `transition`, `duration-xxx`, `ease-xxx` for basic transitions + - Use `framer-motion` for advanced animations only if necessary + - ✅ **Performance comes first**: animations must be smooth and lightweight + +- **Responsiveness**: + + - Fully responsive layout: **no overlapping**, **no overflow** + - Consistent behavior across all devices, from mobile to desktop + +- **User Experience**: + - All interactive elements must provide **clear visual feedback** + - Interfaces should remain **simple to navigate**, even when **feature-rich** + +--- + +### 🧱 Rendering & Performance + +- Favor **Server Components** (`RSC`) and SSR for pages and logic +- Limit `'use client'` usage — only where needed: + - form states, event listeners, animations +- Wrap all client components in `` with fallback +- Use dynamic import for non-critical UI (e.g. `Dialog`, `Chart`) +- Optimize media: + - Use **WebP** images with width/height + - Enable lazy loading where possible + +--- + +### 🔍 Data, Forms, Actions + +- Use `@tanstack/react-query` for client state +- Use `next-safe-action` for server mutations and queries +- All actions should: + - Have clear schema (`schema/`) + - Model expected errors with `ActionError` + - Return typed output + - Use the clientAction from `@/shared/api/safe-actions` +- Use `Form`, `FormField`, `FormMessage` from Shadcn for all forms + +--- + +### 🧭 Routing & Navigation + +- All routes defined in `app/`, avoid logic here +- Use constants in `shared/constants/paths.ts` +- For search parameters, use `nuqs` (`useQueryState`) — never manipulate `router.query` directly +- Follow Next.js App Router standards for layouts and segments + +--- + +- [Feature-Sliced Design](https://feature-sliced.design/) +- [Shadcn UI](https://ui.shadcn.com/) +- [Zod](https://zod.dev/) diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..9df7f427 --- /dev/null +++ b/.env.example @@ -0,0 +1,77 @@ +# Database Configuration +POSTGRES_USER=username +POSTGRES_PASSWORD=password +POSTGRES_DB=workout-cool +DB_HOST=localhost +DB_PORT=5432 +# Format: postgresql://USER:PASSWORD@HOST:PORT/DATABASE +DATABASE_URL=postgresql://username:password@localhost:5432/workout-cool + +# Authentication +# The URL where your application is running +BETTER_AUTH_URL=http://localhost:3000 +# Generate a secure random string using: openssl rand -base64 32 +BETTER_AUTH_SECRET=your-secret-key-here + +# Google OAuth +# Get these from Google Cloud Console: https://console.cloud.google.com +# Required scopes: email, profile +GOOGLE_CLIENT_ID="your-google-client-id.apps.googleusercontent.com" +GOOGLE_CLIENT_SECRET="your-google-client-secret" + +# OpenPanel Integration +# Get these from your OpenPanel dashboard +OPENPANEL_SECRET_KEY= +NEXT_PUBLIC_OPENPANEL_CLIENT_ID= + +# Environment +# Options: development, production, test +NODE_ENV=development + +#SMTP Configuration +# Using MailHog for example. https://github.com/mailhog/MailHog +SMTP_HOST=localhost +SMTP_PORT=1025 +SMTP_USER= +SMTP_PASS= +SMTP_FROM="Workout Cool " +SMTP_SECURE=false + +# Whether to seed sample data on startup +SEED_SAMPLE_DATA=true + +# ======================================== +# BILLING CONFIGURATION +# ======================================== + +# Stripe Configuration (optional) +# Get these from https://dashboard.stripe.com +STRIPE_SECRET_KEY="sk_test_..." +STRIPE_WEBHOOK_SECRET="whsec_..." +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_test_..." + +# Stripe Price IDs +# Create products in Stripe Dashboard and add price IDs here +NEXT_PUBLIC_STRIPE_PRICE_MONTHLY_EU="price_..." +NEXT_PUBLIC_STRIPE_PRICE_YEARLY_EU="price_..." +NEXT_PUBLIC_STRIPE_PRICE_MONTHLY_US="price_..." +NEXT_PUBLIC_STRIPE_PRICE_YEARLY_US="price_..." +NEXT_PUBLIC_STRIPE_PRICE_MONTHLY_LATAM="price_..." +NEXT_PUBLIC_STRIPE_PRICE_YEARLY_LATAM="price_..." +NEXT_PUBLIC_STRIPE_PRICE_MONTHLY_BR="price_..." +NEXT_PUBLIC_STRIPE_PRICE_YEARLY_BR="price_..." +NEXT_PUBLIC_STRIPE_PRICE_MONTHLY_RU="price_..." +NEXT_PUBLIC_STRIPE_PRICE_YEARLY_RU="price_..." +NEXT_PUBLIC_STRIPE_PRICE_MONTHLY_CN="price_..." +NEXT_PUBLIC_STRIPE_PRICE_YEARLY_CN="price_..." + +# RevenueCat Configuration (for mobile app integration) +REVENUECAT_API_KEY="" +REVENUECAT_WEBHOOK_SECRET="" +REVENUECAT_SECRET_KEY="test_secret_key" + +# Billing Mode for self-hosted +# Options: DISABLED, LICENSE_KEY, SUBSCRIPTION, FREEMIUM +DEFAULT_BILLING_MODE="DISABLED" + +NEXT_PUBLIC_APP_URL=http://localhost:3000 diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..6d720305 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,3 @@ +github: snouzy +ko_fi: workoutcool +# buy_me_a_coffee: workout_cool diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..0cf44f31 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,35 @@ +--- +name: Bug report +about: Create a report to help us improve +title: "" +labels: "" +assignees: "" +--- + +**Describe the bug** A clear and concise description of what the bug is. + +**To Reproduce** Steps to reproduce the behavior: + +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** A clear and concise description of what you expected to happen. + +**Screenshots** If applicable, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** + +- OS: [e.g. iOS] +- Browser [e.g. chrome, safari] +- Version [e.g. 22] + +**Smartphone (please complete the following information):** + +- Device: [e.g. iPhone6] +- OS: [e.g. iOS8.1] +- Browser [e.g. stock browser, safari] +- Version [e.g. 22] + +**Additional context** Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..a1986f74 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: 💬 Workout Cool Discord + url: https://discord.gg/NtrsUBuHUB + about: Please use our Discord server for all questions, discussions, and support. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..31811d2e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,16 @@ +--- +name: Feature request +about: Suggest a feature for this project +title: "" +labels: "" +assignees: "" +--- + +**Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always +frustrated when [...] + +**Describe the solution you'd like** A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** Add any other context or screenshots about the feature request here. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..e50bca03 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,22 @@ +## 📝 Description + + + +## 📋 Checklist + +- [ ] My code follows the project conventions +- [ ] This PR includes breaking changes +- [ ] I have updated documentation if necessary + +## 🗃️ Prisma Migrations (if applicable) + +- [ ] I have created a migration +- [ ] I have tested the migration locally + +## 📸 Screenshots (if applicable) + + + +## 🔗 Related Issues + + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..6316d6a2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,148 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Get pnpm store directory + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: Setup pnpm cache + uses: actions/cache@v4 + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Generate Prisma client + run: pnpm prisma generate + + - name: Run linting + run: pnpm lint + + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Get pnpm store directory + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: Setup pnpm cache + uses: actions/cache@v4 + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Generate Prisma client + run: pnpm prisma generate + + - name: Build project + run: pnpm build + env: + BETTER_AUTH_URL: http://localhost:3000 + DATABASE_URL: postgresql://user:password@localhost:5432/test_db + GOOGLE_CLIENT_ID: test_client_id + GOOGLE_CLIENT_SECRET: test_client_secret + RESEND_API_KEY: re_test_key + BETTER_AUTH_SECRET: test_secret_key_32_chars_minimum + OPENPANEL_SECRET_KEY: test_secret + NEXT_PUBLIC_OPENPANEL_CLIENT_ID: test_client_id + NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: test_client_id + NEXT_PUBLIC_STRIPE_PRICE_MONTHLY_EU: test_price_monthly + NEXT_PUBLIC_STRIPE_PRICE_YEARLY_EU: test_price_yearly + NEXT_PUBLIC_STRIPE_PRICE_MONTHLY_US: test_price_monthly + NEXT_PUBLIC_STRIPE_PRICE_YEARLY_US: test_price_yearly + NEXT_PUBLIC_STRIPE_PRICE_MONTHLY_LATAM: test_price_monthly + NEXT_PUBLIC_STRIPE_PRICE_YEARLY_LATAM: test_price_yearly + NEXT_PUBLIC_STRIPE_PRICE_MONTHLY_BR: test_price_monthly + NEXT_PUBLIC_STRIPE_PRICE_YEARLY_BR: test_price_yearly + NEXT_PUBLIC_STRIPE_PRICE_MONTHLY_RU: test_price_monthly + NEXT_PUBLIC_STRIPE_PRICE_YEARLY_RU: test_price_yearly + NEXT_PUBLIC_STRIPE_PRICE_MONTHLY_CN: test_price_monthly + NEXT_PUBLIC_STRIPE_PRICE_YEARLY_CN: test_price_yearly + NEXT_PUBLIC_APP_URL: http://localhost:3000 + STRIPE_SECRET_KEY: test_secret_key + REVENUECAT_SECRET_KEY: test_secret_key + REVENUECAT_WEBHOOK_SECRET: test_webhook_secret + STRIPE_WEBHOOK_SECRET: test_webhook_secret + NEXT_PUBLIC_SHOW_ADS: false + NEXT_PUBLIC_AD_CLIENT: test_client_id + NEXT_PUBLIC_VERTICAL_LEFT_BANNER_AD_SLOT: 1234567890 + NEXT_PUBLIC_VERTICAL_RIGHT_BANNER_AD_SLOT: 1234567890 + NEXT_PUBLIC_EQUIPMENT_SELECTION_BANNER_AD_SLOT: 1234567890 + NEXT_PUBLIC_EXERCISE_SELECTION_BANNER_AD_SLOT: 1234567890 + NEXT_PUBLIC_MUSCLE_SELECTION_BANNER_AD_SLOT: 1234567890 + NEXT_PUBLIC_TOP_WORKOUT_SESSION_BANNER_AD_SLOT: 1234567890 + NEXT_PUBLIC_BOTTOM_WORKOUT_SESSION_BANNER_AD_SLOT: 1234567890 + NEXT_PUBLIC_TOP_STEPPER_STEP_1_BANNER_AD_SLOT: 1234567890 + NEXT_PUBLIC_TOP_STEPPER_STEP_2_BANNER_AD_SLOT: 1234567890 + NEXT_PUBLIC_TOP_STEPPER_STEP_3_BANNER_AD_SLOT: 1234567890 + NEXT_PUBLIC_TOP_PROGRAMS_BANNER_AD_SLOT: 1234567890 + NEXT_PUBLIC_BOTTOM_PROGRAMS_BANNER_AD_SLOT: 1234567890 + NEXT_PUBLIC_TOP_TOOLS_BANNER_AD_SLOT: 1234567890 + NEXT_PUBLIC_BOTTOM_TOOLS_BANNER_AD_SLOT: 1234567890 + NEXT_PUBLIC_TOP_CALCULATOR_HUB_BANNER_AD_SLOT: 1234567890 + NEXT_PUBLIC_BOTTOM_CALCULATOR_HUB_BANNER_AD_SLOT: 1234567890 + NEXT_PUBLIC_TOP_PROGRAM_DETAILS_BANNER_AD_SLOT: 1234567890 + NEXT_PUBLIC_BOTTOM_PROGRAM_DETAILS_BANNER_AD_SLOT: 1234567890 + NEXT_PUBLIC_TOP_PROFILE_BANNER_AD_SLOT: 1234567890 + NEXT_PUBLIC_IN_ARTICLE_BMI_1_AD_SLOT: 1234567890 + NEXT_PUBLIC_IN_ARTICLE_BMI_2_AD_SLOT: 1234567890 + NEXT_PUBLIC_TOP_BMI_BANNER_AD_SLOT: 1234567890 + NEXT_PUBLIC_BOTTOM_BMI_BANNER_AD_SLOT: 1234567890 + NEXT_PUBLIC_TOP_HEART_ZONES_BANNER_AD_SLOT: 1234567890 + NEXT_PUBLIC_BOTTOM_HEART_ZONES_BANNER_AD_SLOT: 1234567890 + NEXT_PUBLIC_IN_ARTICLE_HEART_ZONES_AD_SLOT_1: 1234567890 + NEXT_PUBLIC_IN_ARTICLE_HEART_ZONES_AD_SLOT_2: 1234567890 + NEXT_PUBLIC_IN_ARTICLE_HEART_ZONES_AD_SLOT_3: 1234567890 + NEXT_PUBLIC_TOP_MIFFLIN_ST_JEOR_CALCULATOR_AD_SLOT: 1234567890 + NEXT_PUBLIC_BOTTOM_CALORIE_CALCULATOR_AD_SLOT: 1234567890 + NEXT_PUBLIC_TOP_OXFORD_CALCULATOR_AD_SLOT: 1234567890 + NEXT_PUBLIC_BOTTOM_OXFORD_CALCULATOR_AD_SLOT: 1234567890 + NEXT_PUBLIC_TOP_HARRIS_BENEDICT_CALCULATOR_AD_SLOT: 1234567890 + NEXT_PUBLIC_TOP_KATCH_MCARDLE_CALCULATOR_AD_SLOT: 1234567890 + NEXT_PUBLIC_TOP_CUNNINGHAM_CALCULATOR_AD_SLOT: 1234567890 + NEXT_PUBLIC_TOP_CALORIE_CALCULATOR_COMPARISON_AD_SLOT: 1234567890 + NEXT_PUBLIC_BOTTOM_CALORIE_CALCULATOR_COMPARISON_AD_SLOT: 1234567890 diff --git a/.github/workflows/notify-discord-issues.yml b/.github/workflows/notify-discord-issues.yml new file mode 100644 index 00000000..5f055ecf --- /dev/null +++ b/.github/workflows/notify-discord-issues.yml @@ -0,0 +1,157 @@ +name: Discord Issue Notification + +on: + issues: + types: [opened, reopened, closed] + workflow_dispatch: + inputs: + issue_number: + description: "Issue number" + required: true + type: string + +jobs: + Discord: + runs-on: ubuntu-latest + name: Discord Issue Notifier + steps: + - uses: actions/checkout@v4 + if: github.event_name == 'workflow_dispatch' + + - name: Get issue info for manual trigger + id: issue-info + if: github.event_name == 'workflow_dispatch' + run: | + ISSUE_INFO=$(gh issue view ${{ github.event.inputs.issue_number }} --json number,title,url,author,state,labels,createdAt) + echo "number=$(echo "$ISSUE_INFO" | jq -r '.number')" >> $GITHUB_OUTPUT + echo "title=$(echo "$ISSUE_INFO" | jq -r '.title')" >> $GITHUB_OUTPUT + echo "html_url=$(echo "$ISSUE_INFO" | jq -r '.url')" >> $GITHUB_OUTPUT + echo "author_login=$(echo "$ISSUE_INFO" | jq -r '.author.login')" >> $GITHUB_OUTPUT + echo "author_html_url=https://github.com/$(echo "$ISSUE_INFO" | jq -r '.author.login')" >> $GITHUB_OUTPUT + echo "state=$(echo "$ISSUE_INFO" | jq -r '.state')" >> $GITHUB_OUTPUT + echo "created_at=$(echo "$ISSUE_INFO" | jq -r '.createdAt')" >> $GITHUB_OUTPUT + echo "labels=$(echo "$ISSUE_INFO" | jq -r '.labels | map(.name) | join(", ") // "None"')" >> $GITHUB_OUTPUT + env: + GH_TOKEN: ${{ github.token }} + + - name: Determine action color and emoji + id: action-info + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + # For manual trigger, use the current state + case "${{ steps.issue-info.outputs.state }}" in + "OPEN") + echo "color=15158332" >> $GITHUB_OUTPUT # Red + echo "emoji=🔴" >> $GITHUB_OUTPUT + echo "action_text=Open" >> $GITHUB_OUTPUT + ;; + "CLOSED") + echo "color=5763719" >> $GITHUB_OUTPUT # Green + echo "emoji=🟢" >> $GITHUB_OUTPUT + echo "action_text=Closed" >> $GITHUB_OUTPUT + ;; + esac + else + # For automatic trigger, use the action + case "${{ github.event.action }}" in + "opened") + echo "color=15158332" >> $GITHUB_OUTPUT # Red + echo "emoji=🔴" >> $GITHUB_OUTPUT + echo "action_text=Opened" >> $GITHUB_OUTPUT + ;; + "reopened") + echo "color=16776960" >> $GITHUB_OUTPUT # Yellow + echo "emoji=🟡" >> $GITHUB_OUTPUT + echo "action_text=Reopened" >> $GITHUB_OUTPUT + ;; + "closed") + echo "color=5763719" >> $GITHUB_OUTPUT # Green + echo "emoji=🟢" >> $GITHUB_OUTPUT + echo "action_text=Closed" >> $GITHUB_OUTPUT + ;; + esac + fi + + - name: Create Discord webhook payload + run: | + # Determine data source based on trigger type + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + ISSUE_NUMBER="${{ steps.issue-info.outputs.number }}" + ISSUE_TITLE="${{ steps.issue-info.outputs.title }}" + ISSUE_URL="${{ steps.issue-info.outputs.html_url }}" + AUTHOR_LOGIN="${{ steps.issue-info.outputs.author_login }}" + AUTHOR_URL="${{ steps.issue-info.outputs.author_html_url }}" + ISSUE_STATE="${{ steps.issue-info.outputs.state }}" + ISSUE_LABELS="${{ steps.issue-info.outputs.labels }}" + CREATED_AT="${{ steps.issue-info.outputs.created_at }}" + else + ISSUE_NUMBER="${{ github.event.issue.number }}" + ISSUE_TITLE="${{ github.event.issue.title }}" + ISSUE_URL="${{ github.event.issue.html_url }}" + AUTHOR_LOGIN="${{ github.event.issue.user.login }}" + AUTHOR_URL="${{ github.event.issue.user.html_url }}" + ISSUE_STATE="${{ github.event.issue.state }}" + ISSUE_LABELS="${{ github.event.issue.labels[0].name && join(github.event.issue.labels.*.name, ', ') || 'None' }}" + CREATED_AT="${{ github.event.issue.created_at }}" + fi + + # Create a temporary JSON file + cat > discord_payload.json << EOF + { + "avatar_url": "https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png", + "embeds": [ + { + "title": "${{ steps.action-info.outputs.emoji }} Issue ${{ steps.action-info.outputs.action_text }}: #${ISSUE_NUMBER}", + "description": "${ISSUE_TITLE}", + "url": "${ISSUE_URL}", + "color": ${{ steps.action-info.outputs.color }}, + "thumbnail": { + "url": "https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png" + }, + "fields": [ + { + "name": "📋 Issue #", + "value": "\`#${ISSUE_NUMBER}\`", + "inline": true + }, + { + "name": "👤 Author", + "value": "[${AUTHOR_LOGIN}](${AUTHOR_URL})", + "inline": true + }, + { + "name": "📁 Repository", + "value": "[${{ github.event.repository.name }}](${{ github.event.repository.html_url }})", + "inline": true + }, + { + "name": "🏷️ Labels", + "value": "${ISSUE_LABELS}", + "inline": true + }, + { + "name": "📊 State", + "value": "\`${ISSUE_STATE}\`", + "inline": true + }, + { + "name": "🔗 View Issue", + "value": "[Issue Page](${ISSUE_URL})", + "inline": true + } + ], + "timestamp": "${CREATED_AT}", + "footer": { + "text": "Workout Cool • Issue ${{ steps.action-info.outputs.action_text }}", + "icon_url": "https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png" + } + } + ] + } + EOF + + - name: Send Discord notification + run: | + curl -H "Content-Type: application/json" \ + -d @discord_payload.json \ + "${{ secrets.DISCORD_ISSUES_WEBHOOK }}" diff --git a/.github/workflows/notify-discord-pr.yml b/.github/workflows/notify-discord-pr.yml new file mode 100644 index 00000000..cbf6c828 --- /dev/null +++ b/.github/workflows/notify-discord-pr.yml @@ -0,0 +1,220 @@ +name: Discord PR Notification + +on: + pull_request: + types: [opened] + workflow_dispatch: + inputs: + pr_number: + description: "Pull Request number" + required: true + type: string + +jobs: + Discord: + runs-on: ubuntu-latest + name: Discord PR Notifier + if: github.event.pull_request.head.repo.full_name == github.repository + steps: + - uses: actions/checkout@v4 + if: github.event_name == 'workflow_dispatch' + + - name: Get PR info for manual trigger + id: pr-info + if: github.event_name == 'workflow_dispatch' + run: | + PR_INFO=$(gh pr view ${{ github.event.inputs.pr_number }} --json number,title,url,author,state,labels,createdAt,headRefName,baseRefName,isDraft,mergeable) + echo "number=$(echo "$PR_INFO" | jq -r '.number')" >> $GITHUB_OUTPUT + echo "title=$(echo "$PR_INFO" | jq -r '.title')" >> $GITHUB_OUTPUT + echo "html_url=$(echo "$PR_INFO" | jq -r '.url')" >> $GITHUB_OUTPUT + echo "author_login=$(echo "$PR_INFO" | jq -r '.author.login')" >> $GITHUB_OUTPUT + echo "author_html_url=https://github.com/$(echo "$PR_INFO" | jq -r '.author.login')" >> $GITHUB_OUTPUT + echo "state=$(echo "$PR_INFO" | jq -r '.state')" >> $GITHUB_OUTPUT + echo "created_at=$(echo "$PR_INFO" | jq -r '.createdAt')" >> $GITHUB_OUTPUT + echo "labels=$(echo "$PR_INFO" | jq -r '.labels | map(.name) | join(", ") // "None"')" >> $GITHUB_OUTPUT + echo "head_ref=$(echo "$PR_INFO" | jq -r '.headRefName')" >> $GITHUB_OUTPUT + echo "base_ref=$(echo "$PR_INFO" | jq -r '.baseRefName')" >> $GITHUB_OUTPUT + echo "is_draft=$(echo "$PR_INFO" | jq -r '.isDraft')" >> $GITHUB_OUTPUT + echo "mergeable=$(echo "$PR_INFO" | jq -r '.mergeable // "UNKNOWN"')" >> $GITHUB_OUTPUT + env: + GH_TOKEN: ${{ github.token }} + + - name: Determine action color and emoji + id: action-info + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + # For manual trigger, use the current state + case "${{ steps.pr-info.outputs.state }}" in + "OPEN") + if [ "${{ steps.pr-info.outputs.is_draft }}" = "true" ]; then + echo "color=8421504" >> $GITHUB_OUTPUT # Gray + echo "emoji=📝" >> $GITHUB_OUTPUT + echo "action_text=Draft" >> $GITHUB_OUTPUT + else + echo "color=5763719" >> $GITHUB_OUTPUT # Green + echo "emoji=🔄" >> $GITHUB_OUTPUT + echo "action_text=Open" >> $GITHUB_OUTPUT + fi + ;; + "CLOSED") + echo "color=15158332" >> $GITHUB_OUTPUT # Red + echo "emoji=❌" >> $GITHUB_OUTPUT + echo "action_text=Closed" >> $GITHUB_OUTPUT + ;; + "MERGED") + echo "color=6559689" >> $GITHUB_OUTPUT # Purple + echo "emoji=🎉" >> $GITHUB_OUTPUT + echo "action_text=Merged" >> $GITHUB_OUTPUT + ;; + esac + else + # For automatic trigger, use the action + case "${{ github.event.action }}" in + "opened") + echo "color=5763719" >> $GITHUB_OUTPUT # Green + echo "emoji=🔄" >> $GITHUB_OUTPUT + echo "action_text=Opened" >> $GITHUB_OUTPUT + ;; + "reopened") + echo "color=16776960" >> $GITHUB_OUTPUT # Yellow + echo "emoji=🔄" >> $GITHUB_OUTPUT + echo "action_text=Reopened" >> $GITHUB_OUTPUT + ;; + "closed") + if [ "${{ github.event.pull_request.merged }}" = "true" ]; then + echo "color=6559689" >> $GITHUB_OUTPUT # Purple + echo "emoji=🎉" >> $GITHUB_OUTPUT + echo "action_text=Merged" >> $GITHUB_OUTPUT + else + echo "color=15158332" >> $GITHUB_OUTPUT # Red + echo "emoji=❌" >> $GITHUB_OUTPUT + echo "action_text=Closed" >> $GITHUB_OUTPUT + fi + ;; + "ready_for_review") + echo "color=5763719" >> $GITHUB_OUTPUT # Green + echo "emoji=👀" >> $GITHUB_OUTPUT + echo "action_text=Ready for Review" >> $GITHUB_OUTPUT + ;; + "converted_to_draft") + echo "color=8421504" >> $GITHUB_OUTPUT # Gray + echo "emoji=📝" >> $GITHUB_OUTPUT + echo "action_text=Converted to Draft" >> $GITHUB_OUTPUT + ;; + esac + fi + + - name: Create Discord webhook payload + run: | + # Determine data source based on trigger type + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + PR_NUMBER="${{ steps.pr-info.outputs.number }}" + PR_TITLE="${{ steps.pr-info.outputs.title }}" + PR_URL="${{ steps.pr-info.outputs.html_url }}" + AUTHOR_LOGIN="${{ steps.pr-info.outputs.author_login }}" + AUTHOR_URL="${{ steps.pr-info.outputs.author_html_url }}" + PR_STATE="${{ steps.pr-info.outputs.state }}" + PR_LABELS="${{ steps.pr-info.outputs.labels }}" + CREATED_AT="${{ steps.pr-info.outputs.created_at }}" + HEAD_REF="${{ steps.pr-info.outputs.head_ref }}" + BASE_REF="${{ steps.pr-info.outputs.base_ref }}" + IS_DRAFT="${{ steps.pr-info.outputs.is_draft }}" + else + PR_NUMBER="${{ github.event.pull_request.number }}" + PR_TITLE="${{ github.event.pull_request.title }}" + PR_URL="${{ github.event.pull_request.html_url }}" + AUTHOR_LOGIN="${{ github.event.pull_request.user.login }}" + AUTHOR_URL="${{ github.event.pull_request.user.html_url }}" + PR_STATE="${{ github.event.pull_request.state }}" + PR_LABELS="${{ github.event.pull_request.labels[0].name && join(github.event.pull_request.labels.*.name, ', ') || 'None' }}" + CREATED_AT="${{ github.event.pull_request.created_at }}" + HEAD_REF="${{ github.event.pull_request.head.ref }}" + BASE_REF="${{ github.event.pull_request.base.ref }}" + IS_DRAFT="${{ github.event.pull_request.draft }}" + fi + + # Escape special characters for JSON + PR_TITLE_ESCAPED=$(echo "$PR_TITLE" | sed 's/"/\\"/g' | sed "s/'/\\'/g") + + # Create a temporary JSON file using jq to ensure valid JSON + jq -n \ + --arg avatar_url "https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png" \ + --arg title "${{ steps.action-info.outputs.emoji }} Pull Request ${{ steps.action-info.outputs.action_text }}: #${PR_NUMBER}" \ + --arg description "$PR_TITLE_ESCAPED" \ + --arg url "$PR_URL" \ + --argjson color ${{ steps.action-info.outputs.color }} \ + --arg thumbnail_url "https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png" \ + --arg pr_number "#${PR_NUMBER}" \ + --arg author_name "$AUTHOR_LOGIN" \ + --arg author_url "$AUTHOR_URL" \ + --arg repo_name "${{ github.event.repository.name }}" \ + --arg repo_url "${{ github.event.repository.html_url }}" \ + --arg branch "${HEAD_REF} → ${BASE_REF}" \ + --arg labels "$PR_LABELS" \ + --arg status "$PR_STATE" \ + --arg pr_url "$PR_URL" \ + --arg timestamp "$CREATED_AT" \ + --arg footer_text "Workout Cool • PR ${{ steps.action-info.outputs.action_text }}" \ + --arg footer_icon "https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png" \ + '{ + "avatar_url": $avatar_url, + "embeds": [ + { + "title": $title, + "description": $description, + "url": $url, + "color": $color, + "thumbnail": { + "url": $thumbnail_url + }, + "fields": [ + { + "name": "📋 PR #", + "value": $pr_number, + "inline": true + }, + { + "name": "👤 Author", + "value": ("[\($author_name)](\($author_url))"), + "inline": true + }, + { + "name": "📁 Repository", + "value": ("[\($repo_name)](\($repo_url))"), + "inline": true + }, + { + "name": "🌿 Branch", + "value": $branch, + "inline": true + }, + { + "name": "🏷️ Labels", + "value": $labels, + "inline": true + }, + { + "name": "📊 Status", + "value": $status, + "inline": true + }, + { + "name": "🔗 View PR", + "value": ("[Pull Request](\($pr_url))"), + "inline": true + } + ], + "timestamp": $timestamp, + "footer": { + "text": $footer_text, + "icon_url": $footer_icon + } + } + ] + }' > discord_payload.json + + - name: Send Discord notification + run: | + curl -H "Content-Type: application/json" \ + -d @discord_payload.json \ + "${{ secrets.DISCORD_PR_WEBHOOK }}" diff --git a/.github/workflows/notify-discord.yml b/.github/workflows/notify-discord.yml new file mode 100644 index 00000000..101d98f6 --- /dev/null +++ b/.github/workflows/notify-discord.yml @@ -0,0 +1,138 @@ +name: Discord Notification + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag_name: + description: "Tag name (leave empty for latest release)" + required: false + type: string + custom_title: + description: "Custom title for the release notification" + required: false + type: string + +jobs: + Discord: + runs-on: ubuntu-latest + name: Discord Notifier + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Get release info + id: release-info + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + # For manual trigger, get the latest release or use the input + if [ -n "${{ github.event.inputs.tag_name }}" ]; then + TAG_NAME="${{ github.event.inputs.tag_name }}" + else + TAG_NAME=$(gh release list --limit 1 --json tagName --jq '.[0].tagName') + fi + + # Get release info via GitHub CLI + RELEASE_INFO=$(gh release view "$TAG_NAME" --json name,body,url,author,publishedAt,tagName) + + echo "tag_name=$(echo "$RELEASE_INFO" | jq -r '.tagName')" >> $GITHUB_OUTPUT + echo "name=$(echo "$RELEASE_INFO" | jq -r '.name')" >> $GITHUB_OUTPUT + + # Use EOF for the body which may contain special characters + echo "body<> $GITHUB_OUTPUT + echo "$RELEASE_INFO" | jq -r '.body' >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + echo "html_url=$(echo "$RELEASE_INFO" | jq -r '.url')" >> $GITHUB_OUTPUT + echo "author_login=$(echo "$RELEASE_INFO" | jq -r '.author.login')" >> $GITHUB_OUTPUT + echo "author_html_url=https://github.com/$(echo "$RELEASE_INFO" | jq -r '.author.login')" >> $GITHUB_OUTPUT + echo "published_at=$(echo "$RELEASE_INFO" | jq -r '.publishedAt')" >> $GITHUB_OUTPUT + else + # For automatic trigger, use event data + echo "tag_name=${{ github.event.release.tag_name }}" >> $GITHUB_OUTPUT + echo "name=${{ github.event.release.name }}" >> $GITHUB_OUTPUT + + # Use EOF for the automatic release body as well + echo "body<> $GITHUB_OUTPUT + echo "${{ github.event.release.body }}" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + echo "html_url=${{ github.event.release.html_url }}" >> $GITHUB_OUTPUT + echo "author_login=${{ github.event.release.author.login }}" >> $GITHUB_OUTPUT + echo "author_html_url=${{ github.event.release.author.html_url }}" >> $GITHUB_OUTPUT + echo "published_at=${{ github.event.release.published_at }}" >> $GITHUB_OUTPUT + fi + env: + GH_TOKEN: ${{ github.token }} + + - name: Get previous release + id: previous-release + run: | + PREV_TAG=$(git tag --sort=-version:refname | grep -v '${{ steps.release-info.outputs.tag_name }}' | head -n1) + echo "previous_tag=${PREV_TAG}" >> $GITHUB_OUTPUT + + - name: Get changed files since last release + id: changed-files + uses: tj-actions/changed-files@v44 + with: + base_sha: ${{ steps.previous-release.outputs.previous_tag }} + separator: "\n• " + + - name: Create Discord webhook payload + run: | + # Create a temporary JSON file + cat > discord_payload.json << 'EOF' + { + "avatar_url": "https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png", + "embeds": [ + { + "title": "🚀 New Release: ${{ steps.release-info.outputs.tag_name }}${{ github.event.inputs.custom_title && ' - ' || '' }}${{ github.event.inputs.custom_title || '' }}", + "description": "${{ steps.release-info.outputs.name }}", + "url": "${{ steps.release-info.outputs.html_url }}", + "color": 5763719, + "thumbnail": { + "url": "https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png" + }, + "fields": [ + { + "name": "📦 Version", + "value": "`${{ steps.release-info.outputs.tag_name }}`", + "inline": true + }, + { + "name": "👤 Released by", + "value": "[${{ steps.release-info.outputs.author_login }}](${{ steps.release-info.outputs.author_html_url }})", + "inline": true + }, + { + "name": "📁 Repository", + "value": "[${{ github.event.repository.name }}](${{ github.event.repository.html_url }})", + "inline": true + }, + { + "name": "🔗 Download", + "value": "[Release Page](${{ steps.release-info.outputs.html_url }})", + "inline": true + } + ], + "timestamp": "${{ steps.release-info.outputs.published_at }}", + "footer": { + "text": "Workout Cool • Release", + "icon_url": "https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png" + } + } + ] + } + EOF + + - name: Send Discord notification + run: | + curl -H "Content-Type: application/json" \ + -d @discord_payload.json \ + "${{ secrets.DISCORD_RELEASE_WEBHOOK }}" + +# https://stackoverflow.com/a/68068674/19395252 +# https://birdie0.github.io/discord-webhooks-guide/structure/embeds.html +# https://github.com/marketplace/actions/changed-files diff --git a/.github/workflows/publish-ghcr-image.yml b/.github/workflows/publish-ghcr-image.yml new file mode 100644 index 00000000..e3cb7a6c --- /dev/null +++ b/.github/workflows/publish-ghcr-image.yml @@ -0,0 +1,55 @@ +name: Publish Docker GHCR Image + +on: + release: + types: [published] + workflow_dispatch: + inputs: + version: + description: "Version tag for the Docker image" + required: true + default: "1.2.5" + type: string +permissions: + contents: read + packages: write + +jobs: + build_and_publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set image tag + id: vars + run: | + if [ "${{ github.event_name }}" = "release" ]; then + # Get the release tag name + RELEASE_TAG="${{ github.event.release.tag_name }}" + # Remove 'v' prefix if present for Docker tag + tag="${RELEASE_TAG#v}" + echo "tag=$tag" >> $GITHUB_OUTPUT + else + echo "tag=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT + fi + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + push: true + tags: | + ghcr.io/snouzy/workout-cool:${{ steps.vars.outputs.tag }} + ghcr.io/snouzy/workout-cool:latest + platforms: linux/amd64,linux/arm64 diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..ad24672b --- /dev/null +++ b/.gitignore @@ -0,0 +1,73 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnpm-store +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/_next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env +.env.local +.env.test +.env.development +.env.production +.env*.local + +# local data imports (keep public sample, ignore large personal datasets) +/data/exercises-import.csv +/data/exercises-dataset.json + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# editors +.idea +.zed +scripts/private/ +scripts/personal/ +product-development/ +.claude/ +.cache/ + +# logs & terminal captures +screenlog.* +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# os / editor cruft +.DS_Store +Thumbs.db diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..7d8c50db --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +public-hoist-pattern[]=*import-in-the-middle* +public-hoist-pattern[]=*require-in-the-middle* \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..768032c7 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,6 @@ +{ + "plugins": ["prettier-plugin-sort-json"], + "printWidth": 140, + "proseWrap": "always", + "singleQuote": false +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..f518eafb --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit" + }, + "js/ts.tsdk.path": "node_modules/typescript/lib", + "typescript.tsdk": "node_modules/typescript/lib" +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..92988e32 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,46 @@ +# AGENTS.md - Development Guide for AI Coding Agents + +## Build/Test Commands + +- `pnpm dev` - Start development server with Turbopack +- `pnpm build` - Build production bundle +- `pnpm lint` - Run ESLint (no test framework detected) +- `pnpm db:seed` - Seed database with sample data +- `tsx scripts/[script-name].ts` - Run TypeScript scripts directly + +## Architecture & Structure + +- **Next.js 15** with App Router, **Feature-Sliced Design (FSD)** +- Structure: `features/[feature]/[model|schema|ui|lib]/` for business logic +- `src/components/ui/` for reusable UI, `src/shared/` for cross-cutting concerns +- Use **Server Components** by default, `'use client'` only when needed + +## Code Style & Conventions + +- **TypeScript** with strict mode, functional programming patterns +- **Named exports only** (no default exports for components) +- **kebab-case** for directories, **camelCase** for variables, **PascalCase** for components +- Use `interface` over `type`, avoid `enum` (use `as const` objects) +- Double quotes (`"`) enforced, 140 char line limit, no trailing commas + +## Import Organization (enforced by ESLint) + +```typescript +// External libraries (alphabetical desc) +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; + +// Internal modules +import { useI18n } from "locales/client"; +import { Button } from "@/components/ui/button"; +import { paths } from "@/shared/constants/paths"; +``` + +## Key Patterns + +- **Zod schemas** for validation in `schema/` directories +- **React Hook Form** with zodResolver for forms +- **next-safe-action** for server actions with typed errors +- **@tanstack/react-query** for client state management +- **Shadcn UI + Radix + Tailwind** for styling (mobile-first) +- Abstract external deps in `shared/lib/` (no direct fetch, Date, localStorage) diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..4ec0f8fa --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,60 @@ +# CLAUDE.md + +## Project Overview + +Workout.cool is a fitness app with two main components: + +- **Website** Next.js (App Router) web client with Server Actions Location: `/Users/mathiasbradiceanu/dev/perso/workout-cool-web` + +- **Mobile App** React Native app for iOS and Android Consumes the Workout.cool Next.js API Location: + `/Users/mathiasbradiceanu/dev/perso/workout-cool-mobile` + +## Architecture + +### System Components + +1. **Web Client (Next.js)** + + - Uses App Router and Server Actions for data mutations + - Provides REST/JSON API endpoints consumed by the mobile app + - TailwindCSS for styling + - Contain the schema of the prisma database in `/Users/mathiasbradiceanu/dev/perso/workout-cool-web/prisma/schema.prisma` + +2. **Mobile App (React Native / Expo)** + + - Communicates with the Next.js API for workouts + - Push notifications and offline support for session data + +3. **Both projects are using the FSD design system**. + +### Data Flow + +1. Mobile app and browser make API requests to the Next.js server +2. Next.js Server Actions handle form submissions, data mutations, and fetches +3. Data is stored/retrieved from the database via Next.js backend logic +4. Web client renders pages and exposes JSON endpoints +5. Mobile app syncs progress and displays workout sessions + +## Key Features + +- **3-Step Session Builder**: Equipment → Target Muscles → Generated Exercises +- **Embedded Videos**: Guide users through each exercise +- **In-Session Tracking**: Add sets with Reps, Weight, Time, or Bodyweight +- **Session History**: “Commit-style” log of past workouts on user profile +- **Repeat & Share**: Re-run past sessions or share summaries with others + +## External Integrations + +- **Database**: PostgreSQL via Next.js data layer +- **ORM**: Prisma, the schema is under `/Users/mathiasbradiceanu/dev/perso/workout-cool-web/prisma/schema.prisma` +- **Authentication**: BetterAuth (email/password, OAuth) +- **Video Hosting**: YouTube + +## Linting + +- ESLint and Prettier configured in both web and mobile workspaces + +## Deployment + +- **Website**: Vercel (Next.js) +- **Mobile App**: Expo EAS Build & Updates diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..8da516f0 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,38 @@ +### ✅ Review & Contribution Flow + +- Before starting, **create an issue** for the task you want to work on. +- **Assign yourself** to the issue so it’s clear who’s working on it. +- Keep PRs focused: one issue = one PR (preferably small and scoped). +- All PRs need **at least one maintainer review**. +- We use **"Squash and merge"** to keep history clean. +- Address review comments quickly and respectfully. + +--- + +### 🤔 Need Help? + +- **General questions** → use GitHub Discussions +- **Bug reports or features** → open an Issue +- **Live chat** → [Join our Discord](https://discord.gg/NtrsUBuHUB) + +--- + +### 📚 Useful Links + +- [Feature-Sliced Design](https://feature-sliced.design/) +- [Next.js Docs](https://nextjs.org/docs) +- [Prisma Docs](https://www.prisma.io/docs/) + +--- + +### 🌟 Recognition + +We credit contributors in: + +- the GitHub contributors list +- release notes (for impactful work) +- internal documentation if relevant + +Thanks again for contributing to Workout Cool! 💪 + +Questions? Just open an issue or ping a maintainer. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..0aeb0682 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,40 @@ +FROM node:20-alpine AS base + +WORKDIR /app +RUN npm install -g pnpm + +# Install dependencies +FROM base AS deps +COPY package.json pnpm-lock.yaml ./ +COPY prisma ./prisma +RUN pnpm install --frozen-lockfile + +# Build the app +FROM base AS builder +COPY --from=deps /app/node_modules ./node_modules +COPY --from=deps /app/prisma ./prisma +COPY . . +COPY .env.example .env + +RUN pnpm run build + +# Production image, copy only necessary files +FROM base AS runner +WORKDIR /app + +COPY --from=builder /app/public ./public +COPY --from=builder /app/.next ./.next +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/package.json ./package.json +COPY --from=builder /app/prisma ./prisma +COPY --from=builder /app/data ./data + +COPY scripts /app/scripts +RUN chmod +x /app/scripts/setup.sh + +ENTRYPOINT ["/app/scripts/setup.sh"] + +EXPOSE 3000 +ENV PORT=3000 + +CMD ["pnpm", "start"] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..90c141a1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,15 @@ +MIT License + +Copyright (c) 2023 Mathias Bradiceanu + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..4636593a --- /dev/null +++ b/Makefile @@ -0,0 +1,36 @@ +.PHONY: dev setup up down help + +help: + @echo "🚀 Workout Cool Development Commands" + @echo "" + @echo " dev - Start development server (automatically sets up everything)" + @echo " setup - One-time setup: database, schema, and sample data" + @echo " db - Start PostgreSQL database only" + @echo " down - Stop all services" + @echo "" + + +db: + @echo "🐘 Starting PostgreSQL database..." + docker compose up -d postgres + + +setup: db + @echo "📦 Installing dependencies..." + pnpm install --frozen-lockfile + @echo "🔄 Applying database migrations..." + npx prisma migrate deploy + npx prisma generate + @echo "🌱 Seeding database with sample data..." + pnpm run import:exercises-full ./data/sample-exercises.csv + pnpm run db:seed-leaderboard + @echo "✅ Setup complete!" + + +dev: setup + @echo "🚀 Starting Next.js development server..." + pnpm dev + +down: + @echo "🛑 Stopping all services..." + docker compose down diff --git a/NOTICE b/NOTICE new file mode 100644 index 00000000..b1f54fc7 --- /dev/null +++ b/NOTICE @@ -0,0 +1,71 @@ +========================================================================== +Workout.cool - Third-party attribution and rights notice +========================================================================== + +This NOTICE supplements the MIT LICENSE that covers the source code in +this repository. It documents the provenance and rights status of the +exercise dataset used by the optional import tooling. None of the +dataset files described below are committed to this repository (see +.gitignore); they are obtained and converted locally at the operator's +own discretion. + +-------------------------------------------------------------------------- +1. Source code +-------------------------------------------------------------------------- + +All source code is licensed under the MIT License (see ./LICENSE), +Copyright (c) 2023 Mathias Bradiceanu. + +-------------------------------------------------------------------------- +2. Exercise metadata (names, muscles, equipment, English instructions) +-------------------------------------------------------------------------- + +Source: ExerciseDB v1 by AscendAPI + https://oss.exercisedb.dev (API docs: /docs) +Obtained via a Kaggle re-host by omarxadel: + https://www.kaggle.com/datasets/omarxadel/fitness-exercises-dataset +Mirror used by the import tooling: + https://github.com/hasaneyldrm/exercises-dataset + +The original free-exercise-database metadata (yuhonal/free-exercise-database) +is distributed under the MIT License. The hasaneyldrm/exercises-dataset mirror +that the conversion script consumes does NOT declare a license (license field +is null), so reuse rights beyond personal use are not granted by the mirror. + +Review ExerciseDB's terms (https://oss.exercisedb.dev/docs) before any +redistribution or commercial reuse. + +-------------------------------------------------------------------------- +3. Exercise media (thumbnail images and animation GIFs) +-------------------------------------------------------------------------- + +NOT INCLUDED in this repository. The conversion script builds hotlinks to +the ExerciseDB CDN (https://static.exercisedb.dev/media/{media_id}.gif) +from the media_id field, for LOCAL development only. + +There are multiple, conflicting ownership claims over this media. It is not +licensed by this project and MUST NOT be redistributed or used in a +production/commercial deployment without obtaining the appropriate rights +from the media owner. Use at your own risk; see the rights header in +scripts/convert-exercisedb-dataset.ts. + +-------------------------------------------------------------------------- +4. Multilingual instruction translations (ES, IT, TR, RU, ZH) +-------------------------------------------------------------------------- + +The non-English instruction texts were added by the +hasaneyldrm/exercises-dataset maintainer. Because that repository declares +no license, these translations are NOT licensed for reuse beyond personal +use. The conversion script keeps them for local development only. Do not +redistribute them without explicit permission from their author. + +-------------------------------------------------------------------------- +5. Disclaimer +-------------------------------------------------------------------------- + +This project does not claim ownership of any third-party exercise content, +media, or translations. The import tooling is provided as a convenience for +local development and self-hosting. Operators are responsible for ensuring +they have the necessary rights for any content they import and serve. If +you are a rights holder and want something removed or clarified, please +open an issue. diff --git a/README.md b/README.md new file mode 100644 index 00000000..01cde43f --- /dev/null +++ b/README.md @@ -0,0 +1,437 @@ +
+Workout.cool Logo +

Workout.cool

+

Modern fitness coaching platform with comprehensive exercise database

+

+Contributors + +Forks + +Stars + +Issues +Repository Size + + MIT License + + +

+ + Discord + + + Ko-fi + +

+ + Deutsch | + Español | + français | + 日本語 | + 한국어 | + Português | + Русский | + 中文 +

+
+ +## Table of Contents + +- [About](#about) +- [Project Origin & Motivation](#-project-origin--motivation) +- [Quick Start](#quick-start) +- [Exercise Database Import](#exercise-database-import) +- [Project Architecture](#project-architecture) +- [Contributing](#contributing) +- [Self-hosting](#deployment--self-hosting) +- [Resources](#resources) +- [License](#license) +- [Sponsor This Project](#-sponsor-this-project) + +## Contributors + + + + + +## Sponsors + +
+

They are helping making workout.cool free and open-source for everyone :

+ + + Vercel OSS Program + +
+
+ + + + + + +
+ + lj020326 +
+ lj020326 +
+
+
+ + lucasnevespereira +
+ lucasnevespereira +
+
+
+ +
+ +## About + +A comprehensive fitness coaching platform that allows create workout plans for you, track progress, and access a vast exercise database with +detailed instructions and video demonstrations. + +## 🎯 Project Origin & Motivation + +This project was born from a personal mission to revive and improve upon a previous fitness platform. As the **primary contributor** to the +original [workout.lol](https://github.com/workout-lol/workout-lol) project, I witnessed its journey and abandonment. 🥹 + +### The Story Behind **_workout.cool_** + +- 🏗️ **Original Contributor**: I was the main contributor to workout.lol +- 💼 **Business Challenges**: The original project faced major hurdles with exercise video partnerships (no reliable video provider) could + be established +- 💰 **Project Sale**: Due to these partnership issues, the project was sold to another party +- 📉 **Abandonment**: The new owner quickly realized that **exercise video licensing costs were prohibitively expensive**, began to be sick + and abandoned the entire project +- 🔄 **Revival Attempts**: For the past **9 months**, I've been trying to reconnect with the new stakeholder +- 📧 **Radio Silence**: Despite multiple (15) attempts, there has been no response +- 🚀 **New Beginning**: Rather than let this valuable work disappear, I decided to create a fresh, modern implementation + +### Why **_workout.cool_** Exists + +**Someone had to step up.** + +The opensource fitness community deserves better than broken promises and abandoned platforms. + +I'm not building this for profit. + +This isn't just a revival : it's an evolution. **workout.cool** represents everything the original project could have been, with the +reliability, modern approach, and **maintenance** that the fitness open source community deserves. + +## 👥 From the Community, For the Community + +**I'm not just a developer : I'm a user who refused to let our community down.** + +I experienced firsthand the frustration of watching a beloved tool slowly disappear. Like many of you, I had workouts saved, progress +tracked, and a routine built around the platform. + +### My Mission: Rescue & Revive. + +_If you were part of the original workout.lol community, welcome back! If you're new here, welcome to the future of fitness platform +management._ + +## Quick Start + +### Prerequisites + +- [Node.js](https://nodejs.org/) (v18+) +- [pnpm](https://pnpm.io/) (v8+) +- [Docker](https://www.docker.com/) + +### Installation + +1. **Clone the repository** + + ```bash + git clone https://github.com/Snouzy/workout-cool.git + cd workout-cool + ``` + +2. **Choose your installation method:** + +
+🐳 With Docker + +### Docker Installation + +1. **Copy environment variables** + + ```bash + cp .env.example .env + ``` + +2. **Start everything for development:** + + ```sh + make dev + ``` + + - This will start the database in Docker, run migrations, seed the DB, and start the Next.js dev server. + - To stop services run `make down` + +3. **Open your browser** Navigate to [http://localhost:3000](http://localhost:3000) + +
+ +
+💻 Without Docker + +### Manual Installation + +1. **Install dependencies** + + ```bash + pnpm install + ``` + +2. **Copy environment variables** + + ```bash + cp .env.example .env + ``` + +3. **Set up PostgreSQL database** + + - If you don't already have it, install PostgreSQL locally + - Create a database named `workout_cool` : `createdb -h localhost -p 5432 -U postgres workout_cool` + +4. **Run database migrations** + + ```bash + npx prisma migrate dev + ``` + +5. **Seed the database (optional)** + +See the - [Exercise database import section](#exercise-database-import) + +6. **Start the development server** + + ```bash + pnpm dev + ``` + +7. **Open your browser** Navigate to [http://localhost:3000](http://localhost:3000) + +
+ +## Exercise Database Import + +The project includes a comprehensive exercise database. To import a sample of exercises: + +### Prerequisites for Import + +1. **Prepare your CSV file** + +Your CSV should have these columns: + +``` +id,name,name_en,description,description_en,full_video_url,full_video_image_url,introduction,introduction_en,slug,slug_en,attribute_name,attribute_value +``` + +You can use the provided example. + +### Import Commands + +### Exercise Data Source & Rights + +The sample data (`data/sample-exercises.csv`) is safe to commit and is the +recommended starting point. For a larger set, a local conversion script +(`scripts/convert-exercisedb-dataset.ts`) can turn an ExerciseDB-derived +dataset into the CSV format above. Both the dataset JSON and the generated +CSV are gitignored and never committed. + +That tooling, and any data it produces, carries third-party rights you must +respect: exercise metadata originates from ExerciseDB v1, the exercise media is +**not licensed** (hotlinks are for local dev only), and the multilingual +translations are **not licensed** for redistribution. Review the full +attribution and rights details in [NOTICE](NOTICE) before importing or serving +any converted data. + +```bash +# Import exercises from a CSV file +pnpm run import:exercises-full /path/to/your/exercises.csv + +# Example with the provided sample data +pnpm run import:exercises-full ./data/sample-exercises.csv +``` + +### CSV Format Example + +```csv +id,name,name_en,description,description_en,full_video_url,full_video_image_url,introduction,introduction_en,slug,slug_en,attribute_name,attribute_value +157,"Fentes arrières à la barre","Barbell Reverse Lunges","

Stand upright...

","

Stand upright...

",https://youtube.com/...,https://img.youtube.com/...,slug-fr,slug-en,TYPE,STRENGTH +157,"Fentes arrières à la barre","Barbell Reverse Lunges","

Stand upright...

","

Stand upright...

",https://youtube.com/...,https://img.youtube.com/...,slug-fr,slug-en,PRIMARY_MUSCLE,QUADRICEPS +``` + +Want unlimited exercise for local development ? + +Just ask chatGPT with the prompt from `./scripts/import-exercises-with-attributes.prompt.md` + +## Project Architecture + +This project follows **Feature-Sliced Design (FSD)** principles with Next.js App Router: + +``` +src/ +├── app/ # Next.js pages, routes and layouts +├── processes/ # Business flows (multi-feature) +├── widgets/ # Composable UI with logic (Sidebar, Header) +├── features/ # Business units (auth, exercise-management) +├── entities/ # Domain entities (user, exercise, workout) +├── shared/ # Shared code (UI, lib, config, types) +└── styles/ # Global CSS, themes +``` + +### Architecture Principles + +- **Feature-driven**: Each feature is independent and reusable +- **Clear domain isolation**: `shared` → `entities` → `features` → `widgets` → `app` +- **Consistency**: Between business logic, UI, and data layers + +### Example Feature Structure + +``` +features/ +└── exercise-management/ +├── ui/ # UI components (ExerciseForm, ExerciseCard) +├── model/ # Hooks, state management (useExercises) +├── lib/ # Utilities (exercise-helpers) +└── api/ # Server actions or API calls +``` + +## Contributing + +We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details. + +### Development Workflow + +1. **Create an issue** for the feature/bug you want to work on. Say that you will work on it (or no) +2. Fork the repository +3. Create your feature|fix|chore|refactor branch (`git checkout -b feature/amazing-feature`) +4. Make your changes following our [code standards](#code-style) +5. Commit your changes (`git commit -m 'feat: add amazing feature'`) +6. Push to the branch (`git push origin feature/amazing-feature`) +7. Open a Pull Request (one issue = one PR) + +**📋 For complete contribution guidelines, see our [Contributing Guide](CONTRIBUTING.md)** + +### Code Style + +- Follow TypeScript best practices +- Use Feature-Sliced Design architecture +- Write meaningful commit messages + +## Deployment / Self-hosting + +> 📖 **For detailed self-hosting instructions, see our [Complete Self-hosting Guide](docs/SELF-HOSTING.md)** +> +> 📺 **You can also watch a [3-minute video guide on self-hosting Workout.Cool](https://www.youtube.com/watch?v=HQecjb0CfAo).** + + +To seed the database with the sample exercises, set the `SEED_SAMPLE_DATA` env variable to `true`. + +### Using Docker + +```bash +# Build the Docker image +docker build -t yourusername/workout-cool . + +# Run the container +docker run -p 3000:3000 --env-file .env.production yourusername/workout-cool +``` + +### Using Docker Compose + +#### DATABASE_URL + +Update the `host` to point to the `postgres` service instead of `localhost` +`DATABASE_URL=postgresql://username:password@postgres:5432/workout_cool` + +```bash +docker compose up -d +``` + +### Manual Deployment + +```bash +# Build the application +pnpm build + +# Run database migrations +export DATABASE_URL="your-production-db-url" +npx prisma migrate deploy + +# Start the production server +pnpm start +``` + +## Resources + +- [Feature-Sliced Design](https://feature-sliced.design/) +- [Next.js Documentation](https://nextjs.org/docs) +- [Prisma Documentation](https://www.prisma.io/docs/) +- [Better Auth](https://github.com/better-auth/better-auth) + +## License + +This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. + +[![MIT License](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) + +## 🤝 Join the Rescue Mission + +**This is about rebuilding what we lost, together.** + +### How You Can Help + +- 🌟 **Star this repo** to show the world our community is alive and thriving +- 💬 **Join our Discord** to connect with other fitness enthusiasts and developers +- 🐛 **Report issues** you find. I'm listening to every single one +- 💡 **Share your feature requests** finally, someone who will actually implement them ! +- 🔄 **Spread the word** to fellow fitness enthusiasts who lost hope +- 🤝 **Contribute code** if you're a developer : let's build this together + + + +## 💖 Sponsor This Project + +Appear in the README and on the website as supporter by donating: + +
+ + Sponsor on Ko-fi + +     + + +
+ +

+ If you believe in open-source fitness tools and want to help this project thrive,
+ consider buying me a coffee ☕ or sponsoring the continued development.
+

+ +

+ Your support helps cover hosting costs, exercise database updates, and continuous improvement.
+ Thank you for keeping workout.cool alive and evolving 💪 +

+ +
+
+ +Vercel OSS Program + diff --git a/app/[locale]/(admin)/admin/[...catchAll]/not-found.tsx b/app/[locale]/(admin)/admin/[...catchAll]/not-found.tsx new file mode 100644 index 00000000..bc32150f --- /dev/null +++ b/app/[locale]/(admin)/admin/[...catchAll]/not-found.tsx @@ -0,0 +1,5 @@ +import { Page404 } from "@/widgets/404"; + +export default function NotFoundPage() { + return ; +} diff --git a/app/[locale]/(admin)/admin/[...catchAll]/page.tsx b/app/[locale]/(admin)/admin/[...catchAll]/page.tsx new file mode 100644 index 00000000..3326b86c --- /dev/null +++ b/app/[locale]/(admin)/admin/[...catchAll]/page.tsx @@ -0,0 +1,5 @@ +import { Page404 } from "@/widgets/404"; + +export default function AdminCatchAll() { + return ; +} diff --git a/app/[locale]/(admin)/admin/dashboard/page.tsx b/app/[locale]/(admin)/admin/dashboard/page.tsx new file mode 100644 index 00000000..ddd00fed --- /dev/null +++ b/app/[locale]/(admin)/admin/dashboard/page.tsx @@ -0,0 +1,225 @@ +import { Suspense } from "react"; +import Image from "next/image"; +import { Users, Target } from "lucide-react"; + +import { prisma } from "@/shared/lib/prisma"; +import { Skeleton } from "@/components/ui/skeleton"; + +async function getDashboardStats() { + const [totalUsers, totalWorkoutSessions, totalExercises, activeSubscriptions, recentUsers, recentWorkouts, totalPrograms] = + await Promise.all([ + // Total users + prisma.user.count(), + + // Total workout sessions + prisma.workoutSession.count(), + + // Total exercises + prisma.exercise.count(), + + // Active subscriptions + prisma.subscription.count({ + where: { + status: "ACTIVE", + }, + }), + + // Users created in last 7 days + prisma.user.count({ + where: { + createdAt: { + gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), + }, + }, + }), + + // Workout sessions in last 7 days + prisma.workoutSession.count({ + where: { + startedAt: { + gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), + }, + }, + }), + + // Total programs + prisma.program.count(), + ]); + + return { + totalUsers, + totalWorkoutSessions, + totalExercises, + activeSubscriptions, + recentUsers, + recentWorkouts, + totalPrograms, + }; +} + +async function DashboardStats() { + const stats = await getDashboardStats(); + + return ( +
+
+
+
+
+
+
+
+ +
+ Communauté +
+

{stats.totalUsers.toLocaleString()}

+

+

Utilisateurs

+ +{stats.recentUsers} cette semaine +

+
+
+ Happy mascot +
+
+
+
+ + {/* Workout Sessions Card */} +
+
+
+
+ Swag mascot +
+
+

{stats.totalWorkoutSessions.toLocaleString()}

+

Sessions

+

+{stats.recentWorkouts} cette semaine

+
+
+
+ + {/* Row 2 */} +
+ {/* Programs Card */} +
+
+
+
+ Wooow mascot +
+
+

{stats.totalPrograms.toLocaleString()}

+

Programmes

+
+
+ +
+
+
+
+ Love mascot +
+
+

{stats.totalExercises.toLocaleString()}

+

Exercices

+
+
+ + {/* Growth Card */} +
+
+
+
+ Teeth mascot +
+
+

{stats.activeSubscriptions}

+

Abonnés

+
+
+
+
+ ); +} + +function DashboardStatsLoading() { + return ( +
+
+
+
+ + + +
+
+
+ + + +
+
+ + + +
+
+
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ + + +
+ ))} +
+
+ ); +} + +export default function AdminDashboard() { + return ( +
+
+
+ +
+
+

Dashboard Admin

+

WorkoutCool Admin

+
+
+ + }> + + +
+ ); +} diff --git a/app/[locale]/(admin)/admin/layout.tsx b/app/[locale]/(admin)/admin/layout.tsx new file mode 100644 index 00000000..338a727d --- /dev/null +++ b/app/[locale]/(admin)/admin/layout.tsx @@ -0,0 +1,39 @@ +import { redirect } from "next/navigation"; +import { UserRole } from "@prisma/client"; + +import type { ReactNode } from "react"; + +import { AdminSidebar } from "@/features/admin/layout/admin-sidebar/ui/admin-sidebar"; +import { AdminHeader } from "@/features/admin/layout/admin-sidebar/ui/admin-header"; +import { serverRequiredUser } from "@/entities/user/model/get-server-session-user"; + +interface AdminLayoutProps { + params: Promise<{ locale: string }>; + children: ReactNode; +} + +export default async function AdminLayout({ children }: AdminLayoutProps) { + const user = await serverRequiredUser(); + + if (user.role !== UserRole.admin) { + redirect("/"); + } + + return ( +
+ {/* Sidebar */} + + + {/* Main content */} +
+ {/* Header */} + + + {/* Page content */} +
+
{children}
+
+
+
+ ); +} diff --git a/app/[locale]/(admin)/admin/not-found.tsx b/app/[locale]/(admin)/admin/not-found.tsx new file mode 100644 index 00000000..bc32150f --- /dev/null +++ b/app/[locale]/(admin)/admin/not-found.tsx @@ -0,0 +1,5 @@ +import { Page404 } from "@/widgets/404"; + +export default function NotFoundPage() { + return ; +} diff --git a/app/[locale]/(admin)/admin/programs/[id]/edit/page.tsx b/app/[locale]/(admin)/admin/programs/[id]/edit/page.tsx new file mode 100644 index 00000000..0d82da15 --- /dev/null +++ b/app/[locale]/(admin)/admin/programs/[id]/edit/page.tsx @@ -0,0 +1,20 @@ +import { notFound } from "next/navigation"; + +import { ProgramBuilder } from "@/features/admin/programs/ui/program-builder"; +import { getProgramById } from "@/features/admin/programs/actions/get-programs.action"; + +interface ProgramEditPageProps { + params: Promise<{ id: string }>; +} + +export default async function ProgramEditPage({ params }: ProgramEditPageProps) { + const { id } = await params; + + const program = await getProgramById(id); + + if (!program) { + notFound(); + } + + return ; +} diff --git a/app/[locale]/(admin)/admin/programs/page.tsx b/app/[locale]/(admin)/admin/programs/page.tsx new file mode 100644 index 00000000..ca3a1955 --- /dev/null +++ b/app/[locale]/(admin)/admin/programs/page.tsx @@ -0,0 +1,22 @@ +import { Suspense } from "react"; + +import { ProgramsList } from "@/features/admin/programs/ui/programs-list"; +import { CreateProgramButton } from "@/features/admin/programs/ui/create-program-button"; + +export default function AdminPrograms() { + return ( +
+
+
+

Programs

+

Create, edit, view and delete programs.

+
+ +
+ + Loading programs...
}> + +
+ + ); +} diff --git a/app/[locale]/(admin)/admin/settings/page.tsx b/app/[locale]/(admin)/admin/settings/page.tsx new file mode 100644 index 00000000..927aa405 --- /dev/null +++ b/app/[locale]/(admin)/admin/settings/page.tsx @@ -0,0 +1,10 @@ +export default function AdminSettings() { + return ( +
+
+

Settings

+

Configuration and administration of the system.

+
+
+ ); +} diff --git a/app/[locale]/(admin)/admin/users/page.tsx b/app/[locale]/(admin)/admin/users/page.tsx new file mode 100644 index 00000000..730c0419 --- /dev/null +++ b/app/[locale]/(admin)/admin/users/page.tsx @@ -0,0 +1,55 @@ +import { redirect } from "next/navigation"; +import { UserRole } from "@prisma/client"; + +import { UsersTable } from "@/features/admin/users/list/ui/users-table"; +import { getUsersAction } from "@/entities/user/model/get-users.actions"; +import { serverRequiredUser } from "@/entities/user/model/get-server-session-user"; + +export default async function AdminUsersPage() { + try { + const user = await serverRequiredUser(); + + if (user.role !== UserRole.admin) { + redirect("/"); + } + + // Call the action with proper error handling + const result = await getUsersAction({ + page: 1, + limit: 10, + }); + + // Check if the action was successful + if (!result || !result.data) { + throw new Error("Impossible de charger les utilisateurs"); + } + + return ( +
+
+

Utilisateurs

+

Gestion et administration des comptes utilisateurs

+
+ +
+ ); + } catch (error) { + console.error("Error in admin users page:", error); + + return ( +
+
+

Utilisateurs

+

Gestion et administration des comptes utilisateurs

+
+
+

Erreur de chargement

+

+ Impossible de charger la liste des utilisateurs. Veuillez réessayer plus tard. +

+

{error instanceof Error ? error.message : "Erreur inconnue"}

+
+
+ ); + } +} diff --git a/app/[locale]/(app)/(legal-and-payment)/layout.tsx b/app/[locale]/(app)/(legal-and-payment)/layout.tsx new file mode 100644 index 00000000..dc8fb7ee --- /dev/null +++ b/app/[locale]/(app)/(legal-and-payment)/layout.tsx @@ -0,0 +1,19 @@ +import { LayoutParams } from "@/shared/types/next"; + +type LocaleParams = Record & { + locale: string; +}; + +export default function RouteLayout({ children, params: _ }: LayoutParams) { + return ( +
+ {/* Fixe l'espace sous le header flottant */} +
+ + {/* Contenu principal centré avec marge */} +
+
{children}
+
+
+ ); +} diff --git a/app/[locale]/(app)/(legal-and-payment)/legal/privacy/page.tsx b/app/[locale]/(app)/(legal-and-payment)/legal/privacy/page.tsx new file mode 100644 index 00000000..1366cb48 --- /dev/null +++ b/app/[locale]/(app)/(legal-and-payment)/legal/privacy/page.tsx @@ -0,0 +1,30 @@ +import { getLocalizedMdx } from "@/shared/lib/mdx/load-mdx"; +import { Typography } from "@/components/ui/typography"; + +type PageProps = { + params: Promise<{ locale: string }>; +}; + +export default async function PrivacyPolicyPage({ params }: PageProps) { + const { locale } = await params; + const content = await getLocalizedMdx("privacy-policy", locale); + + return ( +
+
+
+ + {locale === "fr" ? "Politique de Confidentialité" : "Privacy Policy"} + +

+ {locale === "fr" + ? "Voici comment nous traitons vos données personnelles." + : "How we handle your personal data at Workout Cool."} +

+
+ +
{content}
+
+
+ ); +} diff --git a/app/[locale]/(app)/(legal-and-payment)/legal/sales-terms/page.tsx b/app/[locale]/(app)/(legal-and-payment)/legal/sales-terms/page.tsx new file mode 100644 index 00000000..69b111ff --- /dev/null +++ b/app/[locale]/(app)/(legal-and-payment)/legal/sales-terms/page.tsx @@ -0,0 +1,33 @@ +import { getLocalizedMdx } from "@/shared/lib/mdx/load-mdx"; +import { Layout, LayoutContent } from "@/features/page/layout"; +import { Typography } from "@/components/ui/typography"; + +type PageProps = { + params: Promise<{ locale: string }>; +}; + +export default async function SalesTermsPage({ params }: PageProps) { + const { locale } = await params; + const content = await getLocalizedMdx("sales-terms", locale); + + return ( +
+
+
+ + {locale === "fr" ? "Conditions Générales de Vente" : "General Terms of Sale"} + +

+ {locale === "fr" + ? "Les conditions qui régissent l’achat d’un abonnement Workout Cool." + : "The terms governing the purchase of a Workout Cool subscription."} +

+
+ + + {content} + +
+
+ ); +} diff --git a/app/[locale]/(app)/(legal-and-payment)/legal/terms/page.tsx b/app/[locale]/(app)/(legal-and-payment)/legal/terms/page.tsx new file mode 100644 index 00000000..61eff89c --- /dev/null +++ b/app/[locale]/(app)/(legal-and-payment)/legal/terms/page.tsx @@ -0,0 +1,33 @@ +import { getLocalizedMdx } from "@/shared/lib/mdx/load-mdx"; +import { Layout, LayoutContent } from "@/features/page/layout"; +import { Typography } from "@/components/ui/typography"; + +type PageProps = { + params: Promise<{ locale: string }>; +}; + +export default async function TermsPage({ params }: PageProps) { + const { locale } = await params; + const content = await getLocalizedMdx("terms", locale); + + return ( +
+
+
+ + {locale === "fr" ? "Conditions Générales d’Utilisation" : "Terms of Use"} + +

+ {locale === "fr" + ? "Merci de lire attentivement ces conditions avant d’utiliser nos services." + : "Please read these terms carefully before using our services."} +

+
+ + + {content} + +
+
+ ); +} diff --git a/app/[locale]/(app)/[slug]/layout.tsx b/app/[locale]/(app)/[slug]/layout.tsx new file mode 100644 index 00000000..f831cb3b --- /dev/null +++ b/app/[locale]/(app)/[slug]/layout.tsx @@ -0,0 +1,15 @@ +import type { ReactNode } from "react"; + +interface RootLayoutProps { + params: Promise<{ locale: string }>; + children: ReactNode; + } + +export default async function RootLayout({ children }: RootLayoutProps) { + + return ( +
+ {children} +
+ ) +} \ No newline at end of file diff --git a/app/[locale]/(app)/about/page.tsx b/app/[locale]/(app)/about/page.tsx new file mode 100644 index 00000000..1b7391c1 --- /dev/null +++ b/app/[locale]/(app)/about/page.tsx @@ -0,0 +1,18 @@ +import { getLocalizedMdx } from "@/shared/lib/mdx/load-mdx"; + +type PageProps = { + params: Promise<{ locale: string }>; +}; + +export default async function AboutPage({ params }: PageProps) { + const { locale } = await params; + const content = await getLocalizedMdx("about", locale); + + return ( +
+
+
{content}
+
+
+ ); +} diff --git a/app/[locale]/(app)/analytics/page.tsx b/app/[locale]/(app)/analytics/page.tsx new file mode 100644 index 00000000..ca45a16f --- /dev/null +++ b/app/[locale]/(app)/analytics/page.tsx @@ -0,0 +1,28 @@ +import type { Metadata } from "next"; + +import { getI18n } from "locales/server"; + +import { AnalyticsDashboard } from "@/features/workout-analytics"; + +export async function generateMetadata(): Promise { + const t = await getI18n(); + return { + title: "训练分析", + description: "ACWR 急慢性负荷预警与肌群周容量", + }; +} + +export default async function AnalyticsPage() { + return ( +
+
+

训练分析

+

+ 基于运动科学的负荷管理与容量均衡。ACWR > 1.5 受伤风险显著上升。 +

+
+ + +
+ ); +} diff --git a/app/[locale]/(app)/auth/(auth-layout)/forgot-password/page.tsx b/app/[locale]/(app)/auth/(auth-layout)/forgot-password/page.tsx new file mode 100644 index 00000000..62a0943d --- /dev/null +++ b/app/[locale]/(app)/auth/(auth-layout)/forgot-password/page.tsx @@ -0,0 +1,5 @@ +import { ForgotPasswordForm } from "@/features/auth/forgot-password/ui/forgot-password-form"; + +export default async function ForgotPasswordPage() { + return ; +} diff --git a/app/[locale]/(app)/auth/(auth-layout)/layout.tsx b/app/[locale]/(app)/auth/(auth-layout)/layout.tsx new file mode 100644 index 00000000..1063bb88 --- /dev/null +++ b/app/[locale]/(app)/auth/(auth-layout)/layout.tsx @@ -0,0 +1,40 @@ +import { redirect } from "next/navigation"; +import { headers } from "next/headers"; + +import { getI18n } from "locales/server"; +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(); + + const headerStore = await headers(); + const searchParams = Object.fromEntries(new URLSearchParams(headerStore.get("searchParams") || "")); + const translatedError = t(`next_auth_errors.${searchParams.error}` as keyof typeof t); + + const user = await auth.api.getSession({ headers: headerStore }); + + if (user) { + redirect(`/${paths.root}`); + } + + return ( + <> +
+ {searchParams.error && ( + + {translatedError} + {t("signin_error_subtitle")} + + )} + +
+
{props.children}
+
+
+ + ); +} diff --git a/app/[locale]/(app)/auth/(auth-layout)/reset-password/page.tsx b/app/[locale]/(app)/auth/(auth-layout)/reset-password/page.tsx new file mode 100644 index 00000000..5702275d --- /dev/null +++ b/app/[locale]/(app)/auth/(auth-layout)/reset-password/page.tsx @@ -0,0 +1,5 @@ +import { ResetPasswordForm } from "@/features/auth/reset-password/ui/reset-password-form"; + +export default function ResetPasswordPage() { + return ; +} diff --git a/app/[locale]/(app)/auth/(auth-layout)/signin/page.tsx b/app/[locale]/(app)/auth/(auth-layout)/signin/page.tsx new file mode 100644 index 00000000..ae0cbb2a --- /dev/null +++ b/app/[locale]/(app)/auth/(auth-layout)/signin/page.tsx @@ -0,0 +1,5 @@ +import { CredentialsLoginForm } from "@/features/auth/signin/ui/CredentialsLoginForm"; + +export default async function AuthSignInPage() { + return ; +} diff --git a/app/[locale]/(app)/auth/(auth-layout)/signup/page.tsx b/app/[locale]/(app)/auth/(auth-layout)/signup/page.tsx new file mode 100644 index 00000000..824163b3 --- /dev/null +++ b/app/[locale]/(app)/auth/(auth-layout)/signup/page.tsx @@ -0,0 +1,35 @@ +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"; + +export const metadata = { + title: "Sign Up - Workout.cool", + description: "Créez votre compte pour commencer", +}; + +export default async function AuthSignUpPage() { + const t = await getI18n(); + + return ( +
+
+

{t("register_title")}

+

{t("register_description")}

+
+ + + +
+

+ {t("register_terms")}{" "} + + {t("register_privacy")} + {" "} + . +

+
+
+ ); +} \ No newline at end of file diff --git a/app/[locale]/(app)/auth/error.tsx b/app/[locale]/(app)/auth/error.tsx new file mode 100644 index 00000000..fddb404b --- /dev/null +++ b/app/[locale]/(app)/auth/error.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { useEffect } from "react"; + +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(() => { + // Log the error to an error reporting service + logger.error(error); + }, [error]); + + return ( + + + Sorry, something went wrong. Please try again later. + + + + + + ); +} diff --git a/app/[locale]/(app)/auth/error/page.tsx b/app/[locale]/(app)/auth/error/page.tsx new file mode 100644 index 00000000..c4ee678d --- /dev/null +++ b/app/[locale]/(app)/auth/error/page.tsx @@ -0,0 +1,24 @@ +import Link from "next/link"; + +import { Card, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; +import { buttonVariants } from "@/components/ui/button"; + +export default async function AuthErrorPage({ params }: { params: Promise<{ error: string }> }) { + const result = await params; + + return ( +
+ + + Error + {result.error} + + + + Home + + + +
+ ); +} diff --git a/app/[locale]/(app)/auth/layout.tsx b/app/[locale]/(app)/auth/layout.tsx new file mode 100644 index 00000000..9143d65c --- /dev/null +++ b/app/[locale]/(app)/auth/layout.tsx @@ -0,0 +1,3 @@ +export default function AuthLayout({ children }: { children: React.ReactNode }) { + return <>{children}; +} diff --git a/app/[locale]/(app)/auth/signout/page.tsx b/app/[locale]/(app)/auth/signout/page.tsx new file mode 100644 index 00000000..01a2affd --- /dev/null +++ b/app/[locale]/(app)/auth/signout/page.tsx @@ -0,0 +1,3 @@ +export default function AuthSignOutPage() { + return
AuthSignOutPage
; +} diff --git a/app/[locale]/(app)/auth/verify-email/layout.tsx b/app/[locale]/(app)/auth/verify-email/layout.tsx new file mode 100644 index 00000000..747d8786 --- /dev/null +++ b/app/[locale]/(app)/auth/verify-email/layout.tsx @@ -0,0 +1,21 @@ +import type { ReactNode } from "react"; +import { redirect } from "next/navigation"; + +import { getServerUrl } from "@/shared/lib/server-url"; +import { paths } from "@/shared/constants/paths"; +import { serverRequiredUser } from "@/entities/user/model/get-server-session-user"; + +interface RootLayoutProps { + params: Promise<{ locale: string }>; + children: ReactNode; +} + +export default async function RootLayout({ children }: RootLayoutProps) { + const auth = await serverRequiredUser(); + + if (auth.emailVerified) { + redirect(`${getServerUrl()}/${paths.root}`); + } + + return children; +} diff --git a/app/[locale]/(app)/auth/verify-email/page.tsx b/app/[locale]/(app)/auth/verify-email/page.tsx new file mode 100644 index 00000000..38bc0b67 --- /dev/null +++ b/app/[locale]/(app)/auth/verify-email/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { VerifyEmailPage } from "@/features/auth/verify-email/ui/verify-email-page"; + +export default function VerifyEmailRootPage() { + return ; +} diff --git a/app/[locale]/(app)/auth/verify-request/page.tsx b/app/[locale]/(app)/auth/verify-request/page.tsx new file mode 100644 index 00000000..a0515b2a --- /dev/null +++ b/app/[locale]/(app)/auth/verify-request/page.tsx @@ -0,0 +1,33 @@ +import Image from "next/image"; + +import { SiteConfig } from "@/shared/config/site-config"; +import { Typography } from "@/components/ui/typography"; +import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; + +interface VerifyRequestPageParams { + params: Promise>; + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; +} + +export default async function AuthVerifyRequestPage({ params: _p, searchParams: _s }: VerifyRequestPageParams) { + return ( +
+
+ app icon + {SiteConfig.title} +
+
+ + + Almost There! + + { + "To complete the verification, head over to your email inbox. You'll find a magic link from us. Click on it, and you're all set!" + } + + + +
+
+ ); +} diff --git a/app/[locale]/(app)/layout.tsx b/app/[locale]/(app)/layout.tsx new file mode 100644 index 00000000..3dd8892b --- /dev/null +++ b/app/[locale]/(app)/layout.tsx @@ -0,0 +1,23 @@ +import type { ReactNode } from "react"; + +import { Header } from "@/features/layout/Header"; +import { Footer } from "@/features/layout/Footer"; +import { BottomNavigation } from "@/features/layout/BottomNavigation"; +import { LanAccessQr } from "@/components/lan-access-qr"; + +interface RootLayoutProps { + params: Promise<{ locale: string }>; + children: ReactNode; +} + +export default async function RootLayout({ children }: RootLayoutProps) { + return ( +
+
+
{children}
+ +
+ +
+ ); +} diff --git a/app/[locale]/(app)/leaderboard/page.tsx b/app/[locale]/(app)/leaderboard/page.tsx new file mode 100644 index 00000000..018cd2d2 --- /dev/null +++ b/app/[locale]/(app)/leaderboard/page.tsx @@ -0,0 +1,34 @@ +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"; + +export const metadata: Metadata = { + title: "🏆 Workout Streak Leaderboard", + description: "See who's dominating their fitness journey with the longest workout streaks! Join the leaderboard and track your progress.", +}; + +export default async function LeaderboardRootPage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = (await params) as { locale: Locale }; + const t = await getI18n(); + + const breadcrumbItems = [ + { + label: t("breadcrumbs.home"), + href: `/${locale}`, + }, + { + label: t("bottom_navigation.leaderboard"), + current: true, + }, + ]; + + return ( + <> + + + + ); +} diff --git a/app/[locale]/(app)/onboarding/layout.tsx b/app/[locale]/(app)/onboarding/layout.tsx new file mode 100644 index 00000000..f9506229 --- /dev/null +++ b/app/[locale]/(app)/onboarding/layout.tsx @@ -0,0 +1,7 @@ +import { LayoutParams } from "@/shared/types/next"; + +export default async function OnboardingLayout(props: LayoutParams<{}>) { + // TODO: add onboarding logic + + return props.children; +} diff --git a/app/[locale]/(app)/onboarding/page.tsx b/app/[locale]/(app)/onboarding/page.tsx new file mode 100644 index 00000000..280261ed --- /dev/null +++ b/app/[locale]/(app)/onboarding/page.tsx @@ -0,0 +1,7 @@ +export default async function OnboardingPage() { + return ( +
+
Onboarding
+
+ ); +} diff --git a/app/[locale]/(app)/page.tsx b/app/[locale]/(app)/page.tsx new file mode 100644 index 00000000..7f151f1f --- /dev/null +++ b/app/[locale]/(app)/page.tsx @@ -0,0 +1,38 @@ +import type { Metadata } from "next"; + +import { getServerUrl } from "@/shared/lib/server-url"; +import { SiteConfig } from "@/shared/config/site-config"; +import { WorkoutStepper } from "@/features/workout-builder"; +import { getRecommendedDay } from "@/features/training-science/actions/training-plan.action"; + +export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise { + const { locale } = await params; + + const isEnglish = locale === "en"; + const title = isEnglish ? "Build Your Perfect Workout" : "Créez Votre Entraînement Parfait"; + const description = isEnglish + ? "Create free workout routines with our comprehensive exercise database. Track your progress and achieve your fitness goals." + : "Créez des routines d'entraînement gratuites avec notre base de données d'exercices complète. Suivez vos progrès et atteignez vos objectifs fitness."; + + return { + title, + description, + openGraph: { + title: `${title} | ${SiteConfig.title}`, + description, + images: [{ url: `${getServerUrl()}/images/default-og-image_${locale}.jpg` }], + }, + }; +} + +export default async function HomePage() { + // Server-side: fetch today's recommended training day for logged-in users. + // Returns null for anonymous users or those without a saved plan. + const recommendation = await getRecommendedDay().catch(() => null); + + return ( +
+ +
+ ); +} diff --git a/app/[locale]/(app)/plan/page.tsx b/app/[locale]/(app)/plan/page.tsx new file mode 100644 index 00000000..681f2314 --- /dev/null +++ b/app/[locale]/(app)/plan/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; +import { getTrainingPlan } from "@/features/training-science/actions/training-plan.action"; +import { PlanPage } from "@/features/training-science/ui/plan-page"; + +export const metadata: Metadata = { + title: "My Training Plan", +}; + +export default async function Page() { + const plan = await getTrainingPlan().catch(() => null); + return ; +} diff --git a/app/[locale]/(app)/premium/page.tsx b/app/[locale]/(app)/premium/page.tsx new file mode 100644 index 00000000..70c3afab --- /dev/null +++ b/app/[locale]/(app)/premium/page.tsx @@ -0,0 +1,34 @@ +import { Metadata } from "next"; + +import { PremiumUpgradeCard } from "@/features/premium/ui/premium-upgrade-card"; + +export const metadata: Metadata = { + title: "Premium Plans - Train freely, support the mission", + description: + "Join thousands of fitness enthusiasts who believe in open-source training freedom. Support our mission while unlocking advanced features.", + keywords: ["premium", "fitness", "workout", "open-source", "subscription", "training"], + openGraph: { + title: "Premium Plans - Support the Workout.cool Mission 💪", + description: "For passionate fitness enthusiasts who believe in open-source and training freedom. Core features always free!", + type: "website", + }, + twitter: { + card: "summary_large_image", + title: "Premium Plans - Workout.cool", + description: "Train freely, support the mission. Join the passionate fitness community!", + }, +}; + +export default function PremiumPage() { + return ( +
+ {/* Main Content */} +
+ +
+ + {/* Mobile Sticky CTA */} + {/* */} +
+ ); +} diff --git a/app/[locale]/(app)/profile/page.tsx b/app/[locale]/(app)/profile/page.tsx new file mode 100644 index 00000000..7c710480 --- /dev/null +++ b/app/[locale]/(app)/profile/page.tsx @@ -0,0 +1,67 @@ +"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"; +import { env } from "@/env"; +import { BodyWeightInput } from "@/features/profile/ui/body-weight-input"; +import { WorkoutEquipmentInput } from "@/features/profile/ui/workout-equipment-input"; +import { useCurrentSession } from "@/entities/user/model/useCurrentSession"; +import { LocalAlert } from "@/components/ui/local-alert"; +import { Button } from "@/components/ui/button"; +import { HorizontalTopBanner } from "@/components/ads"; + +export default function ProfilePage() { + const router = useRouter(); + const t = useI18n(); + const { data: sessions = [] } = useWorkoutSessions(); + const session = useCurrentSession(); + + const values: Record = {}; + sessions.forEach((session) => { + const date = session.startedAt.slice(0, 10); + values[date] = (values[date] || 0) + 1; + }); + + const until = + sessions.length > 0 + ? sessions.reduce((max, s) => (s.startedAt > max ? s.startedAt : max), sessions[0].startedAt).slice(0, 10) + : new Date().toISOString().slice(0, 10); + + return ( +
+ {env.NEXT_PUBLIC_TOP_PROFILE_BANNER_AD_SLOT && } + {!session && } + {session && ( +
+
+

Hello, {session.user?.name} 👋

+
+
+ )} + + {session && ( +
+ +
+ )} + {session && ( +
+ +
+ )} + +
+ +
+ +
+ +
+
+ ); +} diff --git a/app/[locale]/(app)/programs/[slug]/page.tsx b/app/[locale]/(app)/programs/[slug]/page.tsx new file mode 100644 index 00000000..81bcb913 --- /dev/null +++ b/app/[locale]/(app)/programs/[slug]/page.tsx @@ -0,0 +1,106 @@ +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"; +import { getProgramDescription, getProgramTitle } from "@/features/programs/lib/translations-mapper"; +import { generateProgramSEOKeywords } from "@/features/programs/lib/program-metadata"; +import { getProgramBySlug } from "@/features/programs/actions/get-program-by-slug.action"; +import { auth } from "@/features/auth/lib/better-auth"; + +interface ProgramDetailPageProps { + params: Promise<{ slug: string; locale: Locale }>; +} + +export async function generateMetadata({ params }: ProgramDetailPageProps): Promise { + const { slug, locale } = await params; + const t = await getI18n(); + const program = await getProgramBySlug(slug); + const localizedData = getLocalizedMetadata(locale); + + if (!program) { + return { title: t("programs.not_found") }; + } + + const localizedTitle = getProgramTitle(program, locale); + const localizedDescription = getProgramDescription(program, locale); + const seoKeywords = generateProgramSEOKeywords(program, locale, t); + + return { + title: `${localizedTitle} - ${localizedData.title}`, + description: localizedDescription, + keywords: seoKeywords, + openGraph: { + title: `${localizedTitle} - ${localizedData.title}`, + description: localizedDescription, + images: [ + { + url: program.image, // TODO: specific opengraph image for each program (upload admin side) + width: 400, + height: 600, + alt: localizedTitle, + }, + ], + }, + twitter: { + card: "summary_large_image", + title: `${localizedTitle} - ${localizedData.title}`, + description: localizedDescription, + images: [program.image], + }, + }; +} + +export default async function ProgramDetailPageRoute({ params }: ProgramDetailPageProps) { + const { slug, locale } = await params; + const program = await getProgramBySlug(slug); + + if (!program) { + notFound(); + } + + const session = await auth.api.getSession({ + headers: await headers(), + }); + + // Generate Course structured data + const localizedTitle = getProgramTitle(program, locale); + const localizedDescription = getProgramDescription(program, locale); + + const courseStructuredData = generateStructuredData({ + type: "Course", + locale, + title: localizedTitle, + description: localizedDescription, + courseData: { + id: program.id, + level: program.level, + category: program.category, + durationWeeks: program.durationWeeks, + sessionsPerWeek: program.sessionsPerWeek, + sessionDurationMin: program.sessionDurationMin, + equipment: program.equipment, + isPremium: program.isPremium, + participantCount: program.participantCount, + totalSessions: program.weeks.reduce((acc, week) => acc + week.sessions.length, 0), + totalExercises: program.weeks.reduce( + (acc, week) => acc + week.sessions.reduce((sessAcc, session) => sessAcc + session.totalExercises, 0), + 0, + ), + coaches: program.coaches, + }, + }); + + // Breadcrumbs + + return ( + <> + + + + ); +} diff --git a/app/[locale]/(app)/programs/[slug]/session/[sessionSlug]/ProgramSessionClient.tsx b/app/[locale]/(app)/programs/[slug]/session/[sessionSlug]/ProgramSessionClient.tsx new file mode 100644 index 00000000..f831e64e --- /dev/null +++ b/app/[locale]/(app)/programs/[slug]/session/[sessionSlug]/ProgramSessionClient.tsx @@ -0,0 +1,290 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { ArrowLeft, Play } from "lucide-react"; +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"; +import { useWorkoutSession } from "@/features/workout-session/model/use-workout-session"; +import { SessionAccessGuard } from "@/features/programs/ui/session-access-guard"; +import { getSessionDescription, getSessionTitle, getSessionSlug, getProgramTitle } from "@/features/programs/lib/translations-mapper"; +import { startProgramSession } from "@/features/programs/actions/start-program-session.action"; +import { enrollInProgram } from "@/features/programs/actions/enroll-program.action"; +import { completeProgramSession } from "@/features/programs/actions/complete-program-session.action"; +import { ProgramSessionWithExercises } from "@/entities/program-session/types/program-session.types"; +import { ProgramI18nReference } from "@/entities/program/types/program.types"; +import { Button } from "@/components/ui/button"; +import { SessionRichSnippets } from "@/components/seo/session-rich-snippets"; + +interface ProgramSessionClientProps { + program: ProgramI18nReference; + week: ProgramWeek; + session: ProgramSessionWithExercises; + isAuthenticated: boolean; + isPremium: boolean; +} + +export function ProgramSessionClient({ program, week, session, isAuthenticated, isPremium }: ProgramSessionClientProps) { + const t = useI18n(); + const locale = useCurrentLocale(); + const router = useRouter(); + const { startWorkout, session: workoutSession, completeWorkout, isWorkoutActive, quitWorkout } = useWorkoutSession(); + const [isLoading, setIsLoading] = useState(false); + const [_enrollmentId, setEnrollmentId] = useState(null); + const [sessionProgressId, setSessionProgressId] = useState(null); + const [showCongrats, setShowCongrats] = useState(false); + const [hasStartedWorkout, setHasStartedWorkout] = useState(false); + + const programTitle = getProgramTitle(program, locale); + const programSessionTitle = getSessionTitle(session, locale); + const programSessionDescription = getSessionDescription(session, locale); + const programSlug = getSessionSlug(program, locale); + const sessionSlug = getSessionSlug(session, locale); + + // Access control context + const accessContext = { + isAuthenticated, + isPremium, + isSessionPremium: session.isPremium, + }; + + const handleStartWorkout = async () => { + if (!canStartSession(accessContext)) return; + + setIsLoading(true); + try { + // Ensure user is enrolled + const { enrollment } = await enrollInProgram(program.id); + setEnrollmentId(enrollment.id); + + // Start or resume session + const { sessionProgress } = await startProgramSession(enrollment.id, session.id); + setSessionProgressId(sessionProgress.id); + + // Convert program exercises to workout format + const exercises = session.exercises.map((ex) => ({ + id: ex.exercise.id, + name: ex.exercise.name, + nameEn: ex.exercise.nameEn || null, + description: ex.exercise.description || "", + descriptionEn: ex.exercise.descriptionEn || "", + fullVideoUrl: ex.exercise.fullVideoUrl || null, + fullVideoImageUrl: ex.exercise.fullVideoImageUrl || null, + introduction: null, + introductionEn: null, + slug: null, + slugEn: null, + createdAt: new Date(), + updatedAt: new Date(), + order: ex.order, + attributes: ex.exercise.attributes.map((attr) => ({ + id: attr.id, + createdAt: new Date(), + updatedAt: new Date(), + exerciseId: ex.exercise.id, + attributeNameId: attr.attributeNameId, + attributeValueId: attr.attributeValueId, + attributeName: attr.attributeName, + attributeValue: attr.attributeValue, + })), + })); + + // Extract equipment and muscles from session exercises + const equipment = session.exercises.flatMap((ex) => + ex.exercise.attributes + .filter((attr) => attr.attributeName === ExerciseAttributeNameEnum.EQUIPMENT) + .map((attr) => attr.attributeValue), + ); + + const muscles = session.exercises.flatMap((ex) => + ex.exercise.attributes + .filter((attr) => attr.attributeName === ExerciseAttributeNameEnum.PRIMARY_MUSCLE) + .map((attr) => attr.attributeValue), + ); + + // Convert suggestedSets to workout format + const exercisesWithSets = exercises.map((exercise, idx) => { + const programExercise = session.exercises[idx]; + const suggestedSets = programExercise?.suggestedSets || []; + + const workoutSets = suggestedSets.map((suggestedSet, setIndex) => ({ + id: `${exercise.id}-set-${setIndex + 1}`, + setIndex, + types: suggestedSet.types || [], + valuesInt: suggestedSet.valuesInt || [], + valuesSec: suggestedSet.valuesSec || [], + units: suggestedSet.units || [], + completed: false, + })); + + return { + ...exercise, + sets: + workoutSets.length > 0 + ? workoutSets + : [ + { + id: `${exercise.id}-set-1`, + setIndex: 0, + types: ["REPS"], + valuesInt: [], + valuesSec: [], + units: [], + completed: false, + }, + ], + }; + }); + + startWorkout(exercisesWithSets, equipment, muscles); + setHasStartedWorkout(true); + } catch (error) { + console.error("Failed to start session:", error); + alert(t("programs.error_starting_session")); + } finally { + setIsLoading(false); + } + }; + + const handleCompleteSession = async () => { + if (!workoutSession || !sessionProgressId) return; + + try { + // Complete the workout + completeWorkout(); + + // Save to database and mark session as complete + const { isCompleted, nextWeek, nextSession } = await completeProgramSession(sessionProgressId, workoutSession.id); + + setShowCongrats(true); + + if (isCompleted) { + router.push(`/programs/${programSlug}?completed=true&refresh=${Date.now()}`); + } else { + router.push(`/programs/${programSlug}?week=${nextWeek}&session=${nextSession}&refresh=${Date.now()}`); + } + } catch (error) { + console.error("Failed to complete session:", error); + } + }; + + const handleQuitWorkout = () => { + quitWorkout(); + setHasStartedWorkout(false); + }; + + // Show workout interface if user has started the workout + if (hasStartedWorkout && isWorkoutActive && workoutSession) { + return ( +
+ + +
+ ); + } + + const totalSets = session.exercises.reduce((total, ex) => total + ex.suggestedSets.length, 0); + + // Use access guard to handle authentication and premium restrictions + return ( + +
+ {/* Header */} +
+
+ +
+

+ {programTitle} - {t("programs.week")} {week.weekNumber} +

+

{programSessionTitle}

+
+
+
+ + {/* Session preview content */} +
+
+
+ {/* Session info */} +
+
+

{programSessionTitle}

+ +
+ {programSessionDescription &&

{programSessionDescription}

} +
+ + {/* Exercise list */} +
+

{t("programs.exercises_in_session")}

+
+ {session.exercises.map((exercise, index) => { + const exerciseName = locale === "fr" ? exercise.exercise.name : exercise.exercise.nameEn; + return ( +
+
+ {index + 1} +
+
+

{exerciseName}

+
+
+ + {exercise.suggestedSets.length} {t("programs.set", { count: exercise.suggestedSets.length })} + +
+
+ ); + })} +
+
+ + {/* Start workout button */} +
+ +
+
+
+
+
+
+ ); +} diff --git a/app/[locale]/(app)/programs/[slug]/session/[sessionSlug]/page.tsx b/app/[locale]/(app)/programs/[slug]/session/[sessionSlug]/page.tsx new file mode 100644 index 00000000..340aff3e --- /dev/null +++ b/app/[locale]/(app)/programs/[slug]/session/[sessionSlug]/page.tsx @@ -0,0 +1,158 @@ +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 { getSessionTitle, getProgramTitle } from "@/features/programs/lib/translations-mapper"; +import { generateSessionMetadata } from "@/features/programs/lib/session-metadata"; +import { getSessionBySlug } from "@/features/programs/actions/get-session-by-slug.action"; +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 }>; +} + +export async function generateMetadata({ params }: SessionDetailPageProps): Promise { + const { slug, sessionSlug, locale } = await params; + const t = await getI18n(); + const response = await getSessionBySlug(slug, sessionSlug, locale); + + if (!response) { + return { title: t("programs.not_found") }; + } + + const sessionMetadata = generateSessionMetadata(response.session, response.program, locale); + const imageUrl = response.session.exercises[0]?.exercise.fullVideoImageUrl || "/images/default-workout.jpg"; + + return { + title: sessionMetadata.title, + description: sessionMetadata.description, + keywords: sessionMetadata.keywords, + openGraph: { + title: sessionMetadata.title, + description: sessionMetadata.description, + url: `https://www.workout.cool/${locale}/programs/${slug}/session/${sessionSlug}`, + siteName: "Workout Cool", + images: [ + { + url: imageUrl, + width: 800, + height: 600, + alt: sessionMetadata.title, + }, + ], + locale: locale === "zh-CN" ? "zh_CN" : locale.replace("-", "_"), + type: "website", + }, + twitter: { + card: "summary_large_image", + title: sessionMetadata.title, + description: sessionMetadata.description, + images: [imageUrl], + creator: "@WorkoutCool", + }, + alternates: { + canonical: `https://www.workout.cool/${locale}/programs/${slug}/session/${sessionSlug}`, + languages: { + "fr-FR": `https://www.workout.cool/fr/programs/${slug}/session/${sessionSlug}`, + "en-US": `https://www.workout.cool/en/programs/${slug}/session/${sessionSlug}`, + "es-ES": `https://www.workout.cool/es/programs/${slug}/session/${sessionSlug}`, + "pt-PT": `https://www.workout.cool/pt/programs/${slug}/session/${sessionSlug}`, + "ru-RU": `https://www.workout.cool/ru/programs/${slug}/session/${sessionSlug}`, + "zh-CN": `https://www.workout.cool/zh-CN/programs/${slug}/session/${sessionSlug}`, + "x-default": `https://www.workout.cool/programs/${slug}/session/${sessionSlug}`, + }, + }, + robots: { + index: true, + follow: true, + googleBot: { + index: true, + follow: true, + "max-video-preview": -1, + "max-image-preview": "large", + "max-snippet": -1, + }, + }, + }; +} + +export default async function SessionDetailPage({ params }: SessionDetailPageProps) { + const { slug, sessionSlug, locale } = await params; + const response = await getSessionBySlug(slug, sessionSlug, locale); + + if (!response) { + notFound(); + } + + const authSession = await auth.api.getSession({ + headers: await headers(), + }); + + // Pass authentication and premium status + const isAuthenticated = !!authSession?.user; + const isPremium = authSession?.user?.isPremium || false; + + const t = await getI18n(); + const sessionTitle = getSessionTitle(response.session, locale); + const programTitle = getProgramTitle(response.program, locale); + + // Generate breadcrumb items + const breadcrumbItems = [ + { + label: t("breadcrumbs.home"), + href: `/${locale}`, + }, + { + label: t("programs.workout_programs"), + href: `/${locale}/programs`, + }, + { + label: programTitle, + href: `/${locale}/programs/${slug}`, + }, + { + label: sessionTitle, + current: true, + }, + ]; + + // Generate VideoObject structured data + const sessionStructuredData = generateStructuredData({ + type: "VideoObject", + locale, + title: `${sessionTitle} - ${programTitle}`, + description: response.session.description || `${sessionTitle} workout session`, + url: `https://www.workout.cool/${locale}/programs/${slug}/session/${sessionSlug}`, + image: response.session.exercises[0]?.exercise.fullVideoImageUrl || undefined, + sessionData: { + duration: Math.round(response.session.exercises.length * 3), // Estimate 3 min per exercise + exercises: response.session.exercises.map((ex) => ({ + name: ex.exercise.name, + sets: ex.suggestedSets.length, + })), + thumbnailUrl: response.session.exercises[0]?.exercise.fullVideoImageUrl || undefined, + videoUrl: response.session.exercises[0]?.exercise.fullVideoUrl || undefined, + }, + }); + + return ( + <> + + + + + ); +} diff --git a/app/[locale]/(app)/programs/page.tsx b/app/[locale]/(app)/programs/page.tsx new file mode 100644 index 00000000..8e5631e3 --- /dev/null +++ b/app/[locale]/(app)/programs/page.tsx @@ -0,0 +1,34 @@ +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"; + +export const metadata: Metadata = { + title: "Programmes", + description: "Découvrez nos programmes d'entraînement gamifiés pour tous les niveaux - Rejoins la communauté WorkoutCool !", +}; + +export default async function ProgramsRootPage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = (await params) as { locale: Locale }; + const t = await getI18n(); + + const breadcrumbItems = [ + { + label: t("breadcrumbs.home"), + href: `/${locale}`, + }, + { + label: t("programs.workout_programs"), + current: true, + }, + ]; + + return ( + <> + + + + ); +} diff --git a/app/[locale]/(app)/sponsor/onboarding/page.tsx b/app/[locale]/(app)/sponsor/onboarding/page.tsx new file mode 100644 index 00000000..21f49fc4 --- /dev/null +++ b/app/[locale]/(app)/sponsor/onboarding/page.tsx @@ -0,0 +1,33 @@ +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 = { + title: "Sponsor Onboarding | Workout Cool", + description: "Complete your sponsorship setup on Workout.cool", + robots: { index: false, follow: false }, +}; + +export default async function SponsorOnboardingPage() { + const t = await getI18n(); + + return ( +
+
+
+
+ +

{t("ads.onboarding_title")}

+
+

{t("ads.onboarding_description")}

+
+ +
+ +
+
+
+ ); +} diff --git a/app/[locale]/(app)/statistics/page.tsx b/app/[locale]/(app)/statistics/page.tsx new file mode 100644 index 00000000..8148a7d5 --- /dev/null +++ b/app/[locale]/(app)/statistics/page.tsx @@ -0,0 +1,49 @@ +import React from "react"; + +import { getI18n } from "locales/server"; +import { OverallStatsSummary } from "@/features/statistics/components/OverallStatsSummary"; +import { ExercisesBrowser } from "@/features/statistics/components/ExercisesBrowser"; +import { PremiumGate } from "@/components/ui/premium-gate"; + +export default async function StatisticsPage() { + const t = await getI18n(); + return ( +
+ + +
+

+ {t("statistics.title")} +

+

{t("statistics.page_subtitle")}

+ + {/* Stats hero social proof */} + +
+

15.4K+

+

{t("statistics.active_daily_users")}

+
+
+

89%

+

{t("statistics.success_rate")}

+
+
+

4.8★

+

{t("statistics.user_rating")}

+
+
+ } + feature="Statistics" + > + {/* this is the premium content ↓ */} +
+ +
+ + {/* Main Content */} + +
+ ); +} diff --git a/app/[locale]/(app)/tools/bmi-calculator/bmi-calculator.utils.ts b/app/[locale]/(app)/tools/bmi-calculator/bmi-calculator.utils.ts new file mode 100644 index 00000000..061aa70b --- /dev/null +++ b/app/[locale]/(app)/tools/bmi-calculator/bmi-calculator.utils.ts @@ -0,0 +1,304 @@ +import { TFunction } from "locales/client"; + +export type UnitSystem = "metric" | "imperial"; + +export interface BmiData { + height: number; // cm for metric, inches for imperial + weight: number; // kg for metric, lbs for imperial + unit: UnitSystem; +} + +export interface BmiResult { + bmi: number; + bmiPrime: number; + ponderalIndex: number; + category: BmiCategory; + healthRisk: HealthRisk; + recommendations: string[]; + detailedInfo: { + bmiRange: { min: number; max: number }; + idealWeight: { min: number; max: number }; + weightToLose?: number; + weightToGain?: number; + }; +} + +export type BmiCategory = + | "severe_thinness" + | "moderate_thinness" + | "mild_thinness" + | "normal" + | "overweight" + | "obese_class_1" + | "obese_class_2" + | "obese_class_3"; + +export type HealthRisk = "low" | "normal" | "increased" | "high" | "very_high" | "extremely_high"; + +export function calculateBmi(data: BmiData, t: TFunction): BmiResult { + const { height: initialHeight, weight: initialWeight, unit } = data; + let height = initialHeight; + let weight = initialWeight; + + // Convert to metric if needed + if (unit === "imperial") { + height = height * 2.54; // inches to cm + weight = weight * 0.453592; // lbs to kg + } + + // Convert height from cm to meters + const heightInMeters = height / 100; + + // Calculate BMI + const bmi = weight / (heightInMeters * heightInMeters); + + // Calculate BMI Prime + const bmiPrime = bmi / 25; + + // Calculate Ponderal Index + const ponderalIndex = weight / (heightInMeters * heightInMeters * heightInMeters); + + // Determine category and health risk + const category = getBmiCategory(bmi); + const healthRisk = getHealthRisk(category); + const recommendations = getRecommendations(category, t); + + // Calculate detailed info + const bmiRange = getBmiRange(category); + const idealWeight = calculateIdealWeight(heightInMeters); + const weightToLose = (category === "overweight" || category === "obese_class_1" || category === "obese_class_2" || category === "obese_class_3") + ? Math.max(0, weight - idealWeight.max) + : undefined; + const weightToGain = (category === "severe_thinness" || category === "moderate_thinness" || category === "mild_thinness") + ? Math.max(0, idealWeight.min - weight) + : undefined; + + return { + bmi: Math.round(bmi * 10) / 10, + bmiPrime: Math.round(bmiPrime * 100) / 100, + ponderalIndex: Math.round(ponderalIndex * 10) / 10, + category, + healthRisk, + recommendations, + detailedInfo: { + bmiRange, + idealWeight, + weightToLose, + weightToGain, + }, + }; +} + +export function getBmiCategory(bmi: number): BmiCategory { + if (bmi < 16) return "severe_thinness"; + if (bmi < 17) return "moderate_thinness"; + if (bmi < 18.5) return "mild_thinness"; + if (bmi < 25) return "normal"; + if (bmi < 30) return "overweight"; + if (bmi < 35) return "obese_class_1"; + if (bmi < 40) return "obese_class_2"; + return "obese_class_3"; +} + +export function getHealthRisk(category: BmiCategory): HealthRisk { + switch (category) { + case "severe_thinness": + return "very_high"; + case "moderate_thinness": + return "high"; + case "mild_thinness": + return "increased"; + case "normal": + return "normal"; + case "overweight": + return "increased"; + case "obese_class_1": + return "high"; + case "obese_class_2": + return "very_high"; + case "obese_class_3": + return "extremely_high"; + default: + return "normal"; + } +} + +export function getRecommendations(category: BmiCategory, t: TFunction): string[] { + switch (category) { + case "severe_thinness": + return [ + t("bmi-calculator.recommendations.severe_thinness.medical_consultation"), + t("bmi-calculator.recommendations.severe_thinness.nutritional_assessment"), + t("bmi-calculator.recommendations.severe_thinness.weight_gain_program"), + t("bmi-calculator.recommendations.severe_thinness.screen_conditions"), + t("bmi-calculator.recommendations.severe_thinness.psychological_evaluation"), + ]; + case "moderate_thinness": + return [ + t("bmi-calculator.recommendations.moderate_thinness.healthcare_provider"), + t("bmi-calculator.recommendations.moderate_thinness.nutrient_dense_foods"), + t("bmi-calculator.recommendations.moderate_thinness.registered_dietitian"), + t("bmi-calculator.recommendations.moderate_thinness.monitor_malnutrition"), + t("bmi-calculator.recommendations.moderate_thinness.gradual_weight_gain"), + ]; + case "mild_thinness": + return [ + t("bmi-calculator.recommendations.mild_thinness.consider_healthcare"), + t("bmi-calculator.recommendations.mild_thinness.nutrient_dense_foods"), + t("bmi-calculator.recommendations.mild_thinness.strength_training"), + t("bmi-calculator.recommendations.mild_thinness.monitor_health"), + t("bmi-calculator.recommendations.mild_thinness.gradual_weight_gain"), + ]; + case "normal": + return [ + t("bmi-calculator.recommendations.normal.maintain_weight"), + t("bmi-calculator.recommendations.normal.physical_activity"), + t("bmi-calculator.recommendations.normal.balanced_diet"), + t("bmi-calculator.recommendations.normal.health_checkups"), + t("bmi-calculator.recommendations.normal.overall_wellness"), + ]; + case "overweight": + return [ + t("bmi-calculator.recommendations.overweight.gradual_weight_loss"), + t("bmi-calculator.recommendations.overweight.increase_activity"), + t("bmi-calculator.recommendations.overweight.portion_control"), + t("bmi-calculator.recommendations.overweight.healthcare_provider"), + t("bmi-calculator.recommendations.overweight.lifestyle_goals"), + ]; + case "obese_class_1": + return [ + t("bmi-calculator.recommendations.obese_class_1.healthcare_provider"), + t("bmi-calculator.recommendations.obese_class_1.weight_loss_target"), + t("bmi-calculator.recommendations.obese_class_1.diet_exercise"), + t("bmi-calculator.recommendations.obese_class_1.nutritional_counseling"), + t("bmi-calculator.recommendations.obese_class_1.screen_conditions"), + ]; + case "obese_class_2": + return [ + t("bmi-calculator.recommendations.obese_class_2.medical_supervision"), + t("bmi-calculator.recommendations.obese_class_2.lifestyle_programs"), + t("bmi-calculator.recommendations.obese_class_2.evaluate_conditions"), + t("bmi-calculator.recommendations.obese_class_2.medical_treatments"), + t("bmi-calculator.recommendations.obese_class_2.bariatric_surgery"), + ]; + case "obese_class_3": + return [ + t("bmi-calculator.recommendations.obese_class_3.medical_consultation"), + t("bmi-calculator.recommendations.obese_class_3.bariatric_surgery"), + t("bmi-calculator.recommendations.obese_class_3.weight_management"), + t("bmi-calculator.recommendations.obese_class_3.health_complications"), + t("bmi-calculator.recommendations.obese_class_3.multidisciplinary"), + ]; + default: + return []; + } +} + +export function getBmiRange(category: BmiCategory): { min: number; max: number } { + switch (category) { + case "severe_thinness": + return { min: 0, max: 15.9 }; + case "moderate_thinness": + return { min: 16, max: 16.9 }; + case "mild_thinness": + return { min: 17, max: 18.4 }; + case "normal": + return { min: 18.5, max: 24.9 }; + case "overweight": + return { min: 25, max: 29.9 }; + case "obese_class_1": + return { min: 30, max: 34.9 }; + case "obese_class_2": + return { min: 35, max: 39.9 }; + case "obese_class_3": + return { min: 40, max: 100 }; + default: + return { min: 0, max: 100 }; + } +} + +export function calculateIdealWeight(heightInMeters: number): { min: number; max: number } { + // Calculate ideal weight range based on normal BMI (18.5-24.9) + const minWeight = 18.5 * heightInMeters * heightInMeters; + const maxWeight = 24.9 * heightInMeters * heightInMeters; + + return { + min: Math.round(minWeight * 10) / 10, + max: Math.round(maxWeight * 10) / 10, + }; +} + +export function convertHeight(height: number, fromUnit: UnitSystem, toUnit: UnitSystem): number { + if (fromUnit === toUnit) return height; + + if (fromUnit === "imperial" && toUnit === "metric") { + return height * 2.54; // inches to cm + } else { + return height / 2.54; // cm to inches + } +} + +export function convertWeight(weight: number, fromUnit: UnitSystem, toUnit: UnitSystem): number { + if (fromUnit === toUnit) return weight; + + if (fromUnit === "imperial" && toUnit === "metric") { + return weight * 0.453592; // lbs to kg + } else { + return weight / 0.453592; // kg to lbs + } +} + +// Additional utility functions for enhanced BMI analysis + +export function getBmiPrimeCategory(bmiPrime: number): string { + if (bmiPrime < 0.64) return "severe_thinness"; + if (bmiPrime < 0.68) return "moderate_thinness"; + if (bmiPrime < 0.74) return "mild_thinness"; + if (bmiPrime <= 1) return "normal"; + if (bmiPrime <= 1.2) return "overweight"; + if (bmiPrime <= 1.4) return "obese_class_1"; + if (bmiPrime <= 1.6) return "obese_class_2"; + return "obese_class_3"; +} + +export function getPonderalIndexCategory(pi: number): string { + // Ponderal Index normal range is typically 11-14 kg/m³ + if (pi < 11) return "low"; + if (pi <= 14) return "normal"; + return "high"; +} + +export function getHealthRisks(_category: BmiCategory, t: TFunction): { overweight: string[]; underweight: string[] } { + const overweightRisks = [ + t("bmi-calculator.health_risks.overweight.high_blood_pressure"), + t("bmi-calculator.health_risks.overweight.ldl_cholesterol"), + t("bmi-calculator.health_risks.overweight.hdl_cholesterol"), + t("bmi-calculator.health_risks.overweight.triglycerides"), + t("bmi-calculator.health_risks.overweight.type_2_diabetes"), + t("bmi-calculator.health_risks.overweight.coronary_heart_disease"), + t("bmi-calculator.health_risks.overweight.stroke"), + t("bmi-calculator.health_risks.overweight.gallbladder_disease"), + t("bmi-calculator.health_risks.overweight.osteoarthritis"), + t("bmi-calculator.health_risks.overweight.sleep_apnea"), + t("bmi-calculator.health_risks.overweight.certain_cancers"), + t("bmi-calculator.health_risks.overweight.low_quality_life"), + t("bmi-calculator.health_risks.overweight.mental_illnesses"), + t("bmi-calculator.health_risks.overweight.body_pains"), + t("bmi-calculator.health_risks.overweight.increased_mortality"), + ]; + + const underweightRisks = [ + t("bmi-calculator.health_risks.underweight.malnutrition"), + t("bmi-calculator.health_risks.underweight.anemia"), + t("bmi-calculator.health_risks.underweight.osteoporosis"), + t("bmi-calculator.health_risks.underweight.immune_function"), + t("bmi-calculator.health_risks.underweight.growth_development"), + t("bmi-calculator.health_risks.underweight.reproductive_issues"), + t("bmi-calculator.health_risks.underweight.miscarriage_risk"), + t("bmi-calculator.health_risks.underweight.surgery_complications"), + t("bmi-calculator.health_risks.underweight.increased_mortality"), + t("bmi-calculator.health_risks.underweight.underlying_conditions"), + ]; + + return { overweight: overweightRisks, underweight: underweightRisks }; +} \ No newline at end of file diff --git a/app/[locale]/(app)/tools/bmi-calculator/page.tsx b/app/[locale]/(app)/tools/bmi-calculator/page.tsx new file mode 100644 index 00000000..340e7c3b --- /dev/null +++ b/app/[locale]/(app)/tools/bmi-calculator/page.tsx @@ -0,0 +1,128 @@ +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 { generateSEOMetadata, SEOScripts } from "@/components/seo/SEOHead"; +import { HorizontalBottomBanner, HorizontalTopBanner } from "@/components/ads"; + +export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise { + const { locale } = await params; + const t = await getI18n(); + + return generateSEOMetadata({ + title: t("tools.bmi-calculator-hub.meta.title"), + description: t("tools.bmi-calculator-hub.meta.description"), + keywords: [ + ...t("tools.bmi-calculator-hub.meta.keywords").split(", "), + "BMI formula", + "BMI prime", + "ponderal index", + "WHO BMI classification", + "CDC BMI percentiles", + "BMI health risks", + "BMI limitations", + "body mass index calculator", + "BMI chart", + "BMI table", + "overweight risks", + "underweight risks", + "BMI for adults", + "BMI for children", + "BMI accuracy", + ], + locale, + canonical: `${getServerUrl()}/${locale}/tools/bmi-calculator`, + structuredData: { + type: "Calculator", + calculatorData: { + calculatorType: "bmi", + inputFields: ["height", "weight", "age", "gender"], + outputFields: [ + "BMI", + "BMI Prime", + "Ponderal Index", + "BMI category", + "health risk assessment", + "ideal weight range", + "health recommendations", + ], + formula: "BMI = weight (kg) / height (m)²", + accuracy: "Standard WHO classification with detailed health risk assessment", + targetAudience: [ + "health conscious individuals", + "fitness enthusiasts", + "medical professionals", + "general public", + "parents", + "athletes", + ], + relatedCalculators: ["standard-calculator", "adjusted-calculator", "pediatric-calculator", "bmi-comparison"], + }, + }, + }); +} + +export default async function BmiCalculatorPage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + const t = await getI18n(); + + return ( + <> + +
+ {(env.NEXT_PUBLIC_TOP_BMI_BANNER_AD_SLOT || env.NEXT_PUBLIC_EZOIC_TOP_BMI_PLACEMENT_ID) && ( + + )} +
+ {/* Header */} +
+

+ {t("tools.bmi-calculator-hub.standard.page_title")} +

+

+ {t("tools.bmi-calculator-hub.standard.page_description")} +

+
+ + {/* Calculator */} + + + {/* Educational Content */} +
+ +
+
+ {(env.NEXT_PUBLIC_BOTTOM_BMI_BANNER_AD_SLOT || env.NEXT_PUBLIC_EZOIC_BOTTOM_BMI_PLACEMENT_ID) && ( + + )} +
+ + ); +} diff --git a/app/[locale]/(app)/tools/bmi-calculator/shared/BmiCalculatorClient.tsx b/app/[locale]/(app)/tools/bmi-calculator/shared/BmiCalculatorClient.tsx new file mode 100644 index 00000000..5f87533d --- /dev/null +++ b/app/[locale]/(app)/tools/bmi-calculator/shared/BmiCalculatorClient.tsx @@ -0,0 +1,81 @@ +"use client"; + +import React, { useState, useEffect } from "react"; + +import { useI18n } from "locales/client"; +import { + BmiData, + BmiResult, + UnitSystem, + calculateBmi, + convertHeight, + convertWeight, +} from "app/[locale]/(app)/tools/bmi-calculator/bmi-calculator.utils"; + +import { BmiWeightInput } from "./components/BmiWeightInput"; +import { BmiUnitSelector } from "./components/BmiUnitSelector"; +import { BmiResultsDisplay } from "./components/BmiResultsDisplay"; +import { BmiHeightInput } from "./components/BmiHeightInput"; + +export function BmiCalculatorClient() { + const t = useI18n(); + const [unit, setUnit] = useState("metric"); + const [height, setHeight] = useState(170); // cm for metric, inches for imperial + const [weight, setWeight] = useState(70); // kg for metric, lbs for imperial + const [result, setResult] = useState(null); + const [isInitialized, setIsInitialized] = useState(false); + + // Convert values when unit system changes (but not on first render) + useEffect(() => { + if (!isInitialized) { + setIsInitialized(true); + return; + } + + if (unit === "imperial") { + // Convert from metric to imperial + setHeight(Math.round(convertHeight(height, "metric", "imperial"))); + setWeight(Math.round(convertWeight(weight, "metric", "imperial") * 10) / 10); + } else { + // Convert from imperial to metric + setHeight(Math.round(convertHeight(height, "imperial", "metric"))); + setWeight(Math.round(convertWeight(weight, "imperial", "metric") * 10) / 10); + } + }, [unit]); + + // Calculate BMI whenever inputs change + useEffect(() => { + if (height > 0 && weight > 0) { + const bmiData: BmiData = { height, weight, unit }; + const bmiResult = calculateBmi(bmiData, t); + setResult(bmiResult); + } else { + setResult(null); + } + }, [height, weight, unit]); + + return ( +
+ {/* Input Form */} +
+
+ {/* Unit Selector */} + + + {/* Height and Weight Inputs */} +
+ + +
+
+
+ + {/* Results */} + {result && ( +
+ +
+ )} +
+ ); +} diff --git a/app/[locale]/(app)/tools/bmi-calculator/shared/components/BmiEducationalContent.tsx b/app/[locale]/(app)/tools/bmi-calculator/shared/components/BmiEducationalContent.tsx new file mode 100644 index 00000000..b356c05e --- /dev/null +++ b/app/[locale]/(app)/tools/bmi-calculator/shared/components/BmiEducationalContent.tsx @@ -0,0 +1,407 @@ +"use client"; + +import { useI18n } from "locales/client"; +import { env } from "@/env"; +import { InArticle } from "@/components/ads"; + +import { FormulaCard, createFraction, createSuperscript } from "./MathEquation"; + +export function BmiEducationalContent() { + const t = useI18n(); + + return ( +
+ {/* BMI Introduction */} +
+

{t("bmi-calculator.educational.introduction_title")}

+
+

{t("bmi-calculator.educational.introduction_text")}

+

{t("bmi-calculator.educational.introduction_usage")}

+
+
+ + {/* BMI Tables */} +
+
+ {env.NEXT_PUBLIC_IN_ARTICLE_BMI_1_AD_SLOT && } +

{t("bmi-calculator.educational.adult_table_title")}

+ +

{t("bmi-calculator.educational.adult_table_description")}

+ + {/* WHO Adult BMI Table */} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{t("bmi-calculator.educational.classification")}{t("bmi-calculator.educational.bmi_range")}
{t("bmi-calculator.category_severe_thinness")}< 16
{t("bmi-calculator.category_moderate_thinness")}16 - 17
{t("bmi-calculator.category_mild_thinness")}17 - 18.5
{t("bmi-calculator.category_normal")}18.5 - 25
{t("bmi-calculator.category_overweight")}25 - 30
{t("bmi-calculator.category_obese_class_1")}30 - 35
{t("bmi-calculator.category_obese_class_2")}35 - 40
{t("bmi-calculator.category_obese_class_3")}> 40
+
+
+ + {/* Children BMI Table */} +
+

{t("bmi-calculator.educational.children_table_title")}

+

{t("bmi-calculator.educational.children_table_description")}

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
{t("bmi-calculator.educational.category")}{t("bmi-calculator.educational.percentile_range")}
{t("bmi-calculator.educational.underweight")}< 5%
{t("bmi-calculator.educational.healthy_weight")}5% - 85%
{t("bmi-calculator.educational.at_risk_overweight")}85% - 95%
{t("bmi-calculator.educational.overweight")}> 95%
+
+
+
+ + {/* Health Risks */} +
+
+

{t("bmi-calculator.educational.overweight_risks_title")}

+

{t("bmi-calculator.educational.overweight_risks_intro")}

+ +
+
+

{t("bmi-calculator.educational.cardiovascular_risks")}

+
    +
  • + + {t("bmi-calculator.educational.high_blood_pressure")} +
  • +
  • + + {t("bmi-calculator.educational.cholesterol_issues")} +
  • +
  • + + {t("bmi-calculator.educational.coronary_heart_disease")} +
  • +
  • + + {t("bmi-calculator.educational.stroke")} +
  • +
+
+ +
+

{t("bmi-calculator.educational.metabolic_risks")}

+
    +
  • + + {t("bmi-calculator.educational.type_2_diabetes")} +
  • +
  • + + {t("bmi-calculator.educational.gallbladder_disease")} +
  • +
  • + + {t("bmi-calculator.educational.sleep_apnea")} +
  • +
  • + + {t("bmi-calculator.educational.osteoarthritis")} +
  • +
+
+
+ +
+

{t("bmi-calculator.educational.other_risks")}

+
    +
  • + + {t("bmi-calculator.educational.certain_cancers")} +
  • +
  • + + {t("bmi-calculator.educational.mental_health_issues")} +
  • +
  • + + {t("bmi-calculator.educational.reduced_quality_life")} +
  • +
  • + + {t("bmi-calculator.educational.increased_mortality")} +
  • +
+
+
+ + {/* Underweight Risks */} +
+

{t("bmi-calculator.educational.underweight_risks_title")}

+

{t("bmi-calculator.educational.underweight_risks_intro")}

+ +
+
    +
  • + + {t("bmi-calculator.educational.malnutrition")} +
  • +
  • + + {t("bmi-calculator.educational.osteoporosis")} +
  • +
  • + + {t("bmi-calculator.educational.immune_function_decrease")} +
  • +
  • + + {t("bmi-calculator.educational.growth_development_issues")} +
  • +
+
    +
  • + + {t("bmi-calculator.educational.reproductive_issues")} +
  • +
  • + + {t("bmi-calculator.educational.surgery_complications")} +
  • +
  • + + {t("bmi-calculator.educational.increased_mortality_underweight")} +
  • +
+
+
+
+ + {/* BMI Limitations */} +
+

{t("bmi-calculator.limitations_title")}

+
+

{t("bmi-calculator.limitations_text")}

+
+ +
+
+

{t("bmi-calculator.educational.adults_limitations")}

+
    +
  • + + {t("bmi-calculator.educational.older_adults_fat")} +
  • +
  • + + {t("bmi-calculator.educational.women_fat_difference")} +
  • +
  • + + {t("bmi-calculator.educational.athletes_muscle_mass")} +
  • +
+
+ +
+

{t("bmi-calculator.educational.children_limitations")}

+
    +
  • + + {t("bmi-calculator.educational.height_maturation_influence")} +
  • +
  • + + {t("bmi-calculator.educational.fat_free_mass_difference")} +
  • +
  • + + {t("bmi-calculator.educational.population_accuracy")} +
  • +
+
+
+
+ + {/* BMI Formulas */} +
+

{t("bmi-calculator.educational.formulas_title")}

+ +
+ {/* Metric Formula */} + + + {/* Imperial Formula */} + +
+
+ + {env.NEXT_PUBLIC_IN_ARTICLE_BMI_2_AD_SLOT && } + + {/* BMI Prime Section */} +
+

{t("bmi-calculator.about_bmi_prime")}

+
+

{t("bmi-calculator.bmi_prime_explanation")}

+
+ + + + {/* BMI Prime Table */} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ClassificationBMIBMI Prime
{t("bmi-calculator.category_severe_thinness")}< 16< 0.64
{t("bmi-calculator.category_moderate_thinness")}16 - 170.64 - 0.68
{t("bmi-calculator.category_mild_thinness")}17 - 18.50.68 - 0.74
{t("bmi-calculator.category_normal")}18.5 - 250.74 - 1.0
{t("bmi-calculator.category_overweight")}25 - 301.0 - 1.2
{t("bmi-calculator.category_obese_class_1")}30 - 351.2 - 1.4
{t("bmi-calculator.category_obese_class_2")}35 - 401.4 - 1.6
{t("bmi-calculator.category_obese_class_3")}> 40> 1.6
+
+
+ + {/* Ponderal Index */} +
+

{t("bmi-calculator.educational.ponderal_index_title")}

+
+

{t("bmi-calculator.educational.ponderal_index_explanation")}

+
+ +
+ {/* Metric PI Formula */} + + + {/* Imperial PI Formula */} + +
+
+ + {/* Disclaimer */} +
+
+
⚠️
+
+

{t("bmi-calculator.educational.medical_disclaimer_title")}

+

{t("bmi-calculator.disclaimer")}

+
+
+
+
+ ); +} diff --git a/app/[locale]/(app)/tools/bmi-calculator/shared/components/BmiHeightInput.tsx b/app/[locale]/(app)/tools/bmi-calculator/shared/components/BmiHeightInput.tsx new file mode 100644 index 00000000..5c1d1be2 --- /dev/null +++ b/app/[locale]/(app)/tools/bmi-calculator/shared/components/BmiHeightInput.tsx @@ -0,0 +1,90 @@ +"use client"; + +import React from "react"; + +import { useI18n } from "locales/client"; +import { UnitSystem } from "app/[locale]/(app)/tools/bmi-calculator/bmi-calculator.utils"; + +interface BmiHeightInputProps { + value: number; + unit: UnitSystem; + onChange: (height: number) => void; +} + +export function BmiHeightInput({ value, unit, onChange }: BmiHeightInputProps) { + const t = useI18n(); + + // For imperial, we need to handle feet and inches + if (unit === "imperial") { + const totalInches = value; + const feet = Math.floor(totalInches / 12); + const inches = totalInches % 12; + + const handleFeetChange = (newFeet: number) => { + onChange(newFeet * 12 + inches); + }; + + const handleInchesChange = (newInches: number) => { + onChange(feet * 12 + newInches); + }; + + return ( +
+ +
+
+ handleFeetChange(Number(e.target.value))} + type="number" + value={feet} + /> + + {t("bmi-calculator.feet")} + +
+
+ handleInchesChange(Number(e.target.value))} + type="number" + value={inches} + /> + + {t("bmi-calculator.inches")} + +
+
+
+ ); + } + + // Metric - simple cm input + return ( +
+ +
+
+ onChange(Number(e.target.value))} + placeholder={t("bmi-calculator.height_placeholder")} + type="number" + value={value} + /> + + {t("bmi-calculator.cm")} + +
+
+
+ ); +} diff --git a/app/[locale]/(app)/tools/bmi-calculator/shared/components/BmiResultsDisplay.tsx b/app/[locale]/(app)/tools/bmi-calculator/shared/components/BmiResultsDisplay.tsx new file mode 100644 index 00000000..0a2543d6 --- /dev/null +++ b/app/[locale]/(app)/tools/bmi-calculator/shared/components/BmiResultsDisplay.tsx @@ -0,0 +1,279 @@ +"use client"; + +import React from "react"; +import { CheckCircleIcon, AlertTriangleIcon, XCircleIcon, InfoIcon, TrendingUpIcon, TrendingDownIcon } from "lucide-react"; + +import { useI18n } from "locales/client"; +import { BmiResult, BmiCategory, HealthRisk } from "app/[locale]/(app)/tools/bmi-calculator/bmi-calculator.utils"; + +interface BmiResultsDisplayProps { + result: BmiResult; +} + +export function BmiResultsDisplay({ result }: BmiResultsDisplayProps) { + const t = useI18n(); + + const getCategoryColor = (category: BmiCategory) => { + switch (category) { + case "severe_thinness": + return "text-red-700 dark:text-red-500"; + case "moderate_thinness": + return "text-red-600 dark:text-red-400"; + case "mild_thinness": + return "text-blue-600 dark:text-blue-400"; + case "normal": + return "text-green-600 dark:text-green-400"; + case "overweight": + return "text-yellow-600 dark:text-yellow-400"; + case "obese_class_1": + return "text-orange-600 dark:text-orange-400"; + case "obese_class_2": + return "text-red-600 dark:text-red-400"; + case "obese_class_3": + return "text-red-700 dark:text-red-500"; + default: + return "text-gray-600 dark:text-gray-400"; + } + }; + + const getRiskColor = (risk: HealthRisk) => { + switch (risk) { + case "low": + case "normal": + return "text-green-600 dark:text-green-400"; + case "increased": + return "text-yellow-600 dark:text-yellow-400"; + case "high": + return "text-orange-600 dark:text-orange-400"; + case "very_high": + return "text-red-600 dark:text-red-400"; + case "extremely_high": + return "text-red-700 dark:text-red-500"; + default: + return "text-gray-600 dark:text-gray-400"; + } + }; + + const getRiskIcon = (risk: HealthRisk) => { + switch (risk) { + case "low": + case "normal": + return ; + case "increased": + case "high": + return ; + case "very_high": + case "extremely_high": + return ; + default: + return ; + } + }; + + const getBmiGradient = (category: BmiCategory) => { + switch (category) { + case "severe_thinness": + return "from-red-600 to-red-700"; + case "moderate_thinness": + return "from-red-500 to-red-600"; + case "mild_thinness": + return "from-blue-500 to-blue-600"; + case "normal": + return "from-green-500 to-green-600"; + case "overweight": + return "from-yellow-500 to-yellow-600"; + case "obese_class_1": + return "from-orange-500 to-orange-600"; + case "obese_class_2": + return "from-red-500 to-red-600"; + case "obese_class_3": + return "from-red-600 to-red-700"; + default: + return "from-gray-500 to-gray-600"; + } + }; + + return ( +
+ {/* Main Results Grid */} +
+ {/* BMI Value */} +
+
+
+
{result.bmi}
+
{t("bmi-calculator.your_bmi")}
+
+
+
+ + {/* BMI Prime */} +
+
+
+
{result.bmiPrime}
+
{t("bmi-calculator.bmi_prime")}
+
+
+
+ + {/* Ponderal Index */} +
+
+
+
{result.ponderalIndex}
+
{t("bmi-calculator.ponderal_index")}
+
+
+
+
+ + {/* Category and Risk */} +
+
+

{t("bmi-calculator.bmi_category")}

+
+ {t(`bmi-calculator.category_${result.category}` as keyof typeof t)} +
+
+ BMI: {result.detailedInfo.bmiRange.min} - {result.detailedInfo.bmiRange.max} +
+
+ +
+

{t("bmi-calculator.health_risk")}

+
+ {getRiskIcon(result.healthRisk)} + {t(`bmi-calculator.risk_${result.healthRisk}` as keyof typeof t)} +
+
+
+ + {/* Ideal Weight and Weight Goals */} +
+
+

{t("bmi-calculator.ideal_weight")}

+
+ {result.detailedInfo.idealWeight.min} - {result.detailedInfo.idealWeight.max} kg +
+
{t("bmi-calculator.normal_range")}
+
+ + {(result.detailedInfo.weightToLose || result.detailedInfo.weightToGain) && ( +
+

+ {result.detailedInfo.weightToLose ? t("bmi-calculator.weight_to_lose") : t("bmi-calculator.weight_to_gain")} +

+
+ {result.detailedInfo.weightToLose ? : } + {result.detailedInfo.weightToLose || result.detailedInfo.weightToGain} kg +
+
+ )} +
+ + {/* Detailed BMI Range Reference */} +
+

+ {t("bmi-calculator.bmi_range")} (WHO Classification) +

+
+
+ {t("bmi-calculator.category_severe_thinness")} + {"< 16"} +
+
+ {t("bmi-calculator.category_moderate_thinness")} + 16.0 - 16.9 +
+
+ {t("bmi-calculator.category_mild_thinness")} + 17.0 - 18.4 +
+
+ {t("bmi-calculator.category_normal")} + 18.5 - 24.9 +
+
+ {t("bmi-calculator.category_overweight")} + 25.0 - 29.9 +
+
+ {t("bmi-calculator.category_obese_class_1")} + 30.0 - 34.9 +
+
+ {t("bmi-calculator.category_obese_class_2")} + 35.0 - 39.9 +
+
+ {t("bmi-calculator.category_obese_class_3")} + {"≥ 40.0"} +
+
+
+ + {/* BMI Prime Information */} +
+

{t("bmi-calculator.about_bmi_prime")}

+

{t("bmi-calculator.bmi_prime_explanation")}

+
+
+
{"< 0.74"}
+
{t("bmi-calculator.underweight")}
+
+
+
0.74 - 1.0
+
{t("bmi-calculator.normal")}
+
+
+
1.0 - 1.2
+
{t("bmi-calculator.overweight")}
+
+
+
{"> 1.2"}
+
{t("bmi-calculator.obese")}
+
+
+
+ + {/* Recommendations */} +
+

+ {t("bmi-calculator.recommendations_label")} +

+
    + {result.recommendations.map((recommendation, index) => ( +
  • + + {recommendation} +
  • + ))} +
+
+ + {/* BMI Limitations */} +
+
+ +
+

{t("bmi-calculator.limitations_title")}

+

{t("bmi-calculator.limitations_text")}

+
+
+
+ + {/* Disclaimer */} +
+
+ +

{t("bmi-calculator.disclaimer")}

+
+
+
+ ); +} diff --git a/app/[locale]/(app)/tools/bmi-calculator/shared/components/BmiUnitSelector.tsx b/app/[locale]/(app)/tools/bmi-calculator/shared/components/BmiUnitSelector.tsx new file mode 100644 index 00000000..aad06a12 --- /dev/null +++ b/app/[locale]/(app)/tools/bmi-calculator/shared/components/BmiUnitSelector.tsx @@ -0,0 +1,45 @@ +"use client"; + +import React from "react"; + +import { useI18n } from "locales/client"; +import { UnitSystem } from "app/[locale]/(app)/tools/bmi-calculator/bmi-calculator.utils"; + +interface BmiUnitSelectorProps { + value: UnitSystem; + onChange: (unit: UnitSystem) => void; +} + +export function BmiUnitSelector({ value, onChange }: BmiUnitSelectorProps) { + const t = useI18n(); + + return ( +
+ +
+ + +
+
+ ); +} diff --git a/app/[locale]/(app)/tools/bmi-calculator/shared/components/BmiWeightInput.tsx b/app/[locale]/(app)/tools/bmi-calculator/shared/components/BmiWeightInput.tsx new file mode 100644 index 00000000..82058d9a --- /dev/null +++ b/app/[locale]/(app)/tools/bmi-calculator/shared/components/BmiWeightInput.tsx @@ -0,0 +1,42 @@ +"use client"; + +import React from "react"; + +import { useI18n } from "locales/client"; +import { UnitSystem } from "app/[locale]/(app)/tools/bmi-calculator/bmi-calculator.utils"; + +interface BmiWeightInputProps { + value: number; + unit: UnitSystem; + onChange: (weight: number) => void; +} + +export function BmiWeightInput({ value, unit, onChange }: BmiWeightInputProps) { + const t = useI18n(); + const unitLabel = unit === "metric" ? t("bmi-calculator.kg") : t("bmi-calculator.lbs"); + const min = unit === "metric" ? 30 : 66; + const max = unit === "metric" ? 300 : 660; + + return ( +
+ +
+ onChange(Number(e.target.value))} + placeholder={t("bmi-calculator.weight_placeholder")} + step="0.1" + type="number" + value={value} + /> + + {unitLabel} + +
+
+ ); +} diff --git a/app/[locale]/(app)/tools/bmi-calculator/shared/components/MathEquation.tsx b/app/[locale]/(app)/tools/bmi-calculator/shared/components/MathEquation.tsx new file mode 100644 index 00000000..78e1af66 --- /dev/null +++ b/app/[locale]/(app)/tools/bmi-calculator/shared/components/MathEquation.tsx @@ -0,0 +1,72 @@ +"use client"; + +interface MathEquationProps { + equation: string; + display?: boolean; + className?: string; +} + +export function MathEquation({ equation, display = false, className = "" }: MathEquationProps) { + return ( +
+ +
+ ); +} + +interface FormulaCardProps { + title: string; + equation: string; + example?: string; + description?: string; + className?: string; +} + +export function FormulaCard({ title, equation, example, description, className = "" }: FormulaCardProps) { + return ( +
+

{title}

+ +
+
+
+
+
+ + {description &&

{description}

} + + {example && ( +
+

Example:

+
+
+
+
+ )} +
+ ); +} + +// Helper function to create fraction notation +export function createFraction(numerator: string, denominator: string): string { + return ` +
+
${numerator}
+
${denominator}
+
+ `; +} + +// Helper function for superscript +export function createSuperscript(base: string, exponent: string): string { + return `${base}${exponent}`; +} + +// Helper function for subscript +export function createSubscript(base: string, subscript: string): string { + return `${base}${subscript}`; +} diff --git a/app/[locale]/(app)/tools/calorie-calculator/CalorieCalculatorHub.tsx b/app/[locale]/(app)/tools/calorie-calculator/CalorieCalculatorHub.tsx new file mode 100644 index 00000000..2c29cafe --- /dev/null +++ b/app/[locale]/(app)/tools/calorie-calculator/CalorieCalculatorHub.tsx @@ -0,0 +1,247 @@ +"use client"; + +import React from "react"; +import Link from "next/link"; +import { TrendingUpIcon, AwardIcon, TargetIcon, BrainIcon, GlobeIcon, ChartBarIcon } from "lucide-react"; + +import { useI18n } from "locales/client"; +import { env } from "@/env"; +import { HorizontalBottomBanner, HorizontalTopBanner } from "@/components/ads"; + +interface CalculatorFormula { + id: string; + href: string; + icon: React.ReactNode; + year: string; + popularity: number; // 1-5 + accuracy: "high" | "medium" | "good"; + bestFor: string; + gradient: { + from: string; + to: string; + }; +} + +const calculatorFormulas: CalculatorFormula[] = [ + { + id: "mifflin-st-jeor", + href: "/tools/calorie-calculator/mifflin-st-jeor-calculator", + icon: , + year: "1990", + popularity: 5, + accuracy: "high", + bestFor: "general", + gradient: { + from: "from-[#4F8EF7]", + to: "to-[#238BE6]", + }, + }, + { + id: "harris-benedict", + href: "/tools/calorie-calculator/harris-benedict-calculator", + icon: , + year: "1984", + popularity: 5, + accuracy: "good", + bestFor: "traditional", + gradient: { + from: "from-[#25CB78]", + to: "to-[#22C55E]", + }, + }, + { + id: "katch-mcardle", + href: "/tools/calorie-calculator/katch-mcardle-calculator", + icon: , + year: "1996", + popularity: 3, + accuracy: "high", + bestFor: "athletes", + gradient: { + from: "from-[#FF5722]", + to: "to-[#EF4444]", + }, + }, + { + id: "cunningham", + href: "/tools/calorie-calculator/cunningham-calculator", + icon: , + year: "1980", + popularity: 2, + accuracy: "high", + bestFor: "bodybuilders", + gradient: { + from: "from-[#8B5CF6]", + to: "to-[#7C3AED]", + }, + }, + { + id: "oxford", + href: "/tools/calorie-calculator/oxford-calculator", + icon: , + year: "2005", + popularity: 3, + accuracy: "good", + bestFor: "european", + gradient: { + from: "from-[#F59E0B]", + to: "to-[#EF4444]", + }, + }, + { + id: "comparison", + href: "/tools/calorie-calculator/calorie-calculator-comparison", + icon: , + year: "all", + popularity: 4, + accuracy: "high", + bestFor: "comparison", + gradient: { + from: "from-[#06B6D4]", + to: "to-[#3B82F6]", + }, + }, +]; + +export function CalorieCalculatorHub() { + const t = useI18n(); + + const renderStars = (count: number) => { + return Array.from({ length: 5 }, (_, i) => ( + + ★ + + )); + }; + + const getAccuracyColor = (accuracy: string) => { + switch (accuracy) { + case "high": + return "text-green-600 dark:text-green-400"; + case "good": + return "text-blue-600 dark:text-blue-400"; + default: + return "text-orange-600 dark:text-orange-400"; + } + }; + + return ( +
+ {/* Introduction */} + {(env.NEXT_PUBLIC_TOP_CALCULATOR_HUB_BANNER_AD_SLOT || env.NEXT_PUBLIC_EZOIC_TOP_CALCULATOR_HUB_PLACEMENT_ID) && ( + + )} +
+

+ {t("tools.calorie-calculator-hub.title")} +

+

{t("tools.calorie-calculator-hub.subtitle")}

+
+ + {/* Calculator Cards */} +
+ {calculatorFormulas.map((formula) => ( + +
+ +
+ {/* Header */} +
+
+ {formula.icon} +
+
+ + {/* Title */} +

+ {t(`tools.calorie-calculator-hub.${formula.id}.title` as keyof typeof t)} +

+ + {/* Year Badge */} +
+ + {formula.year === "all" + ? t("tools.calorie-calculator-hub.all_formulas") + : `${t("tools.calorie-calculator-hub.since")} ${formula.year}`} + +
+ + {/* Description */} +

+ {t(`tools.calorie-calculator-hub.${formula.id}.description` as keyof typeof t)} +

+ + {/* Stats */} +
+
+ {t("tools.calorie-calculator-hub.popularity")} + {renderStars(formula.popularity)} +
+
+ {t("tools.calorie-calculator-hub.accuracy")} + + {t(`tools.calorie-calculator-hub.accuracy_${formula.accuracy}`)} + +
+
+ {t("tools.calorie-calculator-hub.best_for")} + + {t(`tools.calorie-calculator-hub.best_for_${formula.bestFor}` as keyof typeof t)} + +
+
+ + {/* Best For Badge */} +
+ + ))} +
+ + {(env.NEXT_PUBLIC_BOTTOM_CALCULATOR_HUB_BANNER_AD_SLOT || env.NEXT_PUBLIC_EZOIC_BOTTOM_CALCULATOR_HUB_PLACEMENT_ID) && ( + + )} + + {/* Info Section */} +
+

+ {t("tools.calorie-calculator-hub.which_formula")} +

+
+

{t("tools.calorie-calculator-hub.formula_explanation")}

+
    +
  • + + {t("tools.calorie-calculator-hub.mifflin-st-jeor.title")}: + {" "} + {t("tools.calorie-calculator-hub.recommendation_general")} +
  • +
  • + + {t("tools.calorie-calculator-hub.harris-benedict.title")}: + {" "} + {t("tools.calorie-calculator-hub.recommendation_traditional")} +
  • +
  • + + {t("tools.calorie-calculator-hub.katch-mcardle.title")}: + {" "} + {t("tools.calorie-calculator-hub.recommendation_bodyfat")} +
  • +
+
+
+
+ ); +} diff --git a/app/[locale]/(app)/tools/calorie-calculator/calorie-calculator-comparison/CalorieCalculatorComparison.tsx b/app/[locale]/(app)/tools/calorie-calculator/calorie-calculator-comparison/CalorieCalculatorComparison.tsx new file mode 100644 index 00000000..abb90ff6 --- /dev/null +++ b/app/[locale]/(app)/tools/calorie-calculator/calorie-calculator-comparison/CalorieCalculatorComparison.tsx @@ -0,0 +1,274 @@ +"use client"; + +import React, { useState } from "react"; + +import { useI18n } from "locales/client"; +import { BodyFatInput } from "app/[locale]/(app)/tools/calorie-calculator/shared/components/BodyFatInput"; +import { + ActivityLevelSelector, + AgeInput, + GenderSelector, + GoalSelector, + HeightInput, + UnitSelector, + WeightInput, +} from "app/[locale]/(app)/tools/calorie-calculator/shared/components"; +import { + calculateCalories, + CalorieCalculatorInputs, + CalorieResults, +} from "app/[locale]/(app)/tools/calorie-calculator/shared/calorie-formulas.utils"; +import { env } from "@/env"; +import { HorizontalBottomBanner } from "@/components/ads"; + +interface FormulaResult { + name: string; + formula: "mifflin" | "harris" | "katch" | "cunningham" | "oxford"; + results: CalorieResults | null; + error?: string; + gradient: { + from: string; + to: string; + }; +} + +export function CalorieCalculatorComparison() { + const t = useI18n(); + + const [inputs, setInputs] = useState({ + gender: "male", + unit: "metric", + age: 25, + height: 170, + weight: 70, + activityLevel: "moderate", + goal: "maintain", + bodyFatPercentage: 15, + }); + + const [isCalculating, setIsCalculating] = useState(false); + const [formulaResults, setFormulaResults] = useState([]); + + const formulas: FormulaResult[] = [ + { + name: t("tools.calorie-calculator-hub.mifflin-st-jeor.title"), + formula: "mifflin", + results: null, + gradient: { from: "from-[#4F8EF7]", to: "to-[#238BE6]" }, + }, + { + name: t("tools.calorie-calculator-hub.harris-benedict.title"), + formula: "harris", + results: null, + gradient: { from: "from-[#25CB78]", to: "to-[#22C55E]" }, + }, + { + name: t("tools.calorie-calculator-hub.katch-mcardle.title"), + formula: "katch", + results: null, + gradient: { from: "from-[#FF5722]", to: "to-[#EF4444]" }, + }, + { + name: t("tools.calorie-calculator-hub.cunningham.title"), + formula: "cunningham", + results: null, + gradient: { from: "from-[#8B5CF6]", to: "to-[#7C3AED]" }, + }, + { + name: t("tools.calorie-calculator-hub.oxford.title"), + formula: "oxford", + results: null, + gradient: { from: "from-[#F59E0B]", to: "to-[#EF4444]" }, + }, + ]; + + const handleCalculate = () => { + setIsCalculating(true); + + setTimeout(() => { + const results = formulas.map((formula) => { + try { + const calculatedResults = calculateCalories(inputs, formula.formula); + return { + ...formula, + results: calculatedResults, + error: undefined, + }; + } catch (error) { + return { + ...formula, + results: null, + error: error instanceof Error ? error.message : "Calculation error", + }; + } + }); + + setFormulaResults(results); + setIsCalculating(false); + }, 500); + }; + + const updateInput = (key: K, value: CalorieCalculatorInputs[K]) => { + setInputs((prev) => ({ ...prev, [key]: value })); + }; + + const getResultDifference = (result: CalorieResults, baseline: CalorieResults) => { + const diff = result.targetCalories - baseline.targetCalories; + const percentage = (diff / baseline.targetCalories) * 100; + return { diff, percentage }; + }; + + const baseline = formulaResults.find((r) => r.formula === "mifflin")?.results; + + return ( +
+ {/* Input Form */} +
+

+ {t("tools.calorie-calculator-comparison.input_details")} +

+ +
+
+ updateInput("gender", gender)} value={inputs.gender} /> + updateInput("unit", unit)} value={inputs.unit} /> +
+ + updateInput("age", age)} value={inputs.age} /> + +
+ updateInput("height", height)} unit={inputs.unit} value={inputs.height} /> + updateInput("weight", weight)} unit={inputs.unit} value={inputs.weight} /> +
+ + updateInput("bodyFatPercentage", bodyFat)} value={inputs.bodyFatPercentage || 15} /> + + updateInput("activityLevel", level)} value={inputs.activityLevel} /> + + updateInput("goal", goal)} value={inputs.goal} /> + + {(env.NEXT_PUBLIC_BOTTOM_CALORIE_CALCULATOR_COMPARISON_AD_SLOT || env.NEXT_PUBLIC_EZOIC_BOTTOM_CALORIE_COMPARISON_PLACEMENT_ID) && ( + + )} + {/* Calculate Button */} + +
+
+ + {/* Results Comparison */} + {formulaResults.length > 0 && ( +
+

+ {t("tools.calorie-calculator-comparison.results_comparison")} +

+ + {/* Results Grid */} +
+ {formulaResults.map((formula, index) => ( +
+
+ +
+
+
+ #{index + 1} +
+

{formula.name}

+
+ + {formula.error ? ( +
{formula.error}
+ ) : formula.results ? ( +
+
+
+ {formula.results.targetCalories} cal +
+
{t("tools.calorie-calculator.results.target")}
+
+ +
+
+
{formula.results.bmr}
+
BMR
+
+
+
{formula.results.tdee}
+
TDEE
+
+
+ + {baseline && formula.formula !== "mifflin" && ( +
+
{t("tools.calorie-calculator-comparison.vs_mifflin")}
+ {(() => { + const { diff, percentage } = getResultDifference(formula.results, baseline); + return ( +
0 ? "text-orange-600" : diff < 0 ? "text-blue-600" : "text-gray-600"}`} + > + {diff > 0 ? "+" : ""} + {diff} cal ({percentage > 0 ? "+" : ""} + {percentage.toFixed(1)}%) +
+ ); + })()} +
+ )} +
+ ) : null} +
+
+ ))} +
+ + {/* Summary */} + {baseline && ( +
+

+ {t("tools.calorie-calculator-comparison.summary")} +

+
+

{t("tools.calorie-calculator-comparison.summary_explanation")}

+

{t("tools.calorie-calculator-comparison.recommendation")}

+
+
+ )} +
+ )} +
+ ); +} diff --git a/app/[locale]/(app)/tools/calorie-calculator/calorie-calculator-comparison/page.tsx b/app/[locale]/(app)/tools/calorie-calculator/calorie-calculator-comparison/page.tsx new file mode 100644 index 00000000..8e8a4e47 --- /dev/null +++ b/app/[locale]/(app)/tools/calorie-calculator/calorie-calculator-comparison/page.tsx @@ -0,0 +1,120 @@ +import Link from "next/link"; +import { Metadata } from "next"; +import { ChevronLeftIcon } from "lucide-react"; +import { getI18n } from "locales/server"; + +import { CalorieCalculatorComparison } from "./CalorieCalculatorComparison"; + +import { getServerUrl } from "@/shared/lib/server-url"; +import { env } from "@/env"; +import { generateSEOMetadata, SEOScripts } from "@/components/seo/SEOHead"; +import { HorizontalTopBanner } from "@/components/ads"; + +export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise { + const { locale } = await params; + const t = await getI18n(); + + return generateSEOMetadata({ + title: t("tools.calorie-calculator-comparison.meta.title"), + description: t("tools.calorie-calculator-comparison.meta.description"), + keywords: t("tools.calorie-calculator-comparison.meta.keywords").split(", "), + locale, + canonical: `${getServerUrl()}/${locale}/tools/calorie-calculator-comparison`, + structuredData: { + type: "Calculator", + calculatorData: { + calculatorType: "calorie", + inputFields: ["gender", "age", "height", "weight", "body fat percentage", "activity level", "goal"], + outputFields: ["BMR comparison", "TDEE comparison", "accuracy analysis", "formula recommendations"], + formula: "Multi-Formula Comparison Tool", + accuracy: "Comprehensive accuracy analysis across 5 formulas", + targetAudience: ["fitness professionals", "researchers", "health enthusiasts", "Cal the Chef users"], + relatedCalculators: [ + "calorie-calculator", + "mifflin-st-jeor-calculator", + "harris-benedict-calculator", + "katch-mcardle-calculator", + "cunningham-calculator", + "oxford-calculator", + ], + }, + }, + }); +} + +export default async function CalorieCalculatorComparisonPage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + const t = await getI18n(); + + return ( + <> + +
+
+ {env.NEXT_PUBLIC_TOP_CALORIE_CALCULATOR_COMPARISON_AD_SLOT && ( + + )} + {/* Back to hub */} + + + {t("tools.back_to_calculators")} + + +
+

+ {t("tools.calorie-calculator-comparison.title")} +

+

+ {t("tools.calorie-calculator-comparison.subtitle")} +

+
+ + {/* Educational Section */} +
+

+ {t("tools.calorie-calculator-comparison.how_it_works")} +

+
+

{t("tools.calorie-calculator-comparison.how_it_works_description")}

+
+
+ + +
+
+ + ); +} diff --git a/app/[locale]/(app)/tools/calorie-calculator/calorie-calculator.utils.ts b/app/[locale]/(app)/tools/calorie-calculator/calorie-calculator.utils.ts new file mode 100644 index 00000000..56be1ecb --- /dev/null +++ b/app/[locale]/(app)/tools/calorie-calculator/calorie-calculator.utils.ts @@ -0,0 +1,122 @@ +// Types for the calorie calculator +export type Gender = "male" | "female"; +export type UnitSystem = "metric" | "imperial"; +export type ActivityLevel = "sedentary" | "light" | "moderate" | "active" | "very_active"; +export type Goal = "lose_fast" | "lose_slow" | "maintain" | "gain_slow" | "gain_fast"; + +export interface CalorieCalculatorInputs { + gender: Gender; + unit: UnitSystem; + age: number; + height: number; // cm for metric, inches for imperial + weight: number; // kg for metric, lbs for imperial + activityLevel: ActivityLevel; + goal: Goal; +} + +export interface CalorieResults { + bmr: number; // Basal Metabolic Rate + tdee: number; // Total Daily Energy Expenditure + targetCalories: number; // Based on goal + proteinGrams: number; + carbsGrams: number; + fatGrams: number; +} + +// Activity level multipliers +const ACTIVITY_MULTIPLIERS: Record = { + sedentary: 1.2, // Little to no exercise + light: 1.375, // Light exercise 1-3 days/week + moderate: 1.55, // Moderate exercise 3-5 days/week + active: 1.725, // Heavy exercise 6-7 days/week + very_active: 1.9, // Very heavy physical job or training +}; + +// Goal adjustments (calories per day) +const GOAL_ADJUSTMENTS: Record = { + lose_fast: -1000, // Lose 2 lbs/week + lose_slow: -500, // Lose 1 lb/week + maintain: 0, + gain_slow: 500, // Gain 1 lb/week + gain_fast: 1000, // Gain 2 lbs/week +}; + +/** + * Convert imperial units to metric for calculation + */ +function convertToMetric(inputs: CalorieCalculatorInputs): { + weight: number; + height: number; +} { + if (inputs.unit === "metric") { + return { + weight: inputs.weight, + height: inputs.height, + }; + } + + // Convert lbs to kg and inches to cm + return { + weight: inputs.weight * 0.453592, + height: inputs.height * 2.54, + }; +} + +/** + * Calculate BMR using Mifflin-St Jeor Equation + * Men: BMR = 10 × weight(kg) + 6.25 × height(cm) - 5 × age(years) + 5 + * Women: BMR = 10 × weight(kg) + 6.25 × height(cm) - 5 × age(years) - 161 + */ +function calculateBMR(inputs: CalorieCalculatorInputs): number { + const { weight, height } = convertToMetric(inputs); + + const baseBMR = 10 * weight + 6.25 * height - 5 * inputs.age; + + if (inputs.gender === "male") { + return baseBMR + 5; + } else { + return baseBMR - 161; + } +} + +/** + * Calculate macros based on target calories + * Using a balanced approach: 30% protein, 40% carbs, 30% fat + */ +function calculateMacros(targetCalories: number): { + proteinGrams: number; + carbsGrams: number; + fatGrams: number; +} { + const proteinCalories = targetCalories * 0.3; + const carbsCalories = targetCalories * 0.4; + const fatCalories = targetCalories * 0.3; + + // Protein and carbs = 4 calories per gram, fat = 9 calories per gram + return { + proteinGrams: Math.round(proteinCalories / 4), + carbsGrams: Math.round(carbsCalories / 4), + fatGrams: Math.round(fatCalories / 9), + }; +} + +/** + * Main calculation function + */ +export function calculateTDEE(inputs: CalorieCalculatorInputs): CalorieResults { + const bmr = calculateBMR(inputs); + const tdee = bmr * ACTIVITY_MULTIPLIERS[inputs.activityLevel]; + const targetCalories = tdee + GOAL_ADJUSTMENTS[inputs.goal]; + + // Ensure minimum calories (never go below 1200 for safety) + const safeTargetCalories = Math.max(1200, targetCalories); + + const macros = calculateMacros(safeTargetCalories); + + return { + bmr: Math.round(bmr), + tdee: Math.round(tdee), + targetCalories: Math.round(safeTargetCalories), + ...macros, + }; +} diff --git a/app/[locale]/(app)/tools/calorie-calculator/cunningham-calculator/page.tsx b/app/[locale]/(app)/tools/calorie-calculator/cunningham-calculator/page.tsx new file mode 100644 index 00000000..d086753b --- /dev/null +++ b/app/[locale]/(app)/tools/calorie-calculator/cunningham-calculator/page.tsx @@ -0,0 +1,125 @@ +import React from "react"; +import Link from "next/link"; +import { Metadata } from "next"; +import { ChevronLeftIcon } from "lucide-react"; + +import { getI18n } from "locales/server"; +import { CalorieCalculatorClient } from "app/[locale]/(app)/tools/calorie-calculator/shared/CalorieCalculatorClient"; +import { calculatorConfigs } from "app/[locale]/(app)/tools/calorie-calculator/shared/calculator-configs"; +import { getServerUrl } from "@/shared/lib/server-url"; +import { env } from "@/env"; +import { generateSEOMetadata, SEOScripts } from "@/components/seo/SEOHead"; +import { HorizontalTopBanner } from "@/components/ads"; +import "../styles.css"; + +export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise { + const { locale } = await params; + const t = await getI18n(); + + return generateSEOMetadata({ + title: t("tools.cunningham.meta.title"), + description: t("tools.cunningham.meta.description"), + keywords: t("tools.cunningham.meta.keywords").split(", "), + locale, + canonical: `${getServerUrl()}/${locale}/tools/cunningham-calculator`, + structuredData: { + type: "Calculator", + calculatorData: { + calculatorType: "calorie", + inputFields: ["gender", "age", "height", "weight", "body fat percentage", "activity level", "goal"], + outputFields: ["BMR (Cunningham)", "lean body mass", "TDEE", "target calories", "recommended macros"], + formula: "Cunningham Equation - Active Individual Formula", + accuracy: "Excellent accuracy for active individuals with known body fat (±5% error rate)", + targetAudience: ["active athletes", "trained individuals", "bodybuilders", "fitness enthusiasts", "Cal the Chef users"], + relatedCalculators: [ + "calorie-calculator", + "mifflin-st-jeor-calculator", + "harris-benedict-calculator", + "katch-mcardle-calculator", + "calorie-calculator-comparison", + ], + }, + }, + }); +} + +export default async function CunninghamCalculatorPage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + const t = await getI18n(); + + return ( + <> + +
+ {env.NEXT_PUBLIC_TOP_CUNNINGHAM_CALCULATOR_AD_SLOT && ( + + )} +
+ {/* Back to hub */} + + + {t("tools.back_to_calculators")} + + +
+

+ {t("tools.cunningham.title")} +

+

+ {t("tools.cunningham.subtitle")} +

+
+ + {/* Educational Section */} +
+

{t("tools.cunningham.how_it_works")}

+
+

{t("tools.cunningham.how_it_works_description")}

+
+

+ Cunningham: BMR = 500 + (22 × lean body mass) +
+ Lean Body Mass: Weight(kg) × (1 - body fat %/100) +

+
+
+
+ + +
+
+ + ); +} diff --git a/app/[locale]/(app)/tools/calorie-calculator/harris-benedict-calculator/page.tsx b/app/[locale]/(app)/tools/calorie-calculator/harris-benedict-calculator/page.tsx new file mode 100644 index 00000000..34a69b58 --- /dev/null +++ b/app/[locale]/(app)/tools/calorie-calculator/harris-benedict-calculator/page.tsx @@ -0,0 +1,125 @@ +import React from "react"; +import Link from "next/link"; +import { Metadata } from "next"; +import { ChevronLeftIcon } from "lucide-react"; + +import { getI18n } from "locales/server"; +import { CalorieCalculatorClient } from "app/[locale]/(app)/tools/calorie-calculator/shared/CalorieCalculatorClient"; +import { calculatorConfigs } from "app/[locale]/(app)/tools/calorie-calculator/shared/calculator-configs"; +import { getServerUrl } from "@/shared/lib/server-url"; +import { env } from "@/env"; +import { generateSEOMetadata, SEOScripts } from "@/components/seo/SEOHead"; +import { HorizontalTopBanner } from "@/components/ads"; +import "../styles.css"; + +export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise { + const { locale } = await params; + const t = await getI18n(); + + return generateSEOMetadata({ + title: t("tools.harris-benedict.meta.title"), + description: t("tools.harris-benedict.meta.description"), + keywords: t("tools.harris-benedict.meta.keywords").split(", "), + locale, + canonical: `${getServerUrl()}/${locale}/tools/harris-benedict-calculator`, + structuredData: { + type: "Calculator", + calculatorData: { + calculatorType: "calorie", + inputFields: ["gender", "age", "height", "weight", "activity level", "goal"], + outputFields: ["BMR (Harris-Benedict)", "TDEE", "target calories", "recommended macros"], + formula: "Harris-Benedict Equation (1984) - Classic Formula", + accuracy: "Good accuracy for most adults (±10-15% error rate)", + targetAudience: ["adults", "fitness enthusiasts", "health conscious individuals", "Cal the Chef users"], + relatedCalculators: [ + "calorie-calculator", + "mifflin-st-jeor-calculator", + "katch-mcardle-calculator", + "calorie-calculator-comparison", + ], + }, + }, + }); +} + +export default async function HarrisBenedictCalculatorPage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + const t = await getI18n(); + + return ( + <> + +
+ {env.NEXT_PUBLIC_TOP_HARRIS_BENEDICT_CALCULATOR_AD_SLOT && ( + + )} +
+ {/* Back to hub */} + + + {t("tools.back_to_calculators")} + + +
+

+ {t("tools.harris-benedict.title")} +

+

+ {t("tools.harris-benedict.subtitle")} +

+
+ + {/* Educational Section */} +
+

+ {t("tools.harris-benedict.how_it_works")} +

+
+

{t("tools.harris-benedict.how_it_works_description")}

+
+

+ Men: BMR = 88.362 + (13.397 × weight) + (4.799 × height) - (5.677 × age) +
+ Women: BMR = 447.593 + (9.247 × weight) + (3.098 × height) - (4.330 × age) +

+
+
+
+ + +
+
+ + ); +} diff --git a/app/[locale]/(app)/tools/calorie-calculator/katch-mcardle-calculator/page.tsx b/app/[locale]/(app)/tools/calorie-calculator/katch-mcardle-calculator/page.tsx new file mode 100644 index 00000000..2ec8ecbd --- /dev/null +++ b/app/[locale]/(app)/tools/calorie-calculator/katch-mcardle-calculator/page.tsx @@ -0,0 +1,125 @@ +import React from "react"; +import Link from "next/link"; +import { Metadata } from "next"; +import { ChevronLeftIcon } from "lucide-react"; + +import { getI18n } from "locales/server"; +import { CalorieCalculatorClient } from "app/[locale]/(app)/tools/calorie-calculator/shared/CalorieCalculatorClient"; +import { calculatorConfigs } from "app/[locale]/(app)/tools/calorie-calculator/shared/calculator-configs"; +import { getServerUrl } from "@/shared/lib/server-url"; +import { env } from "@/env"; +import { generateSEOMetadata, SEOScripts } from "@/components/seo/SEOHead"; +import { HorizontalTopBanner } from "@/components/ads"; +import "../styles.css"; + +export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise { + const { locale } = await params; + const t = await getI18n(); + + return generateSEOMetadata({ + title: t("tools.katch-mcardle.meta.title"), + description: t("tools.katch-mcardle.meta.description"), + keywords: t("tools.katch-mcardle.meta.keywords").split(", "), + locale, + canonical: `${getServerUrl()}/${locale}/tools/katch-mcardle-calculator`, + structuredData: { + type: "Calculator", + calculatorData: { + calculatorType: "calorie", + inputFields: ["gender", "age", "height", "weight", "body fat percentage", "activity level", "goal"], + outputFields: ["BMR (Katch-McArdle)", "lean body mass", "TDEE", "target calories", "recommended macros"], + formula: "Katch-McArdle Equation - Body Composition Based", + accuracy: "Highest accuracy for lean individuals with known body fat (±5% error rate)", + targetAudience: ["athletes", "bodybuilders", "fitness professionals", "lean individuals", "Cal the Chef users"], + relatedCalculators: [ + "calorie-calculator", + "mifflin-st-jeor-calculator", + "harris-benedict-calculator", + "cunningham-calculator", + "calorie-calculator-comparison", + ], + }, + }, + }); +} + +export default async function KatchMcArdleCalculatorPage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + const t = await getI18n(); + + return ( + <> + +
+ {env.NEXT_PUBLIC_TOP_KATCH_MCARDLE_CALCULATOR_AD_SLOT && ( + + )} +
+ {/* Back to hub */} + + + {t("tools.back_to_calculators")} + + +
+

+ {t("tools.katch-mcardle.title")} +

+

+ {t("tools.katch-mcardle.subtitle")} +

+
+ + {/* Educational Section */} +
+

{t("tools.katch-mcardle.how_it_works")}

+
+

{t("tools.katch-mcardle.how_it_works_description")}

+
+

+ Katch-McArdle: BMR = 370 + (21.6 × lean body mass) +
+ Lean Body Mass: Weight(kg) × (1 - body fat %/100) +

+
+
+
+ + +
+
+ + ); +} diff --git a/app/[locale]/(app)/tools/calorie-calculator/mifflin-st-jeor-calculator/page.tsx b/app/[locale]/(app)/tools/calorie-calculator/mifflin-st-jeor-calculator/page.tsx new file mode 100644 index 00000000..5bf4d497 --- /dev/null +++ b/app/[locale]/(app)/tools/calorie-calculator/mifflin-st-jeor-calculator/page.tsx @@ -0,0 +1,125 @@ +import React from "react"; +import Link from "next/link"; +import { Metadata } from "next"; +import { ChevronLeftIcon } from "lucide-react"; + +import { getI18n } from "locales/server"; +import { CalorieCalculatorClient } from "app/[locale]/(app)/tools/calorie-calculator/shared/CalorieCalculatorClient"; +import { calculatorConfigs } from "app/[locale]/(app)/tools/calorie-calculator/shared/calculator-configs"; +import { getServerUrl } from "@/shared/lib/server-url"; +import { env } from "@/env"; +import { generateSEOMetadata, SEOScripts } from "@/components/seo/SEOHead"; +import { HorizontalTopBanner } from "@/components/ads"; + +import "../styles.css"; + +export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise { + const { locale } = await params; + const t = await getI18n(); + + return generateSEOMetadata({ + title: t("tools.mifflin-st-jeor.meta.title"), + description: t("tools.mifflin-st-jeor.meta.description"), + keywords: t("tools.mifflin-st-jeor.meta.keywords").split(", "), + locale, + canonical: `${getServerUrl()}/${locale}/tools/mifflin-st-jeor-calculator`, + structuredData: { + type: "Calculator", + calculatorData: { + calculatorType: "calorie", + inputFields: ["gender", "age", "height", "weight", "activity level", "goal"], + outputFields: ["BMR (Mifflin-St Jeor)", "TDEE", "target calories", "recommended macros"], + formula: "Mifflin-St Jeor Equation (1990) - Gold Standard", + accuracy: "Most accurate for general population (±5-10% error rate)", + targetAudience: ["general population", "fitness beginners", "health conscious individuals", "Cal the Chef users"], + relatedCalculators: [ + "calorie-calculator", + "harris-benedict-calculator", + "katch-mcardle-calculator", + "calorie-calculator-comparison", + ], + }, + }, + }); +} + +export default async function MifflinStJeorCalculatorPage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + const t = await getI18n(); + + return ( + <> + +
+ {env.NEXT_PUBLIC_TOP_MIFFLIN_ST_JEOR_CALCULATOR_AD_SLOT && ( + + )} +
+ {/* Back to hub */} + + + {t("tools.back_to_calculators")} + + +
+

+ {t("tools.mifflin-st-jeor.title")} +

+

+ {t("tools.mifflin-st-jeor.subtitle")} +

+
+ + {/* Educational Section */} +
+

+ {t("tools.mifflin-st-jeor.how_it_works")} +

+
+

{t("tools.mifflin-st-jeor.how_it_works_description")}

+
+

+ Men: BMR = 10 × weight(kg) + 6.25 × height(cm) - 5 × age + 5
+ Women: BMR = 10 × weight(kg) + 6.25 × height(cm) - 5 × age - 161 +

+
+
+
+ + +
+
+ + ); +} diff --git a/app/[locale]/(app)/tools/calorie-calculator/oxford-calculator/page.tsx b/app/[locale]/(app)/tools/calorie-calculator/oxford-calculator/page.tsx new file mode 100644 index 00000000..22e8d5ea --- /dev/null +++ b/app/[locale]/(app)/tools/calorie-calculator/oxford-calculator/page.tsx @@ -0,0 +1,123 @@ +import React from "react"; +import Link from "next/link"; +import { Metadata } from "next"; +import { ChevronLeftIcon } from "lucide-react"; + +import { getI18n } from "locales/server"; +import { CalorieCalculatorClient } from "app/[locale]/(app)/tools/calorie-calculator/shared/CalorieCalculatorClient"; +import { calculatorConfigs } from "app/[locale]/(app)/tools/calorie-calculator/shared/calculator-configs"; +import { getServerUrl } from "@/shared/lib/server-url"; +import { env } from "@/env"; +import { generateSEOMetadata, SEOScripts } from "@/components/seo/SEOHead"; +import { HorizontalTopBanner } from "@/components/ads"; +import "../styles.css"; + +export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise { + const { locale } = await params; + const t = await getI18n(); + + return generateSEOMetadata({ + title: t("tools.oxford.meta.title"), + description: t("tools.oxford.meta.description"), + keywords: t("tools.oxford.meta.keywords").split(", "), + locale, + canonical: `${getServerUrl()}/${locale}/tools/oxford-calculator`, + structuredData: { + type: "Calculator", + calculatorData: { + calculatorType: "calorie", + inputFields: ["gender", "age", "height", "weight", "physical activity", "goal"], + outputFields: ["BMR (Oxford)", "TDEE", "target calories", "recommended macros"], + formula: "Oxford Equation (2005) - Physical Activity Based", + accuracy: "Modern accuracy incorporating physical activity (±8-12% error rate)", + targetAudience: ["modern populations", "diverse ethnicities", "health professionals", "Cal the Chef users"], + relatedCalculators: [ + "calorie-calculator", + "mifflin-st-jeor-calculator", + "harris-benedict-calculator", + "katch-mcardle-calculator", + "calorie-calculator-comparison", + ], + }, + }, + }); +} + +export default async function OxfordCalculatorPage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + const t = await getI18n(); + + return ( + <> + +
+ {env.NEXT_PUBLIC_TOP_OXFORD_CALCULATOR_AD_SLOT && } +
+ {/* Back to hub */} + + + {t("tools.back_to_calculators")} + + +
+

+ {t("tools.oxford.title")} +

+

+ {t("tools.oxford.subtitle")} +

+
+ + {/* Educational Section */} +
+

{t("tools.oxford.how_it_works")}

+
+

{t("tools.oxford.how_it_works_description")}

+
+

+ Men: BMR = 662 - (9.53 × age) + PA × (15.91 × weight + 539.6 × height) +
+ Women: BMR = 354 - (6.91 × age) + PA × (9.36 × weight + 726 × height) +

+
+
+
+ + +
+
+ + ); +} diff --git a/app/[locale]/(app)/tools/calorie-calculator/page.tsx b/app/[locale]/(app)/tools/calorie-calculator/page.tsx new file mode 100644 index 00000000..527f1d80 --- /dev/null +++ b/app/[locale]/(app)/tools/calorie-calculator/page.tsx @@ -0,0 +1,78 @@ +import React from "react"; +import { Metadata } from "next"; + +import { getI18n } from "locales/server"; +import { getServerUrl } from "@/shared/lib/server-url"; +import { generateSEOMetadata, SEOScripts } from "@/components/seo/SEOHead"; + +import { CalorieCalculatorHub } from "./CalorieCalculatorHub"; + +export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise { + const { locale } = await params; + const t = await getI18n(); + + return generateSEOMetadata({ + title: t("tools.calorie-calculator-hub.meta.title"), + description: t("tools.calorie-calculator-hub.meta.description"), + keywords: t("tools.calorie-calculator-hub.meta.keywords").split(", "), + locale, + canonical: `${getServerUrl()}/${locale}/tools/calorie-calculator`, + structuredData: { + type: "Calculator", + calculatorData: { + calculatorType: "calorie", + inputFields: ["gender", "age", "height", "weight", "activity level", "goal"], + outputFields: ["BMR", "TDEE", "target calories", "recommended macros"], + formula: "Mifflin-St Jeor Equation", + accuracy: "±100-200 calories (scientifically validated)", + targetAudience: ["fitness enthusiasts", "athletes", "weight loss seekers", "health conscious individuals"], + relatedCalculators: [ + "mifflin-st-jeor-calculator", + "harris-benedict-calculator", + "katch-mcardle-calculator", + "cunningham-calculator", + "oxford-calculator", + ], + }, + }, + }); +} + +export default async function CalorieCalculatorPage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + const t = await getI18n(); + + return ( + <> + +
+
+ +
+
+ + ); +} diff --git a/app/[locale]/(app)/tools/calorie-calculator/shared/CalorieCalculatorClient.tsx b/app/[locale]/(app)/tools/calorie-calculator/shared/CalorieCalculatorClient.tsx new file mode 100644 index 00000000..84c73adf --- /dev/null +++ b/app/[locale]/(app)/tools/calorie-calculator/shared/CalorieCalculatorClient.tsx @@ -0,0 +1,129 @@ +"use client"; + +import React, { 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 { + GenderSelector, + WeightInput, + HeightInput, + AgeInput, + UnitSelector, + ActivityLevelSelector, + GoalSelector, + ResultsDisplay, +} from "./components"; +import { calculateCalories, type CalorieCalculatorInputs, type CalorieResults } from "./calorie-formulas.utils"; + +import type { CalculatorConfig } from "./types"; + +interface CalorieCalculatorClientProps { + config: CalculatorConfig; +} + +export function CalorieCalculatorClient({ config }: CalorieCalculatorClientProps) { + const t = useI18n(); + + // Form state + const [gender, setGender] = useState("male"); + const [unit, setUnit] = useState("metric"); + const [age, setAge] = useState(25); + const [height, setHeight] = useState(unit === "metric" ? 175 : 69); + const [weight, setWeight] = useState(unit === "metric" ? 70 : 154); + const [activityLevel, setActivityLevel] = useState("moderate"); + const [goal, setGoal] = useState("maintain"); + const [bodyFatPercentage, setBodyFatPercentage] = useState(20); + + // Results state + const [results, setResults] = useState(null); + const [isCalculating, setIsCalculating] = useState(false); + + // Handle unit change + const handleUnitChange = (newUnit: CalorieCalculatorInputs["unit"]) => { + setUnit(newUnit); + // Convert values + if (newUnit === "imperial" && unit === "metric") { + setWeight(Math.round(weight * 2.20462)); + setHeight(Math.round(height / 2.54)); + } else if (newUnit === "metric" && unit === "imperial") { + setWeight(Math.round(weight / 2.20462)); + setHeight(Math.round(height * 2.54)); + } + }; + + // Calculate calories + const handleCalculate = () => { + setIsCalculating(true); + + const inputs: CalorieCalculatorInputs = { + gender, + unit, + age, + height, + weight, + activityLevel, + goal, + ...(config.requiresBodyFat && { bodyFatPercentage }), + }; + + try { + const calculatedResults = calculateCalories(inputs, config.formula); + setResults(calculatedResults); + } catch (error) { + console.error("Error calculating calories:", error); + } finally { + setIsCalculating(false); + } + }; + + return ( +
+
+ {/* Input Section */} +
+
+
+ + + + + + {config.requiresBodyFat && } + + +
+ + {env.NEXT_PUBLIC_BOTTOM_CALORIE_CALCULATOR_AD_SLOT && ( + + )} + + +
+
+ + {/* Results Section */} + {results && } +
+
+ ); +} diff --git a/app/[locale]/(app)/tools/calorie-calculator/shared/calculator-configs.ts b/app/[locale]/(app)/tools/calorie-calculator/shared/calculator-configs.ts new file mode 100644 index 00000000..ed0c84ab --- /dev/null +++ b/app/[locale]/(app)/tools/calorie-calculator/shared/calculator-configs.ts @@ -0,0 +1,55 @@ +import type { CalculatorConfig } from "./types"; + +// Calculator configurations +export const calculatorConfigs: Record = { + oxford: { + formula: "oxford", + requiresBodyFat: false, + name: "Oxford Calculator", + description: "Uses the Oxford formula for calorie calculation", + buttonGradient: { + from: "#4F8EF7", + to: "#238BE6", + }, + }, + "mifflin-st-jeor": { + formula: "mifflin", + requiresBodyFat: false, + name: "Mifflin-St Jeor Calculator", + description: "Uses the Mifflin-St Jeor formula (most accurate for general population)", + buttonGradient: { + from: "#06B6D4", + to: "#0891B2", + }, + }, + "harris-benedict": { + formula: "harris", + requiresBodyFat: false, + name: "Harris-Benedict Calculator", + description: "Uses the revised Harris-Benedict formula", + buttonGradient: { + from: "#10B981", + to: "#059669", + }, + }, + "katch-mcardle": { + formula: "katch", + requiresBodyFat: true, + name: "Katch-McArdle Calculator", + description: "Most accurate for lean individuals (requires body fat %)", + buttonGradient: { + from: "#F59E0B", + to: "#D97706", + }, + }, + cunningham: { + formula: "cunningham", + requiresBodyFat: true, + name: "Cunningham Calculator", + description: "Best for athletes with very low body fat (requires body fat %)", + buttonGradient: { + from: "#8B5CF6", + to: "#7C3AED", + }, + }, +}; diff --git a/app/[locale]/(app)/tools/calorie-calculator/shared/calorie-formulas.utils.ts b/app/[locale]/(app)/tools/calorie-calculator/shared/calorie-formulas.utils.ts new file mode 100644 index 00000000..71d9b5a0 --- /dev/null +++ b/app/[locale]/(app)/tools/calorie-calculator/shared/calorie-formulas.utils.ts @@ -0,0 +1,165 @@ +// Shared types and formulas for all calorie calculators + +export type Gender = "male" | "female"; +export type UnitSystem = "metric" | "imperial"; +export type ActivityLevel = "sedentary" | "light" | "moderate" | "active" | "very_active"; +export type Goal = "lose_fast" | "lose_slow" | "maintain" | "gain_slow" | "gain_fast"; + +export interface CalorieCalculatorInputs { + gender: Gender; + unit: UnitSystem; + age: number; + height: number; // cm for metric, inches for imperial + weight: number; // kg for metric, lbs for imperial + activityLevel: ActivityLevel; + goal: Goal; + bodyFatPercentage?: number; // Optional for Katch-McArdle +} + +export interface CalorieResults { + bmr: number; + tdee: number; + targetCalories: number; + proteinGrams: number; + carbsGrams: number; + fatGrams: number; +} + +// Activity level multipliers +export const ACTIVITY_MULTIPLIERS: Record = { + sedentary: 1.2, + light: 1.375, + moderate: 1.55, + active: 1.725, + very_active: 1.9, +}; + +// Goal adjustments +export const GOAL_ADJUSTMENTS: Record = { + lose_fast: -1000, + lose_slow: -500, + maintain: 0, + gain_slow: 500, + gain_fast: 1000, +}; + +// Convert imperial to metric +export function convertToMetric(inputs: CalorieCalculatorInputs): { + weight: number; + height: number; +} { + if (inputs.unit === "metric") { + return { weight: inputs.weight, height: inputs.height }; + } + return { + weight: inputs.weight * 0.453592, + height: inputs.height * 2.54, + }; +} + +// Calculate macros +export function calculateMacros(targetCalories: number): { + proteinGrams: number; + carbsGrams: number; + fatGrams: number; +} { + const proteinCalories = targetCalories * 0.3; + const carbsCalories = targetCalories * 0.4; + const fatCalories = targetCalories * 0.3; + + return { + proteinGrams: Math.round(proteinCalories / 4), + carbsGrams: Math.round(carbsCalories / 4), + fatGrams: Math.round(fatCalories / 9), + }; +} + +// Mifflin-St Jeor Formula (1990) +export function calculateMifflinStJeor(inputs: CalorieCalculatorInputs): number { + const { weight, height } = convertToMetric(inputs); + const baseBMR = 10 * weight + 6.25 * height - 5 * inputs.age; + + return inputs.gender === "male" ? baseBMR + 5 : baseBMR - 161; +} + +// Harris-Benedict Revised Formula (1984) +export function calculateHarrisBenedict(inputs: CalorieCalculatorInputs): number { + const { weight, height } = convertToMetric(inputs); + + if (inputs.gender === "male") { + return 88.362 + 13.397 * weight + 4.799 * height - 5.677 * inputs.age; + } else { + return 447.593 + 9.247 * weight + 3.098 * height - 4.33 * inputs.age; + } +} + +// Katch-McArdle Formula (requires body fat percentage) +export function calculateKatchMcArdle(inputs: CalorieCalculatorInputs): number { + if (!inputs.bodyFatPercentage) { + throw new Error("Body fat percentage is required for Katch-McArdle formula"); + } + + const { weight } = convertToMetric(inputs); + const leanBodyMass = weight * (1 - inputs.bodyFatPercentage / 100); + + return 370 + 21.6 * leanBodyMass; +} + +// Cunningham Formula (for athletes with very low body fat) +export function calculateCunningham(inputs: CalorieCalculatorInputs): number { + if (!inputs.bodyFatPercentage) { + throw new Error("Body fat percentage is required for Cunningham formula"); + } + + const { weight } = convertToMetric(inputs); + const leanBodyMass = weight * (1 - inputs.bodyFatPercentage / 100); + + return 500 + 22 * leanBodyMass; +} + +// Oxford Formula (2005) +export function calculateOxford(inputs: CalorieCalculatorInputs): number { + const { weight } = convertToMetric(inputs); + + if (inputs.gender === "male") { + return inputs.age < 30 ? 16.6 * weight + (77 * inputs.height) / 100 + 572 : 14.4 * weight + (313 * inputs.height) / 100 + 113; + } else { + return inputs.age < 30 ? 13.1 * weight + (558 * inputs.height) / 100 + 184 : 13.4 * weight + (346 * inputs.height) / 100 + 247; + } +} + +// Main calculation function +export function calculateCalories( + inputs: CalorieCalculatorInputs, + formula: "mifflin" | "harris" | "katch" | "cunningham" | "oxford" = "mifflin", +): CalorieResults { + let bmr: number; + + switch (formula) { + case "harris": + bmr = calculateHarrisBenedict(inputs); + break; + case "katch": + bmr = calculateKatchMcArdle(inputs); + break; + case "cunningham": + bmr = calculateCunningham(inputs); + break; + case "oxford": + bmr = calculateOxford(inputs); + break; + default: + bmr = calculateMifflinStJeor(inputs); + } + + const tdee = bmr * ACTIVITY_MULTIPLIERS[inputs.activityLevel]; + const targetCalories = Math.max(1200, tdee + GOAL_ADJUSTMENTS[inputs.goal]); + const macros = calculateMacros(targetCalories); + + return { + bmr: Math.round(bmr), + tdee: Math.round(tdee), + targetCalories: Math.round(targetCalories), + ...macros, + }; +} diff --git a/app/[locale]/(app)/tools/calorie-calculator/shared/components/ActivityLevelSelector.tsx b/app/[locale]/(app)/tools/calorie-calculator/shared/components/ActivityLevelSelector.tsx new file mode 100644 index 00000000..63cb3b45 --- /dev/null +++ b/app/[locale]/(app)/tools/calorie-calculator/shared/components/ActivityLevelSelector.tsx @@ -0,0 +1,90 @@ +"use client"; + +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"; + +interface ActivityLevelSelectorProps { + value: ActivityLevel; + onChange: (level: ActivityLevel) => void; +} + +const ACTIVITY_LEVELS: ActivityLevel[] = ["sedentary", "light", "moderate", "active", "very_active"]; + +export function ActivityLevelSelector({ value, onChange }: ActivityLevelSelectorProps) { + const t = useI18n(); + + const ACTIVITY_ICONS: Record = { + sedentary: , + light: , + moderate: , + active: , + very_active: , + }; + + const ACTIVITY_COLORS: Record = { + sedentary: { border: "border-gray-400", bg: "from-gray-400/20 to-gray-500/10", text: "text-gray-600" }, + light: { border: "border-blue-400", bg: "from-blue-400/20 to-blue-500/10", text: "text-blue-600" }, + moderate: { border: "border-green-500", bg: "from-green-500/20 to-green-600/10", text: "text-green-600" }, + active: { border: "border-orange-500", bg: "from-orange-500/20 to-orange-600/10", text: "text-orange-600" }, + very_active: { border: "border-red-500", bg: "from-red-500/20 to-red-600/10", text: "text-red-600" }, + }; + + return ( +
+ +
+ {ACTIVITY_LEVELS.map((level) => { + const colors = ACTIVITY_COLORS[level]; + const isSelected = value === level; + + return ( + + ); + })} +
+
+ ); +} diff --git a/app/[locale]/(app)/tools/calorie-calculator/shared/components/AgeInput.tsx b/app/[locale]/(app)/tools/calorie-calculator/shared/components/AgeInput.tsx new file mode 100644 index 00000000..6078b4f9 --- /dev/null +++ b/app/[locale]/(app)/tools/calorie-calculator/shared/components/AgeInput.tsx @@ -0,0 +1,55 @@ +"use client"; + +import React from "react"; + +import { useI18n } from "locales/client"; + +interface AgeInputProps { + value: number; + onChange: (age: number) => void; +} + +export function AgeInput({ value, onChange }: AgeInputProps) { + const t = useI18n(); + + return ( +
+ +
+
+ onChange(Number(e.target.value))} + placeholder={t("tools.calorie-calculator.age_placeholder")} + type="number" + value={value} + /> +
+ onChange(Number(e.target.value))} + style={{ + background: `linear-gradient(to right, #4F8EF7 0%, #4F8EF7 ${((value - 13) / 87) * 100}%, rgb(229 231 235 / 0.3) ${((value - 13) / 87) * 100}%, rgb(229 231 235 / 0.3) 100%)`, + }} + type="range" + value={value} + /> +
+ 13 + 100 +
+
+
+ {t("tools.calorie-calculator.years")} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/app/[locale]/(app)/tools/calorie-calculator/shared/components/BodyFatInput.tsx b/app/[locale]/(app)/tools/calorie-calculator/shared/components/BodyFatInput.tsx new file mode 100644 index 00000000..382e5b88 --- /dev/null +++ b/app/[locale]/(app)/tools/calorie-calculator/shared/components/BodyFatInput.tsx @@ -0,0 +1,57 @@ +"use client"; + +import React from "react"; + +import { useI18n } from "locales/client"; + +interface BodyFatInputProps { + value: number; + onChange: (bodyFat: number) => void; +} + +export function BodyFatInput({ value, onChange }: BodyFatInputProps) { + const t = useI18n(); + + return ( +
+ +
+
+ onChange(Number(e.target.value))} + placeholder="15" + step="0.5" + type="number" + value={value} + /> +
+ onChange(Number(e.target.value))} + step="0.5" + style={{ + background: `linear-gradient(to right, #FF5722 0%, #FF5722 ${((value - 5) / 45) * 100}%, rgb(229 231 235 / 0.3) ${((value - 5) / 45) * 100}%, rgb(229 231 235 / 0.3) 100%)`, + }} + type="range" + value={value} + /> +
+ 5% + 50% +
+
+
+ % +
+
+
+
+ ); +} diff --git a/app/[locale]/(app)/tools/calorie-calculator/shared/components/FAQSection.tsx b/app/[locale]/(app)/tools/calorie-calculator/shared/components/FAQSection.tsx new file mode 100644 index 00000000..2deec522 --- /dev/null +++ b/app/[locale]/(app)/tools/calorie-calculator/shared/components/FAQSection.tsx @@ -0,0 +1,63 @@ +"use client"; + +import React, { useState } from "react"; +import { ChevronDownIcon, HelpCircleIcon } from "lucide-react"; + +import { useI18n } from "locales/client"; + +export function FAQSection() { + const t = useI18n(); + const [openIndex, setOpenIndex] = useState(null); + + const faqs = [ + { + question: t("tools.calorie-calculator.faq.q1"), + answer: t("tools.calorie-calculator.faq.a1"), + }, + { + question: t("tools.calorie-calculator.faq.q2"), + answer: t("tools.calorie-calculator.faq.a2"), + }, + { + question: t("tools.calorie-calculator.faq.q3"), + answer: t("tools.calorie-calculator.faq.a3"), + }, + { + question: t("tools.calorie-calculator.faq.q4"), + answer: t("tools.calorie-calculator.faq.a4"), + }, + ]; + + return ( +
+
+
+ +
+

{t("tools.calorie-calculator.faq.title")}

+
+ +
+ {faqs.map((faq, index) => ( +
+ +
+

{faq.answer}

+
+
+ ))} +
+
+ ); +} diff --git a/app/[locale]/(app)/tools/calorie-calculator/shared/components/GenderSelector.tsx b/app/[locale]/(app)/tools/calorie-calculator/shared/components/GenderSelector.tsx new file mode 100644 index 00000000..687393b7 --- /dev/null +++ b/app/[locale]/(app)/tools/calorie-calculator/shared/components/GenderSelector.tsx @@ -0,0 +1,81 @@ +"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"; + +interface GenderSelectorProps { + value: Gender; + onChange: (gender: Gender) => void; +} + +export function GenderSelector({ value, onChange }: GenderSelectorProps) { + const t = useI18n(); + + return ( +
+ +
+ + + +
+
+ ); +} diff --git a/app/[locale]/(app)/tools/calorie-calculator/shared/components/GoalSelector.tsx b/app/[locale]/(app)/tools/calorie-calculator/shared/components/GoalSelector.tsx new file mode 100644 index 00000000..6399220e --- /dev/null +++ b/app/[locale]/(app)/tools/calorie-calculator/shared/components/GoalSelector.tsx @@ -0,0 +1,90 @@ +"use client"; + +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"; + +interface GoalSelectorProps { + value: Goal; + onChange: (goal: Goal) => void; +} + +const GOALS: Goal[] = ["lose_fast", "lose_slow", "maintain", "gain_slow", "gain_fast"]; + +export function GoalSelector({ value, onChange }: GoalSelectorProps) { + const t = useI18n(); + + const GOAL_ICONS: Record = { + lose_fast: , + lose_slow: , + maintain: , + gain_slow: , + gain_fast: , + }; + + const GOAL_COLORS: Record = { + lose_fast: { border: "border-red-500", bg: "from-red-500/20 to-red-600/10", text: "text-red-600" }, + lose_slow: { border: "border-orange-500", bg: "from-orange-500/20 to-orange-600/10", text: "text-orange-600" }, + maintain: { border: "border-blue-500", bg: "from-blue-500/20 to-blue-600/10", text: "text-blue-600" }, + gain_slow: { border: "border-green-500", bg: "from-green-500/20 to-green-600/10", text: "text-green-600" }, + gain_fast: { border: "border-purple-500", bg: "from-purple-500/20 to-purple-600/10", text: "text-purple-600" }, + }; + + return ( +
+ +
+ {GOALS.map((goal) => { + const colors = GOAL_COLORS[goal]; + const isSelected = value === goal; + + return ( + + ); + })} +
+
+ ); +} diff --git a/app/[locale]/(app)/tools/calorie-calculator/shared/components/HeightInput.tsx b/app/[locale]/(app)/tools/calorie-calculator/shared/components/HeightInput.tsx new file mode 100644 index 00000000..d6701449 --- /dev/null +++ b/app/[locale]/(app)/tools/calorie-calculator/shared/components/HeightInput.tsx @@ -0,0 +1,90 @@ +"use client"; + +import React from "react"; + +import { useI18n } from "locales/client"; +import { UnitSystem } from "app/[locale]/(app)/tools/calorie-calculator/calorie-calculator.utils"; + +interface HeightInputProps { + value: number; + unit: UnitSystem; + onChange: (height: number) => void; +} + +export function HeightInput({ value, unit, onChange }: HeightInputProps) { + const t = useI18n(); + + // For imperial, we need to handle feet and inches + if (unit === "imperial") { + const totalInches = value; + const feet = Math.floor(totalInches / 12); + const inches = totalInches % 12; + + const handleFeetChange = (newFeet: number) => { + onChange(newFeet * 12 + inches); + }; + + const handleInchesChange = (newInches: number) => { + onChange(feet * 12 + newInches); + }; + + return ( +
+ +
+
+ handleFeetChange(Number(e.target.value))} + type="number" + value={feet} + /> + + {t("tools.calorie-calculator.feet")} + +
+
+ handleInchesChange(Number(e.target.value))} + type="number" + value={inches} + /> + + {t("tools.calorie-calculator.inches")} + +
+
+
+ ); + } + + // Metric - simple cm input + return ( +
+ +
+
+ onChange(Number(e.target.value))} + placeholder={t("tools.calorie-calculator.height_placeholder")} + type="number" + value={value} + /> + + {t("tools.calorie-calculator.cm")} + +
+
+
+ ); +} diff --git a/app/[locale]/(app)/tools/calorie-calculator/shared/components/InfoButton.tsx b/app/[locale]/(app)/tools/calorie-calculator/shared/components/InfoButton.tsx new file mode 100644 index 00000000..b77433ff --- /dev/null +++ b/app/[locale]/(app)/tools/calorie-calculator/shared/components/InfoButton.tsx @@ -0,0 +1,32 @@ +"use client"; + +import React from "react"; +import { InfoIcon } from "lucide-react"; + +import { useIsMobile } from "@/shared/hooks/useIsMobile"; + +interface InfoButtonProps { + onClick: () => void; + tooltip?: React.ReactNode; +} + +export function InfoButton({ onClick, tooltip }: InfoButtonProps) { + const isMobile = useIsMobile(); + + return ( +
+ + {!isMobile && tooltip && ( +
+ {tooltip} +
+ )} +
+ ); +} diff --git a/app/[locale]/(app)/tools/calorie-calculator/shared/components/InfoModal.tsx b/app/[locale]/(app)/tools/calorie-calculator/shared/components/InfoModal.tsx new file mode 100644 index 00000000..71a6f98d --- /dev/null +++ b/app/[locale]/(app)/tools/calorie-calculator/shared/components/InfoModal.tsx @@ -0,0 +1,57 @@ +"use client"; + +import React, { useEffect } from "react"; +import { XIcon } from "lucide-react"; + +interface InfoModalProps { + isOpen: boolean; + onClose: () => void; + title: string; + content: string; +} + +export function InfoModal({ isOpen, onClose, title, content }: InfoModalProps) { + // Close modal on escape key + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + + if (isOpen) { + document.addEventListener("keydown", handleEscape); + // Prevent body scroll when modal is open + document.body.style.overflow = "hidden"; + } + + return () => { + document.removeEventListener("keydown", handleEscape); + document.body.style.overflow = "unset"; + }; + }, [isOpen, onClose]); + + if (!isOpen) return null; + + return ( + <> + {/* Backdrop */} +
+ + {/* Modal */} +
+
+
+

{title}

+ +
+

{content}

+
+
+ + ); +} diff --git a/app/[locale]/(app)/tools/calorie-calculator/shared/components/ResultsDisplay.tsx b/app/[locale]/(app)/tools/calorie-calculator/shared/components/ResultsDisplay.tsx new file mode 100644 index 00000000..515ccdc2 --- /dev/null +++ b/app/[locale]/(app)/tools/calorie-calculator/shared/components/ResultsDisplay.tsx @@ -0,0 +1,171 @@ +"use client"; + +import React, { 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"; + +import { InfoModal } from "./InfoModal"; +import { InfoButton } from "./InfoButton"; + +interface ResultsDisplayProps { + results: CalorieResults; +} + +export function ResultsDisplay({ results }: ResultsDisplayProps) { + const t = useI18n(); + const [activeModal, setActiveModal] = useState(null); + + return ( +
+
+

+ {t("tools.calorie-calculator.results.title")} +

+ Happy +
+ + {/* Main Results */} +
+
+
+ +
+ {t("tools.calorie-calculator.results.bmr")} +
+ setActiveModal("bmr")} + tooltip={ +

+ {t("tools.calorie-calculator.results.bmr_explanation")} +

+ } + /> +
+
{results.bmr.toLocaleString()}
+
kcal
+
+ +
+
+ +
+ {t("tools.calorie-calculator.results.tdee")} +
+ setActiveModal("tdee")} + tooltip={ +

+ {t("tools.calorie-calculator.results.tdee_explanation")} +

+ } + /> +
+
{results.tdee.toLocaleString()}
+
kcal
+
+ +
+
+ {t("tools.calorie-calculator.results.target")} +
+
{results.targetCalories.toLocaleString()}
+
kcal
+
+
+ + {/* Macros */} +
+
+

{t("tools.calorie-calculator.results.macros")}

+ setActiveModal("macros")} + tooltip={ +

+ {t("tools.calorie-calculator.results.macros_explanation")} +

+ } + /> +
+
+
+
+
+ +
+
+ {t("tools.calorie-calculator.results.protein")} +
+
+
{results.proteinGrams}g
+
{results.proteinGrams * 4} kcal
+
+
30%
+
+
+ +
+
+
+ +
+
+ {t("tools.calorie-calculator.results.carbs")} +
+
+
{results.carbsGrams}g
+
{results.carbsGrams * 4} kcal
+
+
40%
+
+
+ +
+
+
+ +
+
+ {t("tools.calorie-calculator.results.fat")} +
+
+
{results.fatGrams}g
+
{results.fatGrams * 9} kcal
+
+
30%
+
+
+
+
+ + {/* Info Message */} +
+

+ {t("tools.calorie-calculator.results.disclaimer")} +

+
+ + {/* Mobile Modals */} + setActiveModal(null)} + title={t("tools.calorie-calculator.results.bmr")} + /> + setActiveModal(null)} + title={t("tools.calorie-calculator.results.tdee")} + /> + setActiveModal(null)} + title={t("tools.calorie-calculator.results.macros")} + /> +
+ ); +} diff --git a/app/[locale]/(app)/tools/calorie-calculator/shared/components/UnitSelector.tsx b/app/[locale]/(app)/tools/calorie-calculator/shared/components/UnitSelector.tsx new file mode 100644 index 00000000..68988f5a --- /dev/null +++ b/app/[locale]/(app)/tools/calorie-calculator/shared/components/UnitSelector.tsx @@ -0,0 +1,81 @@ +"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"; + +interface UnitSelectorProps { + value: UnitSystem; + onChange: (unit: UnitSystem) => void; +} + +export function UnitSelector({ value, onChange }: UnitSelectorProps) { + const t = useI18n(); + + return ( +
+ +
+ + + +
+
+ ); +} diff --git a/app/[locale]/(app)/tools/calorie-calculator/shared/components/WeightInput.tsx b/app/[locale]/(app)/tools/calorie-calculator/shared/components/WeightInput.tsx new file mode 100644 index 00000000..2b080f00 --- /dev/null +++ b/app/[locale]/(app)/tools/calorie-calculator/shared/components/WeightInput.tsx @@ -0,0 +1,42 @@ +"use client"; + +import React from "react"; + +import { useI18n } from "locales/client"; +import { UnitSystem } from "app/[locale]/(app)/tools/calorie-calculator/calorie-calculator.utils"; + +interface WeightInputProps { + value: number; + unit: UnitSystem; + onChange: (weight: number) => void; +} + +export function WeightInput({ value, unit, onChange }: WeightInputProps) { + const t = useI18n(); + const unitLabel = unit === "metric" ? t("tools.calorie-calculator.kg") : t("tools.calorie-calculator.lbs"); + const min = unit === "metric" ? 30 : 66; + const max = unit === "metric" ? 300 : 660; + + return ( +
+ +
+ onChange(Number(e.target.value))} + placeholder={t("tools.calorie-calculator.weight_placeholder")} + step="0.1" + type="number" + value={value} + /> + + {unitLabel} + +
+
+ ); +} diff --git a/app/[locale]/(app)/tools/calorie-calculator/shared/components/index.ts b/app/[locale]/(app)/tools/calorie-calculator/shared/components/index.ts new file mode 100644 index 00000000..f3e239f3 --- /dev/null +++ b/app/[locale]/(app)/tools/calorie-calculator/shared/components/index.ts @@ -0,0 +1,12 @@ +// Export all shared components +export { GenderSelector } from "./GenderSelector"; +export { WeightInput } from "./WeightInput"; +export { HeightInput } from "./HeightInput"; +export { AgeInput } from "./AgeInput"; +export { UnitSelector } from "./UnitSelector"; +export { ActivityLevelSelector } from "./ActivityLevelSelector"; +export { GoalSelector } from "./GoalSelector"; +export { FAQSection } from "./FAQSection"; +export { InfoButton } from "./InfoButton"; +export { InfoModal } from "./InfoModal"; +export { ResultsDisplay } from "./ResultsDisplay"; diff --git a/app/[locale]/(app)/tools/calorie-calculator/shared/types/index.ts b/app/[locale]/(app)/tools/calorie-calculator/shared/types/index.ts new file mode 100644 index 00000000..40a31ce9 --- /dev/null +++ b/app/[locale]/(app)/tools/calorie-calculator/shared/types/index.ts @@ -0,0 +1,13 @@ +// Calorie calculator types for better type safety +export type CalculatorFormula = "mifflin" | "harris" | "katch" | "cunningham" | "oxford"; + +export interface CalculatorConfig { + formula: CalculatorFormula; + requiresBodyFat: boolean; + name: string; + description: string; + buttonGradient: { + from: string; + to: string; + }; +} diff --git a/app/[locale]/(app)/tools/calorie-calculator/styles.css b/app/[locale]/(app)/tools/calorie-calculator/styles.css new file mode 100644 index 00000000..b4358799 --- /dev/null +++ b/app/[locale]/(app)/tools/calorie-calculator/styles.css @@ -0,0 +1,123 @@ +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.animate-fadeIn { + animation: fadeIn 0.5s ease-out; +} + +/* Custom slider styles */ +input[type="range"] { + -webkit-appearance: none; + appearance: none; + background: transparent; + cursor: pointer; +} + +input[type="range"]::-webkit-slider-track { + background: transparent; + height: 8px; + border-radius: 4px; +} + +input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + background: #4F8EF7; + height: 20px; + width: 20px; + border-radius: 50%; + cursor: pointer; + margin-top: -6px; + transition: all 0.2s ease-in-out; +} + +input[type="range"]::-webkit-slider-thumb:hover { + transform: scale(1.2); + background: #238BE6; +} + +input[type="range"]::-moz-range-track { + background: transparent; + height: 8px; + border-radius: 4px; +} + +input[type="range"]::-moz-range-thumb { + background: #4F8EF7; + height: 20px; + width: 20px; + border-radius: 50%; + cursor: pointer; + border: none; + transition: all 0.2s ease-in-out; +} + +input[type="range"]::-moz-range-thumb:hover { + transform: scale(1.2); + background: #238BE6; +} + +/* Dark mode adjustments */ +@media (prefers-color-scheme: dark) { + input[type="range"]::-webkit-slider-thumb { + background: #4F8EF7; + } + + input[type="range"]::-moz-range-thumb { + background: #4F8EF7; + } +} + +/* Mobile optimizations */ +@media (max-width: 768px) { + /* Larger touch targets */ + .touch-manipulation { + touch-action: manipulation; + -webkit-tap-highlight-color: transparent; + } + + /* Remove hover effects on mobile */ + @media (hover: none) and (pointer: coarse) { + .hover\:scale-105:hover { + transform: none; + } + + .hover\:border-primary\/30:hover { + border-color: inherit; + } + } + + /* Active states for better feedback */ + button:active { + transform: scale(0.98); + } + + /* Larger slider thumb for mobile */ + input[type="range"]::-webkit-slider-thumb { + height: 28px; + width: 28px; + } + + input[type="range"]::-moz-range-thumb { + height: 28px; + width: 28px; + } +} + +/* Smooth scrolling */ +html { + scroll-behavior: smooth; +} + +/* Prevent layout shift on modal open */ +body.modal-open { + padding-right: 0 !important; +} \ No newline at end of file diff --git a/app/[locale]/(app)/tools/heart-rate-zones/lib/utils.ts b/app/[locale]/(app)/tools/heart-rate-zones/lib/utils.ts new file mode 100644 index 00000000..5a6fda2a --- /dev/null +++ b/app/[locale]/(app)/tools/heart-rate-zones/lib/utils.ts @@ -0,0 +1,75 @@ +import { TFunction } from "locales/client"; + +interface HeartRateZone { + name: string; + minHR: number; + maxHR: number; + emoji: string; + color: string; + bgColor: string; + description: string; +} + +interface HeartRateResults { + maxHeartRate: number; + zones: HeartRateZone[]; +} + +export function calculateHeartRateZones(age: number, t: TFunction): HeartRateResults { + // Calculate MHR + const maxHeartRate = 220 - age; + + // Simple zones with emojis and colors + const zones: HeartRateZone[] = [ + { + name: t("tools.heart-rate-zones.zones.warm_up.name"), + minHR: Math.round(maxHeartRate * 0.5), + maxHR: Math.round(maxHeartRate * 0.6), + emoji: "🚶", + color: "text-blue-600", + bgColor: "bg-blue-100", + description: t("tools.heart-rate-zones.zones.warm_up.description"), + }, + { + name: t("tools.heart-rate-zones.zones.fat_burn.name"), + minHR: Math.round(maxHeartRate * 0.6), + maxHR: Math.round(maxHeartRate * 0.7), + emoji: "🔥", + color: "text-green-600", + bgColor: "bg-green-100", + description: t("tools.heart-rate-zones.zones.fat_burn.description"), + }, + { + name: t("tools.heart-rate-zones.zones.aerobic.name"), + minHR: Math.round(maxHeartRate * 0.7), + maxHR: Math.round(maxHeartRate * 0.8), + emoji: "🏃", + color: "text-yellow-600", + bgColor: "bg-yellow-100", + description: t("tools.heart-rate-zones.zones.aerobic.description"), + }, + { + name: t("tools.heart-rate-zones.zones.anaerobic.name"), + minHR: Math.round(maxHeartRate * 0.8), + maxHR: Math.round(maxHeartRate * 0.9), + emoji: "💪", + color: "text-orange-600", + bgColor: "bg-orange-100", + description: t("tools.heart-rate-zones.zones.anaerobic.description"), + }, + { + name: t("tools.heart-rate-zones.zones.vo2_max.name"), + minHR: Math.round(maxHeartRate * 0.9), + maxHR: maxHeartRate, + emoji: "🚀", + color: "text-red-600", + bgColor: "bg-red-100", + description: t("tools.heart-rate-zones.zones.vo2_max.description"), + }, + ]; + + return { + maxHeartRate, + zones, + }; +} diff --git a/app/[locale]/(app)/tools/heart-rate-zones/page.tsx b/app/[locale]/(app)/tools/heart-rate-zones/page.tsx new file mode 100644 index 00000000..6e8468bc --- /dev/null +++ b/app/[locale]/(app)/tools/heart-rate-zones/page.tsx @@ -0,0 +1,213 @@ +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"; +import { SEOOptimizedContentServer } from "app/[locale]/(app)/tools/heart-rate-zones/ui/components/SEOOptimizedContentServer"; +import { EducationalContentServer } from "app/[locale]/(app)/tools/heart-rate-zones/ui/components/EducationalContentServer"; +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"; +import { HorizontalBottomBanner, HorizontalTopBanner } from "@/components/ads"; + +export async function generateMetadata({ params }: { params: Promise<{ locale: Locale }> }): Promise { + const { locale } = await params; + + // Use centralized SEO config + const metadata = HEART_RATE_ZONES_SEO[locale] || HEART_RATE_ZONES_SEO.en; + + return generateSEOMetadata({ + title: metadata.title, + description: metadata.description, + keywords: metadata.keywords, + locale, + canonical: `${getServerUrl()}/${locale}/tools/heart-rate-zones`, + structuredData: { + type: "Calculator", + calculatorData: { + calculatorType: "heart-rate-zones", + inputFields: ["age", "resting heart rate", "maximum heart rate", "calculation method"], + outputFields: [ + "Maximum Heart Rate (MHR)", + "Target Heart Rate (THR)", + "VO2 Max Zone (90-100%)", + "Anaerobic Zone (80-90%)", + "Aerobic Zone (70-80%)", + "Fat Burn Zone (60-70%)", + "Warm Up Zone (50-60%)", + "Heart Rate Reserve (HRR)", + ], + formula: "Basic: THR = MHR × %Intensity | Karvonen: THR = [(MHR - RHR) × %Intensity] + RHR", + accuracy: "Scientifically validated formulas with personalized calculations", + targetAudience: ["athletes", "runners", "cyclists", "fitness enthusiasts", "personal trainers"], + relatedCalculators: ["bmi-calculator", "calorie-calculator", "macro-calculator"], + }, + }, + }); +} + +const DEFAULT_AGE = 30; +export default async function HeartRateZonesPage({ params }: { params: Promise<{ locale: Locale }> }) { + const { locale } = await params; + const t = await getI18n(); + + const defaultResults = calculateHeartRateZones(DEFAULT_AGE, t); + + // Use centralized configs + const seoContent = HEART_RATE_ZONES_SEO[locale] || HEART_RATE_ZONES_SEO.en; + const pageContent = HEART_RATE_ZONES_CONTENT[locale] || HEART_RATE_ZONES_CONTENT.en; + + return ( + <> + + +