Repository: Snouzy/workout-cool Branch: main Commit: dd60a7bbf5d6 Files: 664 Total size: 2.6 MB Directory structure: gitextract_ajccn8ug/ ├── .cursorrules ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ ├── config.yml │ │ └── feature_request.md │ ├── pull_request_template.md │ └── workflows/ │ ├── ci.yml │ ├── notify-discord-issues.yml │ ├── notify-discord-pr.yml │ ├── notify-discord.yml │ └── publish-ghcr-image.yml ├── .gitignore ├── .npmrc ├── .prettierrc ├── .vscode/ │ └── settings.json ├── AGENTS.md ├── CLAUDE.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── app/ │ ├── [locale]/ │ │ ├── (admin)/ │ │ │ └── admin/ │ │ │ ├── [...catchAll]/ │ │ │ │ ├── not-found.tsx │ │ │ │ └── page.tsx │ │ │ ├── dashboard/ │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ ├── not-found.tsx │ │ │ ├── programs/ │ │ │ │ ├── [id]/ │ │ │ │ │ └── edit/ │ │ │ │ │ └── page.tsx │ │ │ │ └── page.tsx │ │ │ ├── settings/ │ │ │ │ └── page.tsx │ │ │ └── users/ │ │ │ └── page.tsx │ │ ├── (app)/ │ │ │ ├── (legal-and-payment)/ │ │ │ │ ├── layout.tsx │ │ │ │ └── legal/ │ │ │ │ ├── privacy/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── sales-terms/ │ │ │ │ │ └── page.tsx │ │ │ │ └── terms/ │ │ │ │ └── page.tsx │ │ │ ├── [slug]/ │ │ │ │ └── layout.tsx │ │ │ ├── about/ │ │ │ │ └── page.tsx │ │ │ ├── auth/ │ │ │ │ ├── (auth-layout)/ │ │ │ │ │ ├── forgot-password/ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── layout.tsx │ │ │ │ │ ├── reset-password/ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── signin/ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ └── signup/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── error/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── error.tsx │ │ │ │ ├── layout.tsx │ │ │ │ ├── signout/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── verify-email/ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ └── page.tsx │ │ │ │ └── verify-request/ │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ ├── leaderboard/ │ │ │ │ └── page.tsx │ │ │ ├── onboarding/ │ │ │ │ ├── layout.tsx │ │ │ │ └── page.tsx │ │ │ ├── page.tsx │ │ │ ├── premium/ │ │ │ │ └── page.tsx │ │ │ ├── profile/ │ │ │ │ └── page.tsx │ │ │ ├── programs/ │ │ │ │ ├── [slug]/ │ │ │ │ │ ├── page.tsx │ │ │ │ │ └── session/ │ │ │ │ │ └── [sessionSlug]/ │ │ │ │ │ ├── ProgramSessionClient.tsx │ │ │ │ │ └── page.tsx │ │ │ │ └── page.tsx │ │ │ ├── statistics/ │ │ │ │ └── page.tsx │ │ │ └── tools/ │ │ │ ├── bmi-calculator/ │ │ │ │ ├── bmi-calculator.utils.ts │ │ │ │ ├── page.tsx │ │ │ │ └── shared/ │ │ │ │ ├── BmiCalculatorClient.tsx │ │ │ │ └── components/ │ │ │ │ ├── BmiEducationalContent.tsx │ │ │ │ ├── BmiHeightInput.tsx │ │ │ │ ├── BmiResultsDisplay.tsx │ │ │ │ ├── BmiUnitSelector.tsx │ │ │ │ ├── BmiWeightInput.tsx │ │ │ │ └── MathEquation.tsx │ │ │ ├── calorie-calculator/ │ │ │ │ ├── CalorieCalculatorHub.tsx │ │ │ │ ├── calorie-calculator-comparison/ │ │ │ │ │ ├── CalorieCalculatorComparison.tsx │ │ │ │ │ └── page.tsx │ │ │ │ ├── calorie-calculator.utils.ts │ │ │ │ ├── cunningham-calculator/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── harris-benedict-calculator/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── katch-mcardle-calculator/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── mifflin-st-jeor-calculator/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── oxford-calculator/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── page.tsx │ │ │ │ ├── shared/ │ │ │ │ │ ├── CalorieCalculatorClient.tsx │ │ │ │ │ ├── calculator-configs.ts │ │ │ │ │ ├── calorie-formulas.utils.ts │ │ │ │ │ ├── components/ │ │ │ │ │ │ ├── ActivityLevelSelector.tsx │ │ │ │ │ │ ├── AgeInput.tsx │ │ │ │ │ │ ├── BodyFatInput.tsx │ │ │ │ │ │ ├── FAQSection.tsx │ │ │ │ │ │ ├── GenderSelector.tsx │ │ │ │ │ │ ├── GoalSelector.tsx │ │ │ │ │ │ ├── HeightInput.tsx │ │ │ │ │ │ ├── InfoButton.tsx │ │ │ │ │ │ ├── InfoModal.tsx │ │ │ │ │ │ ├── ResultsDisplay.tsx │ │ │ │ │ │ ├── UnitSelector.tsx │ │ │ │ │ │ ├── WeightInput.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── types/ │ │ │ │ │ └── index.ts │ │ │ │ └── styles.css │ │ │ ├── heart-rate-zones/ │ │ │ │ ├── lib/ │ │ │ │ │ └── utils.ts │ │ │ │ ├── page.tsx │ │ │ │ ├── seo/ │ │ │ │ │ ├── config.ts │ │ │ │ │ └── page-content.ts │ │ │ │ └── ui/ │ │ │ │ ├── HeartRateZonesCalculatorClient.tsx │ │ │ │ ├── components/ │ │ │ │ │ ├── EducationalContent.tsx │ │ │ │ │ ├── EducationalContentServer.tsx │ │ │ │ │ ├── FAQAccordion.tsx │ │ │ │ │ ├── SEOOptimizedContentServer.tsx │ │ │ │ │ └── ScrollToTopButton.tsx │ │ │ │ └── styles.css │ │ │ └── page.tsx │ │ ├── @modal/ │ │ │ └── (.)auth/ │ │ │ └── login/ │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ ├── manifest.json/ │ │ │ └── route.ts │ │ ├── not-found.tsx │ │ └── providers.tsx │ ├── ads.txt/ │ │ └── route.ts │ ├── api/ │ │ ├── analytics/ │ │ │ └── premium/ │ │ │ └── route.ts │ │ ├── auth/ │ │ │ ├── [...all]/ │ │ │ │ └── route.ts │ │ │ └── signup/ │ │ │ └── route.ts │ │ ├── billing/ │ │ │ └── status/ │ │ │ └── route.ts │ │ ├── exercises/ │ │ │ ├── [exerciseId]/ │ │ │ │ └── statistics/ │ │ │ │ ├── one-rep-max/ │ │ │ │ │ └── route.ts │ │ │ │ ├── route.ts │ │ │ │ ├── volume/ │ │ │ │ │ └── route.ts │ │ │ │ └── weight-progression/ │ │ │ │ └── route.ts │ │ │ ├── all/ │ │ │ │ └── route.ts │ │ │ ├── route.ts │ │ │ └── shuffle/ │ │ │ └── route.ts │ │ ├── premium/ │ │ │ ├── billing-portal/ │ │ │ │ └── route.ts │ │ │ ├── checkout/ │ │ │ │ └── route.ts │ │ │ ├── plans/ │ │ │ │ └── route.ts │ │ │ └── status/ │ │ │ └── route.ts │ │ ├── programs/ │ │ │ ├── [slug]/ │ │ │ │ ├── enroll/ │ │ │ │ │ └── route.ts │ │ │ │ ├── progress/ │ │ │ │ │ └── route.ts │ │ │ │ ├── route.ts │ │ │ │ └── sessions/ │ │ │ │ └── [sessionSlug]/ │ │ │ │ └── route.ts │ │ │ ├── route.ts │ │ │ └── session-progress/ │ │ │ ├── [progressId]/ │ │ │ │ └── complete/ │ │ │ │ └── route.ts │ │ │ └── start/ │ │ │ └── route.ts │ │ ├── revenuecat/ │ │ │ ├── link-user/ │ │ │ │ └── route.ts │ │ │ ├── sync-status/ │ │ │ │ └── route.ts │ │ │ └── webhook/ │ │ │ └── route.ts │ │ ├── user/ │ │ │ ├── password/ │ │ │ │ └── route.ts │ │ │ └── profile/ │ │ │ └── route.ts │ │ ├── webhooks/ │ │ │ ├── revenuecat/ │ │ │ │ └── route.ts │ │ │ └── stripe/ │ │ │ └── route.ts │ │ └── workout-sessions/ │ │ ├── [sessionId]/ │ │ │ ├── feedback/ │ │ │ │ └── route.ts │ │ │ ├── rating/ │ │ │ │ └── route.ts │ │ │ ├── route.ts │ │ │ └── summary/ │ │ │ └── route.ts │ │ ├── sync/ │ │ │ └── route.ts │ │ └── user/ │ │ └── [userId]/ │ │ └── route.ts │ ├── robots.txt │ └── sitemap.ts ├── components.json ├── content/ │ ├── about/ │ │ ├── en.mdx │ │ ├── es.mdx │ │ ├── fr.mdx │ │ ├── pt.mdx │ │ ├── ru.mdx │ │ └── zh-CN.mdx │ ├── privacy-policy/ │ │ ├── en.mdx │ │ ├── es.mdx │ │ ├── fr.mdx │ │ ├── pt.mdx │ │ ├── ru.mdx │ │ └── zh-CN.mdx │ ├── sales-terms/ │ │ ├── en.mdx │ │ ├── es.mdx │ │ ├── fr.mdx │ │ ├── pt.mdx │ │ ├── ru.mdx │ │ └── zh-CN.mdx │ └── terms/ │ ├── en.mdx │ ├── es.mdx │ ├── fr.mdx │ ├── pt.mdx │ ├── ru.mdx │ └── zh-CN.mdx ├── data/ │ └── sample-exercises.csv ├── docker-compose.yml ├── docs/ │ └── SELF-HOSTING.md ├── emails/ │ ├── ContactSupportEmail.tsx │ ├── DeleteAccountEmail.tsx │ ├── ResetPasswordEmail.tsx │ ├── VerifyEmail.tsx │ └── utils/ │ └── BaseEmailLayout.tsx ├── eslint.config.mjs ├── locales/ │ ├── client.ts │ ├── en.ts │ ├── es.ts │ ├── fr.ts │ ├── heart-rate-zones-translations.ts │ ├── pt.ts │ ├── ru.ts │ ├── server.ts │ ├── types.ts │ └── zh-CN.ts ├── middleware.ts ├── next.config.ts ├── nextauth.d.ts ├── package.json ├── postcss.config.mjs ├── prisma/ │ ├── migrations/ │ │ └── 0_init/ │ │ └── migration.sql │ ├── migrations_backup/ │ │ ├── 20240726_simplify_subscription_model/ │ │ │ └── migration.sql │ │ ├── 20250101000000_baseline/ │ │ │ └── migration.sql │ │ ├── 20250117000000_add_statistics_indexes/ │ │ │ └── migration.sql │ │ ├── 20250414120436_init/ │ │ │ └── migration.sql │ │ ├── 20250414170807_add_feedbacks/ │ │ │ └── migration.sql │ │ ├── 20250414174246_rename_feedbacks/ │ │ │ └── migration.sql │ │ ├── 20250414232816_add_first_name_and_last_name/ │ │ │ └── migration.sql │ │ ├── 20250416160303_add_plans/ │ │ │ └── migration.sql │ │ ├── 20250416160502_map/ │ │ │ └── migration.sql │ │ ├── 20250505114841_add_user_role/ │ │ │ └── migration.sql │ │ ├── 20250505191954_admin_and_user_lowercase/ │ │ │ └── migration.sql │ │ ├── 20250610182024_add_exercises_and_attributes/ │ │ │ └── migration.sql │ │ ├── 20250610182815_add_exercise_enums/ │ │ │ └── migration.sql │ │ ├── 20250610184725_simplified_exercises/ │ │ │ └── migration.sql │ │ ├── 20250611190228_convert_text_to_enums/ │ │ │ └── migration.sql │ │ ├── 20250611210106_add_enum_values/ │ │ │ └── migration.sql │ │ ├── 20250612213546_workout_session_sets/ │ │ │ └── migration.sql │ │ ├── 20250613095031_add_multi_column_support/ │ │ │ └── migration.sql │ │ ├── 20250614125347_add_table_maps/ │ │ │ └── migration.sql │ │ ├── 20250614153656_remove_value_int_value_sec_unit_from_workoutset/ │ │ │ └── migration.sql │ │ ├── 20250615160343_add_muscle_to_a_workout_session/ │ │ │ └── migration.sql │ │ ├── 20250615170916_add_cascade_delete_workout_sessions/ │ │ │ └── migration.sql │ │ ├── 20250623142458_add_billing_and_subscriptions/ │ │ │ └── migration.sql │ │ ├── 20250623143952_remove_webhook_events/ │ │ │ └── migration.sql │ │ ├── 20250623144324_add_webhook_events/ │ │ │ └── migration.sql │ │ ├── 20250625155932_add_admin/ │ │ │ └── migration.sql │ │ ├── 20250625195907_add_program_visibility/ │ │ │ └── migration.sql │ │ ├── 20250626102058_add_i18n_slugs_on_program/ │ │ │ └── migration.sql │ │ ├── 20250626134345_remove_emoji_on_program/ │ │ │ └── migration.sql │ │ ├── 20250626182857_cleanup_billing_system/ │ │ │ └── migration.sql │ │ ├── 20250626204136_remove_payment_table/ │ │ │ └── migration.sql │ │ ├── 20250626205121_remove_legacy_premium_fields/ │ │ │ └── migration.sql │ │ ├── 20250626205904_remove_payment_table_keep_ispremium/ │ │ │ └── migration.sql │ │ ├── 20250707114920_add_user_favorite_exercises/ │ │ │ └── migration.sql │ │ ├── 20250708214116_add_rating_to_wkt_sessions/ │ │ │ └── migration.sql │ │ ├── 20250709_add_revenuecat_fields/ │ │ │ └── migration.sql │ │ └── migration_lock.toml │ └── schema.prisma ├── public/ │ ├── _ads.txt │ ├── manifest.json │ └── sw.js ├── scripts/ │ ├── check-pricing-config.ts │ ├── import-exercises-with-attributes.prompt.md │ ├── import-exercises-with-attributes.ts │ ├── seed-leaderboard-data.ts │ ├── seed-multi-region-plans.ts │ ├── seed-subscription-plans-simple.ts │ ├── seed-workout-data-advanced.ts │ └── setup.sh ├── src/ │ ├── components/ │ │ ├── ads/ │ │ │ ├── AdBlockerForPremium.tsx │ │ │ ├── AdPlaceholder.tsx │ │ │ ├── AdSenseAutoAds.tsx │ │ │ ├── AdWrapper.tsx │ │ │ ├── EzoicAd.tsx │ │ │ ├── GoogleAdSense.tsx │ │ │ ├── HorizontalAdBanner.tsx │ │ │ ├── HorizontalBottomBanner.tsx │ │ │ ├── HorizontalTopBanner.tsx │ │ │ ├── InArticle.tsx │ │ │ ├── ResponsiveAdBanner.tsx │ │ │ ├── VerticalAdBanner.tsx │ │ │ ├── VerticalLeftBanner.tsx │ │ │ ├── VerticalRightBanner.tsx │ │ │ ├── index.ts │ │ │ └── nutripure-affiliate-banner.tsx │ │ ├── premium/ │ │ │ └── RemoveAdsText.tsx │ │ ├── pwa/ │ │ │ └── ServiceWorkerRegistration.tsx │ │ ├── seo/ │ │ │ ├── SEOHead.tsx │ │ │ ├── breadcrumbs.tsx │ │ │ ├── duration-badge.tsx │ │ │ ├── rich-snippet-rating.tsx │ │ │ └── session-rich-snippets.tsx │ │ ├── svg/ │ │ │ ├── BrokenLink.tsx │ │ │ ├── Calendly.tsx │ │ │ ├── CircleSvg.tsx │ │ │ ├── DiscordSvg.tsx │ │ │ ├── DotPattern.tsx │ │ │ ├── GoogleSvg.tsx │ │ │ ├── IconCheckboxCheck.tsx │ │ │ ├── LogoSvg.tsx │ │ │ ├── UnderlineSvg.tsx │ │ │ ├── VerifiedBadge.tsx │ │ │ └── Youtube.tsx │ │ ├── ui/ │ │ │ ├── 404-page-not-found.tsx │ │ │ ├── Bento.tsx │ │ │ ├── ToastSonner.tsx │ │ │ ├── accordion.tsx │ │ │ ├── alert-dialog.tsx │ │ │ ├── alert.tsx │ │ │ ├── animated-button/ │ │ │ │ └── ShinyButton.tsx │ │ │ ├── aspect-ratio.tsx │ │ │ ├── avatar.tsx │ │ │ ├── badge.tsx │ │ │ ├── bottom-sheet-vaul.tsx │ │ │ ├── bottom-sheet.tsx │ │ │ ├── button.tsx │ │ │ ├── card-styled.tsx │ │ │ ├── card.tsx │ │ │ ├── collapsible.tsx │ │ │ ├── dialog-stack.tsx │ │ │ ├── dialog.tsx │ │ │ ├── divider.tsx │ │ │ ├── donation-alert.tsx │ │ │ ├── dropdown-menu.tsx │ │ │ ├── form.tsx │ │ │ ├── hover-card.tsx │ │ │ ├── input-password-strength.tsx │ │ │ ├── input.tsx │ │ │ ├── iphone-mockup.tsx │ │ │ ├── label.tsx │ │ │ ├── link.tsx │ │ │ ├── loader.tsx │ │ │ ├── local-alert.tsx │ │ │ ├── moving-border.tsx │ │ │ ├── navigation-menu.tsx │ │ │ ├── next-top-loader.tsx │ │ │ ├── pagination.tsx │ │ │ ├── phone-frame-preview.tsx │ │ │ ├── popover.tsx │ │ │ ├── premium-gate.tsx │ │ │ ├── premium-upsell-alert.tsx │ │ │ ├── radio-group.tsx │ │ │ ├── scroll-area.tsx │ │ │ ├── select.tsx │ │ │ ├── separator.tsx │ │ │ ├── sheet.tsx │ │ │ ├── shine-border.tsx │ │ │ ├── simple-select.tsx │ │ │ ├── skeleton.tsx │ │ │ ├── slider.tsx │ │ │ ├── sonner.tsx │ │ │ ├── star-button.tsx │ │ │ ├── switch.tsx │ │ │ ├── table.tsx │ │ │ ├── tabs.tsx │ │ │ ├── textarea.tsx │ │ │ ├── theme-provider.tsx │ │ │ ├── timer.tsx │ │ │ ├── title-with-dot.tsx │ │ │ ├── toast.tsx │ │ │ ├── toaster.tsx │ │ │ ├── tooltip.tsx │ │ │ ├── typography.tsx │ │ │ ├── use-toast.ts │ │ │ └── workout-lol.tsx │ │ ├── utils/ │ │ │ ├── ErrorBoundaries.tsx │ │ │ └── TailwindIndicator.tsx │ │ └── version.tsx │ ├── entities/ │ │ ├── exercise/ │ │ │ ├── shared/ │ │ │ │ └── muscles.tsx │ │ │ └── types/ │ │ │ └── exercise.types.ts │ │ ├── program/ │ │ │ └── types/ │ │ │ └── program.types.ts │ │ ├── program-session/ │ │ │ └── types/ │ │ │ └── program-session.types.ts │ │ └── user/ │ │ ├── lib/ │ │ │ └── display-name.ts │ │ ├── model/ │ │ │ ├── get-server-session-user.ts │ │ │ ├── get-users.actions.ts │ │ │ ├── update-user-locale.ts │ │ │ ├── update-user.action.ts │ │ │ ├── use-auto-locale.ts │ │ │ ├── useCurrentSession.ts │ │ │ └── useCurrentUser.ts │ │ ├── schemas/ │ │ │ ├── get-user.schema.ts │ │ │ └── update-user.schema.ts │ │ └── types/ │ │ └── session-user.ts │ ├── env.ts │ ├── features/ │ │ ├── admin/ │ │ │ ├── layout/ │ │ │ │ ├── admin-header.tsx │ │ │ │ └── admin-sidebar/ │ │ │ │ └── ui/ │ │ │ │ ├── admin-header.tsx │ │ │ │ └── admin-sidebar.tsx │ │ │ ├── programs/ │ │ │ │ ├── actions/ │ │ │ │ │ ├── add-exercise.action.ts │ │ │ │ │ ├── add-session.action.ts │ │ │ │ │ ├── add-week.action.ts │ │ │ │ │ ├── create-program.action.ts │ │ │ │ │ ├── delete-program.action.ts │ │ │ │ │ ├── get-programs.action.ts │ │ │ │ │ ├── update-exercise-sets.action.ts │ │ │ │ │ ├── update-program-visibility.action.ts │ │ │ │ │ ├── update-program.action.ts │ │ │ │ │ ├── update-session.action.ts │ │ │ │ │ └── update-week.action.ts │ │ │ │ ├── types/ │ │ │ │ │ └── program.types.ts │ │ │ │ └── ui/ │ │ │ │ ├── add-exercise-modal.tsx │ │ │ │ ├── add-session-modal.tsx │ │ │ │ ├── add-week-modal.tsx │ │ │ │ ├── create-program-button.tsx │ │ │ │ ├── create-program-form.tsx │ │ │ │ ├── create-program-modal.tsx │ │ │ │ ├── delete-program-button.tsx │ │ │ │ ├── edit-program-modal.tsx │ │ │ │ ├── edit-session-modal.tsx │ │ │ │ ├── edit-sets-modal.tsx │ │ │ │ ├── edit-week-modal.tsx │ │ │ │ ├── program-builder.tsx │ │ │ │ ├── programs-list.tsx │ │ │ │ ├── session-card.tsx │ │ │ │ ├── visibility-badge.tsx │ │ │ │ └── week-card.tsx │ │ │ └── users/ │ │ │ └── list/ │ │ │ └── ui/ │ │ │ └── users-table.tsx │ │ ├── ads/ │ │ │ └── hooks/ │ │ │ └── useUserSubscription.ts │ │ ├── auth/ │ │ │ ├── forgot-password/ │ │ │ │ ├── forgot-password.schema.ts │ │ │ │ ├── model/ │ │ │ │ │ └── useForgotPassword.tsx │ │ │ │ └── ui/ │ │ │ │ └── forgot-password-form.tsx │ │ │ ├── lib/ │ │ │ │ ├── auth-client.ts │ │ │ │ └── better-auth.ts │ │ │ ├── model/ │ │ │ │ └── useLogout.ts │ │ │ ├── reset-password/ │ │ │ │ ├── model/ │ │ │ │ │ └── useResetPassword.ts │ │ │ │ ├── schema/ │ │ │ │ │ └── reset-password.schema.ts │ │ │ │ └── ui/ │ │ │ │ └── reset-password-form.tsx │ │ │ ├── signin/ │ │ │ │ ├── model/ │ │ │ │ │ └── useSignIn.ts │ │ │ │ ├── schema/ │ │ │ │ │ └── signin.schema.ts │ │ │ │ └── ui/ │ │ │ │ └── CredentialsLoginForm.tsx │ │ │ ├── signup/ │ │ │ │ ├── model/ │ │ │ │ │ ├── signup.action.ts │ │ │ │ │ └── useSignUp.ts │ │ │ │ ├── schema/ │ │ │ │ │ └── signup.schema.ts │ │ │ │ └── ui/ │ │ │ │ └── signup-form.tsx │ │ │ ├── ui/ │ │ │ │ ├── AuthButtonServer.tsx │ │ │ │ ├── LoggedInButton.tsx │ │ │ │ ├── ProviderButton.tsx │ │ │ │ ├── SignInButton.tsx │ │ │ │ └── SignUpButton.tsx │ │ │ └── verify-email/ │ │ │ ├── constants.ts │ │ │ ├── model/ │ │ │ │ └── useResendEmail.ts │ │ │ └── ui/ │ │ │ └── verify-email-page.tsx │ │ ├── consent-banner/ │ │ │ ├── model/ │ │ │ │ └── tracking-consent.action.ts │ │ │ ├── schema/ │ │ │ │ └── tracking-consent.schema.ts │ │ │ └── ui/ │ │ │ └── consent-banner.tsx │ │ ├── contact/ │ │ │ └── support/ │ │ │ ├── ContactSupportDialog.tsx │ │ │ ├── contact-support.action.ts │ │ │ └── contact-support.schema.ts │ │ ├── contact-feedback/ │ │ │ ├── model/ │ │ │ │ ├── contact-feedback.action.ts │ │ │ │ └── contact-feedback.schema.ts │ │ │ └── ui/ │ │ │ ├── ReviewInput.tsx │ │ │ └── contact-feedback-popover.tsx │ │ ├── dialogs-provider/ │ │ │ ├── DialogProvider.tsx │ │ │ └── DialogProviderDialog.tsx │ │ ├── email/ │ │ │ ├── EmailForm.tsx │ │ │ ├── email.action.ts │ │ │ └── email.schema.ts │ │ ├── form/ │ │ │ └── SubmitButton.tsx │ │ ├── layout/ │ │ │ ├── BottomNavigation.tsx │ │ │ ├── Footer.tsx │ │ │ ├── Header.tsx │ │ │ ├── model/ │ │ │ │ └── use-sidebar.store.tsx │ │ │ ├── nav-link.tsx │ │ │ ├── page-heading.tsx │ │ │ ├── useSidebarToggle.ts │ │ │ └── workout-streak-header.tsx │ │ ├── leaderboard/ │ │ │ ├── actions/ │ │ │ │ ├── get-top-workout-users.action.ts │ │ │ │ └── get-user-position.action.ts │ │ │ ├── hooks/ │ │ │ │ ├── use-top-workout-users.ts │ │ │ │ └── use-user-position.ts │ │ │ ├── lib/ │ │ │ │ └── utils.ts │ │ │ ├── models/ │ │ │ │ └── types.ts │ │ │ └── ui/ │ │ │ ├── leaderboard-item.tsx │ │ │ ├── leaderboard-page.tsx │ │ │ ├── leaderboard-skeleton.tsx │ │ │ └── user-leaderboard-position.tsx │ │ ├── page/ │ │ │ └── layout.tsx │ │ ├── premium/ │ │ │ └── ui/ │ │ │ ├── README.md │ │ │ ├── conversion-flow-notification.tsx │ │ │ ├── feature-comparison-table.tsx │ │ │ ├── index.ts │ │ │ ├── premium-upgrade-card.tsx │ │ │ ├── pricing-faq.tsx │ │ │ ├── pricing-hero-section.tsx │ │ │ └── pricing-testimonials.tsx │ │ ├── programs/ │ │ │ ├── actions/ │ │ │ │ ├── complete-program-session.action.ts │ │ │ │ ├── enroll-program.action.ts │ │ │ │ ├── get-program-by-slug.action.ts │ │ │ │ ├── get-program-progress-by-slug.action.ts │ │ │ │ ├── get-program-progress.action.ts │ │ │ │ ├── get-public-programs.action.ts │ │ │ │ ├── get-session-by-slug.action.ts │ │ │ │ ├── get-sitemap-data.action.ts │ │ │ │ └── start-program-session.action.ts │ │ │ ├── hooks/ │ │ │ │ └── use-program-share.ts │ │ │ ├── lib/ │ │ │ │ ├── program-metadata.ts │ │ │ │ ├── session-metadata.ts │ │ │ │ ├── suggested-sets-helpers.ts │ │ │ │ └── translations-mapper.ts │ │ │ └── ui/ │ │ │ ├── program-card.tsx │ │ │ ├── program-detail-page.tsx │ │ │ ├── program-progress.tsx │ │ │ ├── programs-page.tsx │ │ │ ├── session-access-guard.tsx │ │ │ ├── share-button.tsx │ │ │ └── welcome-modal.tsx │ │ ├── release-notes/ │ │ │ ├── hooks/ │ │ │ │ ├── index.ts │ │ │ │ └── use-changelog-notification.ts │ │ │ ├── index.ts │ │ │ ├── lib/ │ │ │ │ └── date-utils.ts │ │ │ ├── model/ │ │ │ │ ├── changelog-notification.local.ts │ │ │ │ └── notes.ts │ │ │ ├── types/ │ │ │ │ └── notification.ts │ │ │ └── ui/ │ │ │ ├── changelog-notification-badge.tsx │ │ │ ├── index.ts │ │ │ └── release-notes-dialog.tsx │ │ ├── statistics/ │ │ │ ├── components/ │ │ │ │ ├── ExerciseSelection.tsx │ │ │ │ ├── ExerciseStatisticsTab.tsx │ │ │ │ ├── ExercisesBrowser.tsx │ │ │ │ ├── OneRepMaxChart.tsx │ │ │ │ ├── StatisticsPreviewOverlay.tsx │ │ │ │ ├── TimeframeSelector.tsx │ │ │ │ ├── VolumeChart.tsx │ │ │ │ ├── WeightProgressionChart.tsx │ │ │ │ └── index.ts │ │ │ ├── hooks/ │ │ │ │ ├── use-chart-theme.ts │ │ │ │ └── use-exercise-statistics.ts │ │ │ └── types/ │ │ │ └── index.ts │ │ ├── theme/ │ │ │ ├── ThemeProviders.tsx │ │ │ ├── ThemeToggle.tsx │ │ │ └── ui/ │ │ │ └── ThemeSynchronizer.tsx │ │ ├── update-password/ │ │ │ ├── lib/ │ │ │ │ ├── hash.ts │ │ │ │ └── validate-password.ts │ │ │ ├── model/ │ │ │ │ ├── update-password.action.ts │ │ │ │ └── update-password.schema.ts │ │ │ └── ui/ │ │ │ └── password-form.tsx │ │ ├── user/ │ │ │ └── ui/ │ │ │ └── UserDropdown.tsx │ │ ├── workout-builder/ │ │ │ ├── actions/ │ │ │ │ ├── get-exercises-by-muscle.action.ts │ │ │ │ ├── get-exercises.action.ts │ │ │ │ ├── get-favorite-exercises.action.ts │ │ │ │ ├── pick-exercise.action.ts │ │ │ │ ├── shuffle-exercise.action.ts │ │ │ │ └── sync-favorite-exercises.action.ts │ │ │ ├── hooks/ │ │ │ │ ├── use-exercises.ts │ │ │ │ ├── use-favorite-exercises.service.ts │ │ │ │ ├── use-favorites-modal.ts │ │ │ │ ├── use-sync-favorite-exercises.ts │ │ │ │ ├── use-workout-session.ts │ │ │ │ └── use-workout-stepper.ts │ │ │ ├── index.ts │ │ │ ├── model/ │ │ │ │ ├── equipment-config.ts │ │ │ │ ├── favorite-exercises-synchronizer.tsx │ │ │ │ ├── favorite-exercises.local.ts │ │ │ │ └── workout-builder.store.ts │ │ │ ├── schema/ │ │ │ │ └── get-exercises.schema.ts │ │ │ ├── types/ │ │ │ │ └── index.ts │ │ │ └── ui/ │ │ │ ├── add-exercise-modal.tsx │ │ │ ├── equipment-selection.tsx │ │ │ ├── exercise-card.tsx │ │ │ ├── exercise-list-item.tsx │ │ │ ├── exercise-pick-modal.tsx │ │ │ ├── exercise-video-modal.tsx │ │ │ ├── exercises-selection.tsx │ │ │ ├── favorite-button.tsx │ │ │ ├── favorite-exercise-button.tsx │ │ │ ├── muscle-selection.tsx │ │ │ ├── muscles/ │ │ │ │ ├── abdominals-group.tsx │ │ │ │ ├── back-group.tsx │ │ │ │ ├── biceps-group.tsx │ │ │ │ ├── calves-group.tsx │ │ │ │ ├── chest-group.tsx │ │ │ │ ├── forearms-group.tsx │ │ │ │ ├── glutes-group.tsx │ │ │ │ ├── hamstrings-group.tsx │ │ │ │ ├── obliques-group.tsx │ │ │ │ ├── quadriceps-group.tsx │ │ │ │ ├── shoulders-group.tsx │ │ │ │ ├── traps-group.tsx │ │ │ │ └── triceps-group.tsx │ │ │ ├── muscles.module.css │ │ │ ├── quit-workout-dialog.tsx │ │ │ ├── stepper-header.tsx │ │ │ ├── workout-stepper-footer.tsx │ │ │ └── workout-stepper.tsx │ │ └── workout-session/ │ │ ├── actions/ │ │ │ ├── delete-workout-session.action.ts │ │ │ ├── get-workout-sessions.action.ts │ │ │ └── sync-workout-sessions.action.ts │ │ ├── hooks/ │ │ │ └── use-donation-modal.ts │ │ ├── lib/ │ │ │ └── workout-set-labels.ts │ │ ├── model/ │ │ │ ├── use-sync-workout-sessions.ts │ │ │ ├── use-workout-session.ts │ │ │ ├── use-workout-sessions.ts │ │ │ └── workout-session.store.ts │ │ ├── types/ │ │ │ └── workout-set.ts │ │ └── ui/ │ │ ├── donation-modal.tsx │ │ ├── workout-session-header.tsx │ │ ├── workout-session-heatmap.tsx │ │ ├── workout-session-list.tsx │ │ ├── workout-session-set.tsx │ │ ├── workout-session-sets.tsx │ │ ├── workout-session-timer.tsx │ │ └── workout-sessions-synchronizer.tsx │ ├── index.d.ts │ ├── shared/ │ │ ├── api/ │ │ │ ├── README.md │ │ │ ├── createHandler.ts │ │ │ ├── handlers.ts │ │ │ ├── mobile-auth.ts │ │ │ ├── mobile-cookie-utils.ts │ │ │ ├── mobile-safe-actions.ts │ │ │ └── safe-actions.ts │ │ ├── config/ │ │ │ ├── localized-metadata.ts │ │ │ └── site-config.ts │ │ ├── constants/ │ │ │ ├── cookies.ts │ │ │ ├── errors.ts │ │ │ ├── paths.ts │ │ │ ├── placeholders.ts │ │ │ ├── regexs.ts │ │ │ ├── screen.ts │ │ │ ├── social-platforms.tsx │ │ │ ├── statistics.ts │ │ │ ├── success.ts │ │ │ └── workout-set-types.ts │ │ ├── hooks/ │ │ │ ├── use-clipboard.ts │ │ │ ├── use-premium-plans.ts │ │ │ ├── useBoolean.ts │ │ │ ├── useIsMobile.ts │ │ │ └── useScrollToTop.ts │ │ ├── lib/ │ │ │ ├── access-control.ts │ │ │ ├── analytics/ │ │ │ │ ├── client.tsx │ │ │ │ ├── events.ts │ │ │ │ └── server.ts │ │ │ ├── attribute-value-translation.ts │ │ │ ├── date.ts │ │ │ ├── format.ts │ │ │ ├── guards.ts │ │ │ ├── i18n-mapper.ts │ │ │ ├── locale-slug.ts │ │ │ ├── location/ │ │ │ │ ├── eu-countries.ts │ │ │ │ └── location.ts │ │ │ ├── logger.ts │ │ │ ├── mail/ │ │ │ │ └── sendEmail.ts │ │ │ ├── mdx/ │ │ │ │ └── load-mdx.ts │ │ │ ├── network/ │ │ │ │ └── use-network-status.ts │ │ │ ├── premium/ │ │ │ │ ├── premium.manager.ts │ │ │ │ ├── premium.service.ts │ │ │ │ ├── providers/ │ │ │ │ │ ├── base-provider.ts │ │ │ │ │ └── stripe-provider.ts │ │ │ │ ├── use-pending-checkout.ts │ │ │ │ ├── use-premium-redirect.ts │ │ │ │ └── use-premium.ts │ │ │ ├── prisma.ts │ │ │ ├── revenuecat/ │ │ │ │ ├── index.ts │ │ │ │ ├── revenuecat.api.ts │ │ │ │ ├── revenuecat.config.ts │ │ │ │ └── revenuecat.mapping.ts │ │ │ ├── server-url.ts │ │ │ ├── slug.ts │ │ │ ├── structured-data.ts │ │ │ ├── utils.ts │ │ │ ├── version.ts │ │ │ ├── web-share.ts │ │ │ ├── weight-conversion.ts │ │ │ ├── workout-session/ │ │ │ │ ├── equipments.ts │ │ │ │ ├── types/ │ │ │ │ │ └── workout-session.ts │ │ │ │ ├── use-workout-session.service.ts │ │ │ │ ├── workout-session.api.ts │ │ │ │ └── workout-session.local.ts │ │ │ └── youtube.ts │ │ ├── schemas/ │ │ │ └── url.ts │ │ ├── styles/ │ │ │ ├── additional-styles/ │ │ │ │ ├── highlights.css │ │ │ │ └── utility-patterns.css │ │ │ └── globals.css │ │ └── types/ │ │ ├── i18n.types.ts │ │ ├── next.ts │ │ ├── premium.types.ts │ │ ├── statistics.types.ts │ │ └── storage.ts │ └── widgets/ │ ├── 404.tsx │ └── language-selector/ │ └── language-selector.tsx ├── tailwind.config.ts ├── tsconfig.json └── workout-cool.code-workspace ================================================ FILE CONTENTS ================================================ ================================================ FILE: .cursorrules ================================================ ## 🧑‍💻 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/) ================================================ FILE: .github/FUNDING.yml ================================================ github: snouzy ko_fi: workoutcool # buy_me_a_coffee: workout_cool ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- 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. ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ 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. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- 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. ================================================ FILE: .github/pull_request_template.md ================================================ ## 📝 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 ================================================ FILE: .github/workflows/ci.yml ================================================ 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 ================================================ FILE: .github/workflows/notify-discord-issues.yml ================================================ 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 }}" ================================================ FILE: .github/workflows/notify-discord-pr.yml ================================================ 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 }}" ================================================ FILE: .github/workflows/notify-discord.yml ================================================ 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 ================================================ FILE: .github/workflows/publish-ghcr-image.yml ================================================ 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 ================================================ FILE: .gitignore ================================================ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies /node_modules /.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 # vercel .vercel # typescript *.tsbuildinfo next-env.d.ts # editors .idea .zed scripts/private/ scripts/personal/ product-development/ .claude/ .cache/ ================================================ FILE: .npmrc ================================================ public-hoist-pattern[]=*import-in-the-middle* public-hoist-pattern[]=*require-in-the-middle* ================================================ FILE: .prettierrc ================================================ { "plugins": ["prettier-plugin-sort-json"], "printWidth": 140, "proseWrap": "always", "singleQuote": false } ================================================ FILE: .vscode/settings.json ================================================ { "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit" }, "typescript.tsdk": "node_modules/typescript/lib" } ================================================ FILE: AGENTS.md ================================================ # 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) ================================================ FILE: CLAUDE.md ================================================ # 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 ================================================ FILE: CONTRIBUTING.md ================================================ ### ✅ 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. ================================================ FILE: Dockerfile ================================================ 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"] ================================================ FILE: LICENSE ================================================ 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. ================================================ FILE: Makefile ================================================ .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 ================================================ FILE: README.md ================================================
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 ```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 ================================================ FILE: app/[locale]/(admin)/admin/[...catchAll]/not-found.tsx ================================================ import { Page404 } from "@/widgets/404"; export default function NotFoundPage() { return ; } ================================================ FILE: app/[locale]/(admin)/admin/[...catchAll]/page.tsx ================================================ import { Page404 } from "@/widgets/404"; export default function AdminCatchAll() { return ; } ================================================ FILE: app/[locale]/(admin)/admin/dashboard/page.tsx ================================================ 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

}>
); } ================================================ FILE: app/[locale]/(admin)/admin/layout.tsx ================================================ import { ReactElement } from "react"; import { redirect } from "next/navigation"; import { UserRole } from "@prisma/client"; 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: ReactElement; } 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}
); } ================================================ FILE: app/[locale]/(admin)/admin/not-found.tsx ================================================ import { Page404 } from "@/widgets/404"; export default function NotFoundPage() { return ; } ================================================ FILE: app/[locale]/(admin)/admin/programs/[id]/edit/page.tsx ================================================ 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 ; } ================================================ FILE: app/[locale]/(admin)/admin/programs/page.tsx ================================================ 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...
}>
); } ================================================ FILE: app/[locale]/(admin)/admin/settings/page.tsx ================================================ export default function AdminSettings() { return (

Settings

Configuration and administration of the system.

); } ================================================ FILE: app/[locale]/(admin)/admin/users/page.tsx ================================================ 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"}

); } } ================================================ FILE: app/[locale]/(app)/(legal-and-payment)/layout.tsx ================================================ 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}
); } ================================================ FILE: app/[locale]/(app)/(legal-and-payment)/legal/privacy/page.tsx ================================================ 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}
); } ================================================ FILE: app/[locale]/(app)/(legal-and-payment)/legal/sales-terms/page.tsx ================================================ 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}
); } ================================================ FILE: app/[locale]/(app)/(legal-and-payment)/legal/terms/page.tsx ================================================ 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}
); } ================================================ FILE: app/[locale]/(app)/[slug]/layout.tsx ================================================ import { ReactElement } from "react"; interface RootLayoutProps { params: Promise<{ locale: string }>; children: ReactElement; } export default async function RootLayout({ children }: RootLayoutProps) { return (
{children}
) } ================================================ FILE: app/[locale]/(app)/about/page.tsx ================================================ 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}
); } ================================================ FILE: app/[locale]/(app)/auth/(auth-layout)/forgot-password/page.tsx ================================================ import { ForgotPasswordForm } from "@/features/auth/forgot-password/ui/forgot-password-form"; export default async function ForgotPasswordPage() { return ; } ================================================ FILE: app/[locale]/(app)/auth/(auth-layout)/layout.tsx ================================================ 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}
); } ================================================ FILE: app/[locale]/(app)/auth/(auth-layout)/reset-password/page.tsx ================================================ import { ResetPasswordForm } from "@/features/auth/reset-password/ui/reset-password-form"; export default function ResetPasswordPage() { return ; } ================================================ FILE: app/[locale]/(app)/auth/(auth-layout)/signin/page.tsx ================================================ import { CredentialsLoginForm } from "@/features/auth/signin/ui/CredentialsLoginForm"; export default async function AuthSignInPage() { return ; } ================================================ FILE: app/[locale]/(app)/auth/(auth-layout)/signup/page.tsx ================================================ 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")} {" "} .

); } ================================================ FILE: app/[locale]/(app)/auth/error/page.tsx ================================================ 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
); } ================================================ FILE: app/[locale]/(app)/auth/error.tsx ================================================ "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. ); } ================================================ FILE: app/[locale]/(app)/auth/layout.tsx ================================================ export default function AuthLayout({ children }: { children: React.ReactNode }) { return <>{children}; } ================================================ FILE: app/[locale]/(app)/auth/signout/page.tsx ================================================ export default function AuthSignOutPage() { return
AuthSignOutPage
; } ================================================ FILE: app/[locale]/(app)/auth/verify-email/layout.tsx ================================================ import { ReactElement } 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: ReactElement; } export default async function RootLayout({ children }: RootLayoutProps) { const auth = await serverRequiredUser(); if (auth.emailVerified) { redirect(`${getServerUrl()}/${paths.root}`); } return children; } ================================================ FILE: app/[locale]/(app)/auth/verify-email/page.tsx ================================================ "use client"; import { VerifyEmailPage } from "@/features/auth/verify-email/ui/verify-email-page"; export default function VerifyEmailRootPage() { return ; } ================================================ FILE: app/[locale]/(app)/auth/verify-request/page.tsx ================================================ 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!" }
); } ================================================ FILE: app/[locale]/(app)/layout.tsx ================================================ import { ReactElement } from "react"; import { Header } from "@/features/layout/Header"; import { Footer } from "@/features/layout/Footer"; import { BottomNavigation } from "@/features/layout/BottomNavigation"; interface RootLayoutProps { params: Promise<{ locale: string }>; children: ReactElement; } export default async function RootLayout({ children }: RootLayoutProps) { return (
{children}
); } ================================================ FILE: app/[locale]/(app)/leaderboard/page.tsx ================================================ 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 ( <> ); } ================================================ FILE: app/[locale]/(app)/onboarding/layout.tsx ================================================ import { LayoutParams } from "@/shared/types/next"; export default async function OnboardingLayout(props: LayoutParams<{}>) { // TODO: add onboarding logic return props.children; } ================================================ FILE: app/[locale]/(app)/onboarding/page.tsx ================================================ export default async function OnboardingPage() { return (
Onboarding
); } ================================================ FILE: app/[locale]/(app)/page.tsx ================================================ import React from "react"; import { getServerUrl } from "@/shared/lib/server-url"; import { SiteConfig } from "@/shared/config/site-config"; import { WorkoutStepper } from "@/features/workout-builder"; import type { Metadata } from "next"; 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, keywords: isEnglish ? ["workout builder", "exercise planner", "fitness routine", "personalized training", "muscle targeting", "free workout"] : [ "créateur d'entraînement", "planificateur d'exercices", "routine fitness", "entraînement personnalisé", "ciblage musculaire", "entraînement gratuit", ], openGraph: { title: `${title} | ${SiteConfig.title}`, description, images: [ { url: `${getServerUrl()}/images/default-og-image_${locale}.jpg`, width: SiteConfig.seo.ogImage.width, height: SiteConfig.seo.ogImage.height, alt: title, }, ], }, twitter: { title: `${title} | ${SiteConfig.title}`, description, images: [`${getServerUrl()}/images/default-og-image_${locale}.jpg`], }, }; } export default async function HomePage() { return (
); } ================================================ FILE: app/[locale]/(app)/premium/page.tsx ================================================ 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 */} {/* */}
); } ================================================ FILE: app/[locale]/(app)/profile/page.tsx ================================================ "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 { 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} 👋

)}
); } ================================================ FILE: app/[locale]/(app)/programs/[slug]/page.tsx ================================================ 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 ( <> ); } ================================================ FILE: app/[locale]/(app)/programs/[slug]/session/[sessionSlug]/ProgramSessionClient.tsx ================================================ "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 */}
); } ================================================ FILE: app/[locale]/(app)/programs/[slug]/session/[sessionSlug]/page.tsx ================================================ 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 ( <> ); } ================================================ FILE: app/[locale]/(app)/programs/page.tsx ================================================ 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 ( <> ); } ================================================ FILE: app/[locale]/(app)/statistics/page.tsx ================================================ import React from "react"; import { getI18n } from "locales/server"; 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 */}
); } ================================================ FILE: app/[locale]/(app)/tools/bmi-calculator/bmi-calculator.utils.ts ================================================ 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 }; } ================================================ FILE: app/[locale]/(app)/tools/bmi-calculator/page.tsx ================================================ 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 && }
{/* 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 && }
); } ================================================ FILE: app/[locale]/(app)/tools/bmi-calculator/shared/BmiCalculatorClient.tsx ================================================ "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 && (
)}
); } ================================================ FILE: app/[locale]/(app)/tools/bmi-calculator/shared/components/BmiEducationalContent.tsx ================================================ "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 */}
Classification BMI BMI Prime
{t("bmi-calculator.category_severe_thinness")} < 16 < 0.64
{t("bmi-calculator.category_moderate_thinness")} 16 - 17 0.64 - 0.68
{t("bmi-calculator.category_mild_thinness")} 17 - 18.5 0.68 - 0.74
{t("bmi-calculator.category_normal")} 18.5 - 25 0.74 - 1.0
{t("bmi-calculator.category_overweight")} 25 - 30 1.0 - 1.2
{t("bmi-calculator.category_obese_class_1")} 30 - 35 1.2 - 1.4
{t("bmi-calculator.category_obese_class_2")} 35 - 40 1.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")}

); } ================================================ FILE: app/[locale]/(app)/tools/bmi-calculator/shared/components/BmiHeightInput.tsx ================================================ "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")}
); } ================================================ FILE: app/[locale]/(app)/tools/bmi-calculator/shared/components/BmiResultsDisplay.tsx ================================================ "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")}

); } ================================================ FILE: app/[locale]/(app)/tools/bmi-calculator/shared/components/BmiUnitSelector.tsx ================================================ "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 (
); } ================================================ FILE: app/[locale]/(app)/tools/bmi-calculator/shared/components/BmiWeightInput.tsx ================================================ "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}
); } ================================================ FILE: app/[locale]/(app)/tools/bmi-calculator/shared/components/MathEquation.tsx ================================================ "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}`; } ================================================ FILE: app/[locale]/(app)/tools/calorie-calculator/CalorieCalculatorHub.tsx ================================================ "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 && ( )}

{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 && ( )} {/* 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")}
); } ================================================ FILE: app/[locale]/(app)/tools/calorie-calculator/calorie-calculator-comparison/CalorieCalculatorComparison.tsx ================================================ "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 && ( )} {/* 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")}

)}
)}
); } ================================================ FILE: app/[locale]/(app)/tools/calorie-calculator/calorie-calculator-comparison/page.tsx ================================================ import React from "react"; import Link from "next/link"; import { Metadata } from "next"; import { ChevronLeftIcon } from "lucide-react"; import { getI18n } from "locales/server"; import { getServerUrl } from "@/shared/lib/server-url"; import { env } from "@/env"; import { generateSEOMetadata, SEOScripts } from "@/components/seo/SEOHead"; import { HorizontalTopBanner } from "@/components/ads"; import { CalorieCalculatorComparison } from "./CalorieCalculatorComparison"; 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")}

); } ================================================ FILE: app/[locale]/(app)/tools/calorie-calculator/calorie-calculator.utils.ts ================================================ // 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, }; } ================================================ FILE: app/[locale]/(app)/tools/calorie-calculator/cunningham-calculator/page.tsx ================================================ 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)

); } ================================================ FILE: app/[locale]/(app)/tools/calorie-calculator/harris-benedict-calculator/page.tsx ================================================ 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)

); } ================================================ FILE: app/[locale]/(app)/tools/calorie-calculator/katch-mcardle-calculator/page.tsx ================================================ 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)

); } ================================================ FILE: app/[locale]/(app)/tools/calorie-calculator/mifflin-st-jeor-calculator/page.tsx ================================================ 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

); } ================================================ FILE: app/[locale]/(app)/tools/calorie-calculator/oxford-calculator/page.tsx ================================================ 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)

); } ================================================ FILE: app/[locale]/(app)/tools/calorie-calculator/page.tsx ================================================ 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 ( <>
); } ================================================ FILE: app/[locale]/(app)/tools/calorie-calculator/shared/CalorieCalculatorClient.tsx ================================================ "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 && }
); } ================================================ FILE: app/[locale]/(app)/tools/calorie-calculator/shared/calculator-configs.ts ================================================ 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", }, }, }; ================================================ FILE: app/[locale]/(app)/tools/calorie-calculator/shared/calorie-formulas.utils.ts ================================================ // 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, }; } ================================================ FILE: app/[locale]/(app)/tools/calorie-calculator/shared/components/ActivityLevelSelector.tsx ================================================ "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 ( ); })}
); } ================================================ FILE: app/[locale]/(app)/tools/calorie-calculator/shared/components/AgeInput.tsx ================================================ "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")}
); } ================================================ FILE: app/[locale]/(app)/tools/calorie-calculator/shared/components/BodyFatInput.tsx ================================================ "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%
%
); } ================================================ FILE: app/[locale]/(app)/tools/calorie-calculator/shared/components/FAQSection.tsx ================================================ "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}

))}
); } ================================================ FILE: app/[locale]/(app)/tools/calorie-calculator/shared/components/GenderSelector.tsx ================================================ "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 (
); } ================================================ FILE: app/[locale]/(app)/tools/calorie-calculator/shared/components/GoalSelector.tsx ================================================ "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 ( ); })}
); } ================================================ FILE: app/[locale]/(app)/tools/calorie-calculator/shared/components/HeightInput.tsx ================================================ "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")}
); } ================================================ FILE: app/[locale]/(app)/tools/calorie-calculator/shared/components/InfoButton.tsx ================================================ "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}
)}
); } ================================================ FILE: app/[locale]/(app)/tools/calorie-calculator/shared/components/InfoModal.tsx ================================================ "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}

); } ================================================ FILE: app/[locale]/(app)/tools/calorie-calculator/shared/components/ResultsDisplay.tsx ================================================ "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")} />
); } ================================================ FILE: app/[locale]/(app)/tools/calorie-calculator/shared/components/UnitSelector.tsx ================================================ "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 (
); } ================================================ FILE: app/[locale]/(app)/tools/calorie-calculator/shared/components/WeightInput.tsx ================================================ "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}
); } ================================================ FILE: app/[locale]/(app)/tools/calorie-calculator/shared/components/index.ts ================================================ // 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"; ================================================ FILE: app/[locale]/(app)/tools/calorie-calculator/shared/types/index.ts ================================================ // 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; }; } ================================================ FILE: app/[locale]/(app)/tools/calorie-calculator/styles.css ================================================ @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; } ================================================ FILE: app/[locale]/(app)/tools/heart-rate-zones/lib/utils.ts ================================================ 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, }; } ================================================ FILE: app/[locale]/(app)/tools/heart-rate-zones/page.tsx ================================================ 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 ( <>