├── public
├── _ads.txt
├── logo.png
├── favicon.ico
├── images
│ ├── trophy.png
│ ├── favicon.ico
│ ├── calculator-og.jpg
│ ├── edwardsoyfit.png
│ ├── favicon-16x16.png
│ ├── favicon-32x32.png
│ ├── coachs
│ │ ├── mathias.png
│ │ └── mathias2.jpg
│ ├── default-workout.jpg
│ ├── equipment
│ │ ├── band.png
│ │ ├── bench.png
│ │ ├── plate.png
│ │ ├── barbell.png
│ │ ├── dumbbell.png
│ │ ├── bodyweight.png
│ │ ├── kettlebell.png
│ │ └── pull-up-bar.png
│ ├── nutripure-logo.webp
│ ├── nutripure-gellules.png
│ ├── default-og-image_en.jpg
│ ├── default-og-image_es.jpg
│ ├── default-og-image_fr.jpg
│ ├── default-og-image_pt.jpg
│ ├── default-og-image_ru.jpg
│ ├── default-og-image_zh.jpg
│ ├── emojis
│ │ ├── WorkoutCoolCry.png
│ │ ├── WorkoutCoolLoL.png
│ │ ├── WorkoutCoolChief.png
│ │ ├── WorkoutCoolHappy.png
│ │ ├── WorkoutCoolHuuuu.png
│ │ ├── WorkoutCoolLove.png
│ │ ├── WorkoutCoolMeeeh.png
│ │ ├── WorkoutCoolRich.png
│ │ ├── WorkoutCoolSwag.png
│ │ ├── WorkoutCoolTong.png
│ │ ├── WorkoutCoolWooow.png
│ │ ├── WorkoutCoolBiceps.png
│ │ ├── WorkoutCoolMedical.png
│ │ ├── WorkoutCoolPolice.png
│ │ ├── WorkoutCoolShoked.png
│ │ ├── WorkoutCoolShoked2.png
│ │ ├── WorkoutCoolTeeths.png
│ │ ├── WorkoutCoolYeahOk.png
│ │ ├── WorkoutCoolhaphap.png
│ │ ├── WorkoutCoolEmbarassed.png
│ │ ├── WorkoutCoolExhausted.png
│ │ ├── WorkoutCoolKittyGirl.png
│ │ └── WorkoutCoolDisapointed.png
│ ├── nutripure-gellules-2.png
│ ├── placeholders
│ │ ├── no-image.jpg
│ │ └── coach-avatar.png
│ ├── old-nutripure-gellules.png
│ └── screenshots
│ │ └── heart-rate-zones
│ │ ├── en.jpg
│ │ ├── es.jpg
│ │ ├── fr.jpg
│ │ ├── og.jpg
│ │ ├── pt.jpg
│ │ ├── ru.jpg
│ │ └── zh-CN.jpg
├── apple-touch-icon.png
├── android-chrome-192x192.png
├── android-chrome-512x512.png
└── manifest.json
├── .github
├── FUNDING.yml
├── ISSUE_TEMPLATE
│ ├── config.yml
│ ├── feature_request.md
│ └── bug_report.md
└── pull_request_template.md
├── src
├── shared
│ ├── constants
│ │ ├── regexs.ts
│ │ ├── success.ts
│ │ ├── cookies.ts
│ │ ├── screen.ts
│ │ ├── placeholders.ts
│ │ ├── workout-set-types.ts
│ │ ├── paths.ts
│ │ ├── errors.ts
│ │ └── statistics.ts
│ ├── lib
│ │ ├── version.ts
│ │ ├── format.ts
│ │ ├── guards.ts
│ │ ├── utils.ts
│ │ ├── logger.ts
│ │ ├── revenuecat
│ │ │ └── index.ts
│ │ ├── prisma.ts
│ │ ├── location
│ │ │ ├── location.ts
│ │ │ └── eu-countries.ts
│ │ ├── mdx
│ │ │ └── load-mdx.ts
│ │ ├── network
│ │ │ └── use-network-status.ts
│ │ ├── server-url.ts
│ │ ├── workout-session
│ │ │ ├── workout-session.api.ts
│ │ │ ├── types
│ │ │ │ └── workout-session.ts
│ │ │ └── equipments.ts
│ │ ├── analytics
│ │ │ ├── events.ts
│ │ │ └── client.tsx
│ │ ├── premium
│ │ │ ├── use-premium-redirect.ts
│ │ │ └── providers
│ │ │ │ └── base-provider.ts
│ │ ├── mail
│ │ │ └── sendEmail.ts
│ │ └── i18n-mapper.ts
│ ├── schemas
│ │ └── url.ts
│ ├── types
│ │ └── storage.ts
│ ├── styles
│ │ └── additional-styles
│ │ │ └── utility-patterns.css
│ ├── hooks
│ │ ├── useBoolean.ts
│ │ ├── useIsMobile.ts
│ │ └── use-premium-plans.ts
│ ├── api
│ │ └── safe-actions.ts
│ └── config
│ │ └── site-config.ts
├── features
│ ├── auth
│ │ ├── verify-email
│ │ │ └── constants.ts
│ │ ├── signin
│ │ │ ├── schema
│ │ │ │ └── signin.schema.ts
│ │ │ └── model
│ │ │ │ └── useSignIn.ts
│ │ ├── forgot-password
│ │ │ ├── forgot-password.schema.ts
│ │ │ └── model
│ │ │ │ └── useForgotPassword.tsx
│ │ ├── signup
│ │ │ └── schema
│ │ │ │ └── signup.schema.ts
│ │ ├── ui
│ │ │ ├── SignUpButton.tsx
│ │ │ ├── SignInButton.tsx
│ │ │ ├── AuthButtonServer.tsx
│ │ │ └── LoggedInButton.tsx
│ │ ├── model
│ │ │ └── useLogout.ts
│ │ ├── reset-password
│ │ │ └── schema
│ │ │ │ └── reset-password.schema.ts
│ │ └── lib
│ │ │ └── auth-client.ts
│ ├── consent-banner
│ │ ├── schema
│ │ │ └── tracking-consent.schema.ts
│ │ └── model
│ │ │ └── tracking-consent.action.ts
│ ├── release-notes
│ │ ├── ui
│ │ │ └── index.ts
│ │ ├── hooks
│ │ │ └── index.ts
│ │ └── index.ts
│ ├── update-password
│ │ ├── lib
│ │ │ ├── validate-password.ts
│ │ │ └── hash.ts
│ │ └── model
│ │ │ └── update-password.schema.ts
│ ├── email
│ │ ├── email.schema.ts
│ │ └── email.action.ts
│ ├── workout-session
│ │ ├── model
│ │ │ ├── use-workout-session.ts
│ │ │ └── use-workout-sessions.ts
│ │ ├── hooks
│ │ │ └── use-donation-modal.ts
│ │ ├── lib
│ │ │ └── workout-set-labels.ts
│ │ ├── ui
│ │ │ └── workout-sessions-synchronizer.tsx
│ │ ├── types
│ │ │ └── workout-set.ts
│ │ └── actions
│ │ │ └── delete-workout-session.action.ts
│ ├── leaderboard
│ │ ├── models
│ │ │ └── types.ts
│ │ ├── ui
│ │ │ └── leaderboard-skeleton.tsx
│ │ ├── hooks
│ │ │ ├── use-top-workout-users.ts
│ │ │ └── use-user-position.ts
│ │ └── lib
│ │ │ └── utils.ts
│ ├── contact
│ │ └── support
│ │ │ ├── contact-support.schema.ts
│ │ │ └── contact-support.action.ts
│ ├── ads
│ │ └── hooks
│ │ │ └── useUserSubscription.ts
│ ├── contact-feedback
│ │ ├── model
│ │ │ ├── contact-feedback.schema.ts
│ │ │ └── contact-feedback.action.ts
│ │ └── ui
│ │ │ └── ReviewInput.tsx
│ ├── theme
│ │ ├── ThemeProviders.tsx
│ │ ├── ui
│ │ │ └── ThemeSynchronizer.tsx
│ │ └── ThemeToggle.tsx
│ ├── statistics
│ │ ├── components
│ │ │ └── index.ts
│ │ ├── types
│ │ │ └── index.ts
│ │ └── hooks
│ │ │ └── use-chart-theme.ts
│ ├── layout
│ │ ├── model
│ │ │ └── use-sidebar.store.tsx
│ │ ├── page-heading.tsx
│ │ └── useSidebarToggle.ts
│ ├── workout-builder
│ │ ├── index.ts
│ │ ├── hooks
│ │ │ ├── use-workout-session.ts
│ │ │ └── use-exercises.ts
│ │ ├── schema
│ │ │ └── get-exercises.schema.ts
│ │ ├── model
│ │ │ └── favorite-exercises-synchronizer.tsx
│ │ ├── ui
│ │ │ ├── muscles.module.css
│ │ │ └── favorite-button.tsx
│ │ ├── actions
│ │ │ ├── pick-exercise.action.ts
│ │ │ ├── get-favorite-exercises.action.ts
│ │ │ └── sync-favorite-exercises.action.ts
│ │ └── types
│ │ │ └── index.ts
│ ├── premium
│ │ └── ui
│ │ │ └── index.ts
│ ├── admin
│ │ └── programs
│ │ │ ├── ui
│ │ │ └── create-program-button.tsx
│ │ │ └── actions
│ │ │ └── delete-program.action.ts
│ ├── form
│ │ └── SubmitButton.tsx
│ └── page
│ │ └── layout.tsx
├── index.d.ts
├── components
│ ├── ui
│ │ ├── aspect-ratio.tsx
│ │ ├── loader.tsx
│ │ ├── theme-provider.tsx
│ │ ├── collapsible.tsx
│ │ ├── title-with-dot.tsx
│ │ ├── skeleton.tsx
│ │ ├── divider.tsx
│ │ ├── label.tsx
│ │ ├── separator.tsx
│ │ ├── star-button.tsx
│ │ ├── textarea.tsx
│ │ ├── phone-frame-preview.tsx
│ │ ├── toaster.tsx
│ │ ├── sonner.tsx
│ │ ├── Bento.tsx
│ │ ├── local-alert.tsx
│ │ ├── ToastSonner.tsx
│ │ ├── link.tsx
│ │ └── hover-card.tsx
│ ├── version.tsx
│ ├── ads
│ │ ├── HorizontalTopBanner.tsx
│ │ ├── HorizontalBottomBanner.tsx
│ │ ├── index.ts
│ │ ├── AdPlaceholder.tsx
│ │ ├── VerticalLeftBanner.tsx
│ │ ├── VerticalRightBanner.tsx
│ │ ├── AdWrapper.tsx
│ │ ├── InArticle.tsx
│ │ ├── EzoicAd.tsx
│ │ └── VerticalAdBanner.tsx
│ ├── svg
│ │ ├── Youtube.tsx
│ │ ├── Calendly.tsx
│ │ ├── IconCheckboxCheck.tsx
│ │ ├── UnderlineSvg.tsx
│ │ ├── DotPattern.tsx
│ │ ├── VerifiedBadge.tsx
│ │ ├── LogoSvg.tsx
│ │ ├── GoogleSvg.tsx
│ │ └── DiscordSvg.tsx
│ ├── pwa
│ │ └── ServiceWorkerRegistration.tsx
│ ├── utils
│ │ └── TailwindIndicator.tsx
│ └── premium
│ │ └── RemoveAdsText.tsx
└── entities
│ ├── user
│ ├── schemas
│ │ ├── get-user.schema.ts
│ │ └── update-user.schema.ts
│ ├── model
│ │ ├── useCurrentUser.ts
│ │ ├── useCurrentSession.ts
│ │ ├── get-server-session-user.ts
│ │ ├── update-user-locale.ts
│ │ ├── use-auto-locale.ts
│ │ └── update-user.action.ts
│ ├── types
│ │ └── session-user.ts
│ └── lib
│ │ └── display-name.ts
│ ├── program-session
│ └── types
│ │ └── program-session.types.ts
│ └── exercise
│ ├── shared
│ └── muscles.tsx
│ └── types
│ └── exercise.types.ts
├── .npmrc
├── app
├── [locale]
│ ├── (app)
│ │ ├── auth
│ │ │ ├── signout
│ │ │ │ └── page.tsx
│ │ │ ├── layout.tsx
│ │ │ ├── (auth-layout)
│ │ │ │ ├── signin
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── reset-password
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── forgot-password
│ │ │ │ │ └── page.tsx
│ │ │ │ └── signup
│ │ │ │ │ └── page.tsx
│ │ │ ├── verify-email
│ │ │ │ ├── page.tsx
│ │ │ │ └── layout.tsx
│ │ │ ├── error.tsx
│ │ │ ├── error
│ │ │ │ └── page.tsx
│ │ │ └── verify-request
│ │ │ │ └── page.tsx
│ │ ├── onboarding
│ │ │ ├── layout.tsx
│ │ │ └── page.tsx
│ │ ├── [slug]
│ │ │ └── layout.tsx
│ │ ├── tools
│ │ │ ├── calorie-calculator
│ │ │ │ └── shared
│ │ │ │ │ ├── types
│ │ │ │ │ └── index.ts
│ │ │ │ │ └── components
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── InfoButton.tsx
│ │ │ └── heart-rate-zones
│ │ │ │ ├── ui
│ │ │ │ └── components
│ │ │ │ │ └── ScrollToTopButton.tsx
│ │ │ │ └── seo
│ │ │ │ └── page-content.ts
│ │ ├── about
│ │ │ └── page.tsx
│ │ ├── (legal-and-payment)
│ │ │ ├── layout.tsx
│ │ │ └── legal
│ │ │ │ ├── privacy
│ │ │ │ └── page.tsx
│ │ │ │ ├── terms
│ │ │ │ └── page.tsx
│ │ │ │ └── sales-terms
│ │ │ │ └── page.tsx
│ │ ├── layout.tsx
│ │ ├── programs
│ │ │ └── page.tsx
│ │ ├── leaderboard
│ │ │ └── page.tsx
│ │ └── premium
│ │ │ └── page.tsx
│ ├── not-found.tsx
│ ├── (admin)
│ │ └── admin
│ │ │ ├── not-found.tsx
│ │ │ ├── [...catchAll]
│ │ │ ├── page.tsx
│ │ │ └── not-found.tsx
│ │ │ ├── settings
│ │ │ └── page.tsx
│ │ │ ├── programs
│ │ │ ├── [id]
│ │ │ │ └── edit
│ │ │ │ │ └── page.tsx
│ │ │ └── page.tsx
│ │ │ └── layout.tsx
│ └── @modal
│ │ └── (.)auth
│ │ └── login
│ │ └── page.tsx
├── api
│ ├── auth
│ │ └── [...all]
│ │ │ └── route.ts
│ ├── programs
│ │ ├── route.ts
│ │ └── [slug]
│ │ │ ├── route.ts
│ │ │ ├── progress
│ │ │ └── route.ts
│ │ │ └── sessions
│ │ │ └── [sessionSlug]
│ │ │ └── route.ts
│ ├── webhooks
│ │ └── stripe
│ │ │ └── route.ts
│ ├── premium
│ │ ├── checkout
│ │ │ └── route.ts
│ │ └── billing-portal
│ │ │ └── route.ts
│ └── revenuecat
│ │ └── link-user
│ │ └── route.ts
└── robots.txt
├── locales
├── types.ts
└── server.ts
├── .prettierrc
├── .vscode
└── settings.json
├── prisma
└── migrations_backup
│ ├── 20250626205904_remove_payment_table_keep_ispremium
│ └── migration.sql
│ ├── migration_lock.toml
│ ├── 20250708214116_add_rating_to_wkt_sessions
│ └── migration.sql
│ ├── 20250414232816_add_first_name_and_last_name
│ └── migration.sql
│ ├── 20250615160343_add_muscle_to_a_workout_session
│ └── migration.sql
│ ├── 20250625195907_add_program_visibility
│ └── migration.sql
│ ├── 20250626134345_remove_emoji_on_program
│ └── migration.sql
│ ├── 20240726_simplify_subscription_model
│ └── migration.sql
│ ├── 20250623143952_remove_webhook_events
│ └── migration.sql
│ ├── 20250613095031_add_multi_column_support
│ └── migration.sql
│ ├── 20250626204136_remove_payment_table
│ └── migration.sql
│ ├── 20250505114841_add_user_role
│ └── migration.sql
│ ├── 20250626205121_remove_legacy_premium_fields
│ └── migration.sql
│ ├── 20250414170807_add_feedbacks
│ └── migration.sql
│ ├── 20250614153656_remove_value_int_value_sec_unit_from_workoutset
│ └── migration.sql
│ ├── 20250505191954_admin_and_user_lowercase
│ └── migration.sql
│ ├── 20250615170916_add_cascade_delete_workout_sessions
│ └── migration.sql
│ ├── 20250414174246_rename_feedbacks
│ └── migration.sql
│ ├── 20250707114920_add_user_favorite_exercises
│ └── migration.sql
│ ├── 20250117000000_add_statistics_indexes
│ └── migration.sql
│ └── 20250623144324_add_webhook_events
│ └── migration.sql
├── postcss.config.mjs
├── workout-cool.code-workspace
├── nextauth.d.ts
├── components.json
├── next.config.ts
├── docker-compose.yml
├── scripts
└── setup.sh
├── .gitignore
├── tsconfig.json
├── Dockerfile
├── Makefile
├── LICENSE
├── content
└── about
│ └── zh-CN.mdx
├── CONTRIBUTING.md
└── emails
└── DeleteAccountEmail.tsx
/public/_ads.txt:
--------------------------------------------------------------------------------
1 | google.com, pub-3437447245301146, DIRECT, f08c47fec0942fa0
--------------------------------------------------------------------------------
/public/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Snouzy/workout-cool/HEAD/public/logo.png
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Snouzy/workout-cool/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: snouzy
2 | ko_fi: workoutcool
3 | # buy_me_a_coffee: workout_cool
4 |
--------------------------------------------------------------------------------
/src/shared/constants/regexs.ts:
--------------------------------------------------------------------------------
1 | export const PASSWORD_REGEX = /^(?=.*[A-Za-z])(?=.*\d).{8,}$/;
2 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | public-hoist-pattern[]=*import-in-the-middle*
2 | public-hoist-pattern[]=*require-in-the-middle*
--------------------------------------------------------------------------------
/public/images/trophy.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Snouzy/workout-cool/HEAD/public/images/trophy.png
--------------------------------------------------------------------------------
/public/images/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Snouzy/workout-cool/HEAD/public/images/favicon.ico
--------------------------------------------------------------------------------
/public/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Snouzy/workout-cool/HEAD/public/apple-touch-icon.png
--------------------------------------------------------------------------------
/src/shared/lib/version.ts:
--------------------------------------------------------------------------------
1 | import pkg from "../../../package.json";
2 |
3 | export const appVersion = pkg.version;
--------------------------------------------------------------------------------
/public/images/calculator-og.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Snouzy/workout-cool/HEAD/public/images/calculator-og.jpg
--------------------------------------------------------------------------------
/public/images/edwardsoyfit.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Snouzy/workout-cool/HEAD/public/images/edwardsoyfit.png
--------------------------------------------------------------------------------
/public/images/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Snouzy/workout-cool/HEAD/public/images/favicon-16x16.png
--------------------------------------------------------------------------------
/public/images/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Snouzy/workout-cool/HEAD/public/images/favicon-32x32.png
--------------------------------------------------------------------------------
/src/shared/constants/success.ts:
--------------------------------------------------------------------------------
1 | export const SUCCESS_MESSAGES = {
2 | DELETE_SUCCESS: "DELETE_SUCCESS",
3 | };
4 |
--------------------------------------------------------------------------------
/public/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Snouzy/workout-cool/HEAD/public/android-chrome-192x192.png
--------------------------------------------------------------------------------
/public/android-chrome-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Snouzy/workout-cool/HEAD/public/android-chrome-512x512.png
--------------------------------------------------------------------------------
/public/images/coachs/mathias.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Snouzy/workout-cool/HEAD/public/images/coachs/mathias.png
--------------------------------------------------------------------------------
/public/images/coachs/mathias2.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Snouzy/workout-cool/HEAD/public/images/coachs/mathias2.jpg
--------------------------------------------------------------------------------
/public/images/default-workout.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Snouzy/workout-cool/HEAD/public/images/default-workout.jpg
--------------------------------------------------------------------------------
/public/images/equipment/band.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Snouzy/workout-cool/HEAD/public/images/equipment/band.png
--------------------------------------------------------------------------------
/public/images/equipment/bench.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Snouzy/workout-cool/HEAD/public/images/equipment/bench.png
--------------------------------------------------------------------------------
/public/images/equipment/plate.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Snouzy/workout-cool/HEAD/public/images/equipment/plate.png
--------------------------------------------------------------------------------
/public/images/nutripure-logo.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Snouzy/workout-cool/HEAD/public/images/nutripure-logo.webp
--------------------------------------------------------------------------------
/src/shared/constants/cookies.ts:
--------------------------------------------------------------------------------
1 | export const Cookies = {
2 | TrackingConsent: "tracking-consent",
3 | } as const;
4 |
--------------------------------------------------------------------------------
/public/images/equipment/barbell.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Snouzy/workout-cool/HEAD/public/images/equipment/barbell.png
--------------------------------------------------------------------------------
/public/images/equipment/dumbbell.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Snouzy/workout-cool/HEAD/public/images/equipment/dumbbell.png
--------------------------------------------------------------------------------
/public/images/nutripure-gellules.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Snouzy/workout-cool/HEAD/public/images/nutripure-gellules.png
--------------------------------------------------------------------------------
/src/features/auth/verify-email/constants.ts:
--------------------------------------------------------------------------------
1 | export const COUNTDOWN_TIME = process.env.NODE_ENV === "development" ? 3 : 60;
2 |
--------------------------------------------------------------------------------
/public/images/default-og-image_en.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Snouzy/workout-cool/HEAD/public/images/default-og-image_en.jpg
--------------------------------------------------------------------------------
/public/images/default-og-image_es.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Snouzy/workout-cool/HEAD/public/images/default-og-image_es.jpg
--------------------------------------------------------------------------------
/public/images/default-og-image_fr.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Snouzy/workout-cool/HEAD/public/images/default-og-image_fr.jpg
--------------------------------------------------------------------------------
/public/images/default-og-image_pt.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Snouzy/workout-cool/HEAD/public/images/default-og-image_pt.jpg
--------------------------------------------------------------------------------
/public/images/default-og-image_ru.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Snouzy/workout-cool/HEAD/public/images/default-og-image_ru.jpg
--------------------------------------------------------------------------------
/public/images/default-og-image_zh.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Snouzy/workout-cool/HEAD/public/images/default-og-image_zh.jpg
--------------------------------------------------------------------------------
/public/images/emojis/WorkoutCoolCry.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Snouzy/workout-cool/HEAD/public/images/emojis/WorkoutCoolCry.png
--------------------------------------------------------------------------------
/public/images/emojis/WorkoutCoolLoL.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Snouzy/workout-cool/HEAD/public/images/emojis/WorkoutCoolLoL.png
--------------------------------------------------------------------------------
/public/images/equipment/bodyweight.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Snouzy/workout-cool/HEAD/public/images/equipment/bodyweight.png
--------------------------------------------------------------------------------
/public/images/equipment/kettlebell.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Snouzy/workout-cool/HEAD/public/images/equipment/kettlebell.png
--------------------------------------------------------------------------------
/public/images/equipment/pull-up-bar.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Snouzy/workout-cool/HEAD/public/images/equipment/pull-up-bar.png
--------------------------------------------------------------------------------
/public/images/nutripure-gellules-2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Snouzy/workout-cool/HEAD/public/images/nutripure-gellules-2.png
--------------------------------------------------------------------------------
/public/images/placeholders/no-image.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Snouzy/workout-cool/HEAD/public/images/placeholders/no-image.jpg
--------------------------------------------------------------------------------
/src/index.d.ts:
--------------------------------------------------------------------------------
1 | // add opera to the user agent
2 | declare global {
3 | interface Navigator {
4 | opera: any;
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/app/[locale]/(app)/auth/signout/page.tsx:
--------------------------------------------------------------------------------
1 | export default function AuthSignOutPage() {
2 | return
AuthSignOutPage
;
3 | }
4 |
--------------------------------------------------------------------------------
/public/images/emojis/WorkoutCoolChief.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Snouzy/workout-cool/HEAD/public/images/emojis/WorkoutCoolChief.png
--------------------------------------------------------------------------------
/public/images/emojis/WorkoutCoolHappy.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Snouzy/workout-cool/HEAD/public/images/emojis/WorkoutCoolHappy.png
--------------------------------------------------------------------------------
/public/images/emojis/WorkoutCoolHuuuu.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Snouzy/workout-cool/HEAD/public/images/emojis/WorkoutCoolHuuuu.png
--------------------------------------------------------------------------------
/public/images/emojis/WorkoutCoolLove.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Snouzy/workout-cool/HEAD/public/images/emojis/WorkoutCoolLove.png
--------------------------------------------------------------------------------
/public/images/emojis/WorkoutCoolMeeeh.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Snouzy/workout-cool/HEAD/public/images/emojis/WorkoutCoolMeeeh.png
--------------------------------------------------------------------------------
/public/images/emojis/WorkoutCoolRich.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Snouzy/workout-cool/HEAD/public/images/emojis/WorkoutCoolRich.png
--------------------------------------------------------------------------------
/public/images/emojis/WorkoutCoolSwag.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Snouzy/workout-cool/HEAD/public/images/emojis/WorkoutCoolSwag.png
--------------------------------------------------------------------------------
/public/images/emojis/WorkoutCoolTong.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Snouzy/workout-cool/HEAD/public/images/emojis/WorkoutCoolTong.png
--------------------------------------------------------------------------------
/public/images/emojis/WorkoutCoolWooow.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Snouzy/workout-cool/HEAD/public/images/emojis/WorkoutCoolWooow.png
--------------------------------------------------------------------------------
/public/images/old-nutripure-gellules.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Snouzy/workout-cool/HEAD/public/images/old-nutripure-gellules.png
--------------------------------------------------------------------------------
/public/images/emojis/WorkoutCoolBiceps.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Snouzy/workout-cool/HEAD/public/images/emojis/WorkoutCoolBiceps.png
--------------------------------------------------------------------------------
/public/images/emojis/WorkoutCoolMedical.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Snouzy/workout-cool/HEAD/public/images/emojis/WorkoutCoolMedical.png
--------------------------------------------------------------------------------
/public/images/emojis/WorkoutCoolPolice.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Snouzy/workout-cool/HEAD/public/images/emojis/WorkoutCoolPolice.png
--------------------------------------------------------------------------------
/public/images/emojis/WorkoutCoolShoked.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Snouzy/workout-cool/HEAD/public/images/emojis/WorkoutCoolShoked.png
--------------------------------------------------------------------------------
/public/images/emojis/WorkoutCoolShoked2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Snouzy/workout-cool/HEAD/public/images/emojis/WorkoutCoolShoked2.png
--------------------------------------------------------------------------------
/public/images/emojis/WorkoutCoolTeeths.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Snouzy/workout-cool/HEAD/public/images/emojis/WorkoutCoolTeeths.png
--------------------------------------------------------------------------------
/public/images/emojis/WorkoutCoolYeahOk.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Snouzy/workout-cool/HEAD/public/images/emojis/WorkoutCoolYeahOk.png
--------------------------------------------------------------------------------
/public/images/emojis/WorkoutCoolhaphap.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Snouzy/workout-cool/HEAD/public/images/emojis/WorkoutCoolhaphap.png
--------------------------------------------------------------------------------
/public/images/placeholders/coach-avatar.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Snouzy/workout-cool/HEAD/public/images/placeholders/coach-avatar.png
--------------------------------------------------------------------------------
/locales/types.ts:
--------------------------------------------------------------------------------
1 | export const locales = ["en", "fr", "es", "zh-CN", "ru", "pt"] as const;
2 | export type Locale = (typeof locales)[number];
3 |
--------------------------------------------------------------------------------
/public/images/emojis/WorkoutCoolEmbarassed.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Snouzy/workout-cool/HEAD/public/images/emojis/WorkoutCoolEmbarassed.png
--------------------------------------------------------------------------------
/public/images/emojis/WorkoutCoolExhausted.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Snouzy/workout-cool/HEAD/public/images/emojis/WorkoutCoolExhausted.png
--------------------------------------------------------------------------------
/public/images/emojis/WorkoutCoolKittyGirl.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Snouzy/workout-cool/HEAD/public/images/emojis/WorkoutCoolKittyGirl.png
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "plugins": ["prettier-plugin-sort-json"],
3 | "printWidth": 140,
4 | "proseWrap": "always",
5 | "singleQuote": false
6 | }
7 |
--------------------------------------------------------------------------------
/app/[locale]/not-found.tsx:
--------------------------------------------------------------------------------
1 | import { Page404 } from "@/widgets/404";
2 |
3 | export default function NotFoundPage() {
4 | return ;
5 | }
6 |
--------------------------------------------------------------------------------
/public/images/emojis/WorkoutCoolDisapointed.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Snouzy/workout-cool/HEAD/public/images/emojis/WorkoutCoolDisapointed.png
--------------------------------------------------------------------------------
/public/images/screenshots/heart-rate-zones/en.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Snouzy/workout-cool/HEAD/public/images/screenshots/heart-rate-zones/en.jpg
--------------------------------------------------------------------------------
/public/images/screenshots/heart-rate-zones/es.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Snouzy/workout-cool/HEAD/public/images/screenshots/heart-rate-zones/es.jpg
--------------------------------------------------------------------------------
/public/images/screenshots/heart-rate-zones/fr.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Snouzy/workout-cool/HEAD/public/images/screenshots/heart-rate-zones/fr.jpg
--------------------------------------------------------------------------------
/public/images/screenshots/heart-rate-zones/og.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Snouzy/workout-cool/HEAD/public/images/screenshots/heart-rate-zones/og.jpg
--------------------------------------------------------------------------------
/public/images/screenshots/heart-rate-zones/pt.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Snouzy/workout-cool/HEAD/public/images/screenshots/heart-rate-zones/pt.jpg
--------------------------------------------------------------------------------
/public/images/screenshots/heart-rate-zones/ru.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Snouzy/workout-cool/HEAD/public/images/screenshots/heart-rate-zones/ru.jpg
--------------------------------------------------------------------------------
/src/features/consent-banner/schema/tracking-consent.schema.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 |
3 | export const trackingConsentSchema = z.boolean();
4 |
--------------------------------------------------------------------------------
/src/shared/lib/format.ts:
--------------------------------------------------------------------------------
1 | export function nullToUndefined(value: T | null): T | undefined {
2 | return value === null ? undefined : value;
3 | }
4 |
--------------------------------------------------------------------------------
/app/[locale]/(app)/auth/layout.tsx:
--------------------------------------------------------------------------------
1 | export default function AuthLayout({ children }: { children: React.ReactNode }) {
2 | return <>{children}>;
3 | }
4 |
--------------------------------------------------------------------------------
/public/images/screenshots/heart-rate-zones/zh-CN.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Snouzy/workout-cool/HEAD/public/images/screenshots/heart-rate-zones/zh-CN.jpg
--------------------------------------------------------------------------------
/app/[locale]/(admin)/admin/not-found.tsx:
--------------------------------------------------------------------------------
1 | import { Page404 } from "@/widgets/404";
2 |
3 | export default function NotFoundPage() {
4 | return ;
5 | }
6 |
--------------------------------------------------------------------------------
/src/shared/schemas/url.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 |
3 | export const urlSchema = z.string().url("form_invalid_url");
4 | export type Url = z.infer;
5 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.codeActionsOnSave": {
3 | "source.fixAll.eslint": "explicit"
4 | },
5 | "typescript.tsdk": "node_modules/typescript/lib"
6 | }
7 |
--------------------------------------------------------------------------------
/app/[locale]/(admin)/admin/[...catchAll]/page.tsx:
--------------------------------------------------------------------------------
1 | import { Page404 } from "@/widgets/404";
2 |
3 | export default function AdminCatchAll() {
4 | return ;
5 | }
6 |
--------------------------------------------------------------------------------
/src/shared/constants/screen.ts:
--------------------------------------------------------------------------------
1 | export const Screens = {
2 | linkInBio: "link-in-bio",
3 | dashboard: "dashboard",
4 | website: "website",
5 | editor: "editor",
6 | };
7 |
--------------------------------------------------------------------------------
/app/[locale]/(admin)/admin/[...catchAll]/not-found.tsx:
--------------------------------------------------------------------------------
1 | import { Page404 } from "@/widgets/404";
2 |
3 | export default function NotFoundPage() {
4 | return ;
5 | }
6 |
--------------------------------------------------------------------------------
/prisma/migrations_backup/20250626205904_remove_payment_table_keep_ispremium/migration.sql:
--------------------------------------------------------------------------------
1 | -- AlterTable
2 | ALTER TABLE "user" ADD COLUMN "isPremium" BOOLEAN DEFAULT false;
3 |
--------------------------------------------------------------------------------
/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | const config = {
3 | plugins: {
4 | tailwindcss: {},
5 | },
6 | };
7 |
8 | export default config;
9 |
--------------------------------------------------------------------------------
/prisma/migrations_backup/migration_lock.toml:
--------------------------------------------------------------------------------
1 | # Please do not edit this file manually
2 | # It should be added in your version-control system (e.g., Git)
3 | provider = "postgresql"
4 |
--------------------------------------------------------------------------------
/src/shared/lib/guards.ts:
--------------------------------------------------------------------------------
1 | export const isObject = (value: unknown): value is Record => {
2 | return typeof value === "object" && value !== null && !Array.isArray(value);
3 | };
4 |
--------------------------------------------------------------------------------
/prisma/migrations_backup/20250708214116_add_rating_to_wkt_sessions/migration.sql:
--------------------------------------------------------------------------------
1 | -- AlterTable
2 | ALTER TABLE "workout_sessions" ADD COLUMN "rating" INTEGER,
3 | ADD COLUMN "ratingComment" TEXT;
4 |
--------------------------------------------------------------------------------
/src/features/release-notes/ui/index.ts:
--------------------------------------------------------------------------------
1 | export { ChangelogNotificationBadge } from "./changelog-notification-badge";
2 | export type { ChangelogNotificationBadgeProps } from "./changelog-notification-badge";
--------------------------------------------------------------------------------
/app/api/auth/[...all]/route.ts:
--------------------------------------------------------------------------------
1 | import { toNextJsHandler } from "better-auth/next-js";
2 |
3 | import { auth } from "@/features/auth/lib/better-auth";
4 |
5 | export const { POST, GET } = toNextJsHandler(auth);
6 |
--------------------------------------------------------------------------------
/src/shared/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { twMerge } from "tailwind-merge";
2 | import { clsx, type ClassValue } from "clsx";
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
--------------------------------------------------------------------------------
/src/components/ui/aspect-ratio.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio";
4 |
5 | const AspectRatio = AspectRatioPrimitive.Root;
6 |
7 | export { AspectRatio };
8 |
--------------------------------------------------------------------------------
/prisma/migrations_backup/20250414232816_add_first_name_and_last_name/migration.sql:
--------------------------------------------------------------------------------
1 | -- AlterTable
2 | ALTER TABLE "user" ADD COLUMN "firstName" TEXT NOT NULL DEFAULT '',
3 | ADD COLUMN "lastName" TEXT NOT NULL DEFAULT '';
4 |
--------------------------------------------------------------------------------
/src/features/release-notes/hooks/index.ts:
--------------------------------------------------------------------------------
1 | export { useChangelogNotification } from "./use-changelog-notification";
2 | export type { UseChangelogNotificationReturn, ChangelogNotificationConfig } from "./use-changelog-notification";
--------------------------------------------------------------------------------
/src/features/update-password/lib/validate-password.ts:
--------------------------------------------------------------------------------
1 | import { PASSWORD_REGEX } from "@/shared/constants/regexs";
2 |
3 | export const validatePassword = (password: string) => {
4 | return PASSWORD_REGEX.test(password);
5 | };
6 |
--------------------------------------------------------------------------------
/src/features/email/email.schema.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 |
3 | export const EmailActionSchema = z.object({
4 | email: z.string().email(),
5 | });
6 |
7 | export type EmailActionSchemaType = z.infer;
8 |
--------------------------------------------------------------------------------
/prisma/migrations_backup/20250615160343_add_muscle_to_a_workout_session/migration.sql:
--------------------------------------------------------------------------------
1 | -- AlterTable
2 | ALTER TABLE "workout_sessions" ADD COLUMN "muscles" "ExerciseAttributeValueEnum"[] DEFAULT ARRAY[]::"ExerciseAttributeValueEnum"[];
3 |
--------------------------------------------------------------------------------
/src/entities/user/schemas/get-user.schema.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 |
3 | export const getUsersSchema = z.object({
4 | page: z.number().default(1),
5 | limit: z.number().default(10),
6 | search: z.string().optional(),
7 | });
8 |
--------------------------------------------------------------------------------
/src/features/workout-session/model/use-workout-session.ts:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useWorkoutSessionStore } from "./workout-session.store";
4 |
5 | export function useWorkoutSession() {
6 | return useWorkoutSessionStore();
7 | }
8 |
--------------------------------------------------------------------------------
/app/[locale]/(app)/auth/(auth-layout)/signin/page.tsx:
--------------------------------------------------------------------------------
1 | import { CredentialsLoginForm } from "@/features/auth/signin/ui/CredentialsLoginForm";
2 |
3 | export default async function AuthSignInPage() {
4 | return ;
5 | }
6 |
--------------------------------------------------------------------------------
/workout-cool.code-workspace:
--------------------------------------------------------------------------------
1 | {
2 | "folders": [
3 | {
4 | "path": "."
5 | },
6 | {
7 | "path": "../workout-cool-mobile"
8 | }
9 | ],
10 | "settings": {
11 | "typescript.tsdk": "node_modules/typescript/lib"
12 | }
13 | }
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/config.yml:
--------------------------------------------------------------------------------
1 | blank_issues_enabled: false
2 | contact_links:
3 | - name: 💬 Workout Cool Discord
4 | url: https://discord.gg/NtrsUBuHUB
5 | about: Please use our Discord server for all questions, discussions, and support.
--------------------------------------------------------------------------------
/app/[locale]/(app)/auth/(auth-layout)/reset-password/page.tsx:
--------------------------------------------------------------------------------
1 | import { ResetPasswordForm } from "@/features/auth/reset-password/ui/reset-password-form";
2 |
3 | export default function ResetPasswordPage() {
4 | return ;
5 | }
6 |
--------------------------------------------------------------------------------
/app/[locale]/(app)/auth/verify-email/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { VerifyEmailPage } from "@/features/auth/verify-email/ui/verify-email-page";
4 |
5 | export default function VerifyEmailRootPage() {
6 | return ;
7 | }
8 |
--------------------------------------------------------------------------------
/app/[locale]/(app)/onboarding/layout.tsx:
--------------------------------------------------------------------------------
1 | import { LayoutParams } from "@/shared/types/next";
2 |
3 | export default async function OnboardingLayout(props: LayoutParams<{}>) {
4 | // TODO: add onboarding logic
5 |
6 | return props.children;
7 | }
8 |
--------------------------------------------------------------------------------
/app/[locale]/(app)/onboarding/page.tsx:
--------------------------------------------------------------------------------
1 | export default async function OnboardingPage() {
2 | return (
3 |
4 | Onboarding
5 |
6 | );
7 | }
8 |
--------------------------------------------------------------------------------
/app/[locale]/(app)/auth/(auth-layout)/forgot-password/page.tsx:
--------------------------------------------------------------------------------
1 | import { ForgotPasswordForm } from "@/features/auth/forgot-password/ui/forgot-password-form";
2 |
3 | export default async function ForgotPasswordPage() {
4 | return ;
5 | }
6 |
--------------------------------------------------------------------------------
/src/features/auth/signin/schema/signin.schema.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 |
3 | export const loginSchema = z.object({
4 | email: z.string().email(),
5 | password: z.string().min(6),
6 | });
7 |
8 | export type LoginSchema = z.infer;
9 |
--------------------------------------------------------------------------------
/src/features/update-password/model/update-password.schema.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 |
3 | export const UpdatePasswordSchema = z.object({
4 | currentPassword: z.string().min(8),
5 | newPassword: z.string().min(8),
6 | confirmPassword: z.string().min(8),
7 | });
8 |
--------------------------------------------------------------------------------
/src/shared/lib/logger.ts:
--------------------------------------------------------------------------------
1 | import { Logger } from "tslog";
2 |
3 | export const logger = new Logger({
4 | name: "AppLogger",
5 | // Don't use `env` here, because we can use the logger in the browser
6 | minLevel: process.env.NODE_ENV === "production" ? 3 : 0,
7 | });
8 |
--------------------------------------------------------------------------------
/src/features/auth/forgot-password/forgot-password.schema.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 |
3 | export const forgotPasswordSchema = z.object({
4 | email: z.string().email("Adresse e-mail invalide"),
5 | });
6 |
7 | export type ForgotPasswordSchema = z.infer;
8 |
--------------------------------------------------------------------------------
/prisma/migrations_backup/20250625195907_add_program_visibility/migration.sql:
--------------------------------------------------------------------------------
1 | -- CreateEnum
2 | CREATE TYPE "ProgramVisibility" AS ENUM ('DRAFT', 'PUBLISHED', 'ARCHIVED');
3 |
4 | -- AlterTable
5 | ALTER TABLE "programs" ADD COLUMN "visibility" "ProgramVisibility" NOT NULL DEFAULT 'DRAFT';
6 |
--------------------------------------------------------------------------------
/prisma/migrations_backup/20250626134345_remove_emoji_on_program/migration.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - You are about to drop the column `emoji` on the `programs` table. All the data in the column will be lost.
5 |
6 | */
7 | -- AlterTable
8 | ALTER TABLE "programs" DROP COLUMN "emoji";
9 |
--------------------------------------------------------------------------------
/src/features/leaderboard/models/types.ts:
--------------------------------------------------------------------------------
1 | export interface TopWorkoutUser {
2 | userId: string;
3 | userName: string;
4 | userImage: string | null;
5 | totalWorkouts: number;
6 | lastWorkoutAt: Date | null;
7 | averageWorkoutsPerWeek: number;
8 | memberSince: Date;
9 | }
10 |
--------------------------------------------------------------------------------
/src/components/version.tsx:
--------------------------------------------------------------------------------
1 | import { appVersion } from "@/shared/lib/version";
2 |
3 | export const Version = () => (
4 |
5 |
6 | v{appVersion}
7 |
8 |
9 |
10 | );
--------------------------------------------------------------------------------
/src/features/contact/support/contact-support.schema.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 |
3 | export const ContactSupportSchema = z.object({
4 | email: z.string(),
5 | subject: z.string(),
6 | message: z.string(),
7 | });
8 |
9 | export type ContactSupportSchemaType = z.infer;
10 |
--------------------------------------------------------------------------------
/src/shared/types/storage.ts:
--------------------------------------------------------------------------------
1 | export interface ImageUploader {
2 | (params: { fileBuffer: Buffer; fileName: string; mimeType: string; bucket: string }): Promise<{ fileName: string; url: string }>;
3 | }
4 |
5 | export interface ImageDeleter {
6 | (params: { fileName: string; bucket: string }): Promise;
7 | }
8 |
--------------------------------------------------------------------------------
/src/entities/user/model/useCurrentUser.ts:
--------------------------------------------------------------------------------
1 | import { useSession } from "@/features/auth/lib/auth-client";
2 |
3 | export const useCurrentUser = () => {
4 | const session = useSession();
5 | const user = session.data?.user;
6 |
7 | if (!user) {
8 | return null;
9 | }
10 |
11 | return user;
12 | };
13 |
--------------------------------------------------------------------------------
/prisma/migrations_backup/20240726_simplify_subscription_model/migration.sql:
--------------------------------------------------------------------------------
1 | -- Simplify Subscription model by removing unnecessary RevenueCat fields
2 | ALTER TABLE "subscriptions"
3 | DROP COLUMN IF EXISTS "revenueCatTransactionId",
4 | DROP COLUMN IF EXISTS "revenueCatProductId",
5 | DROP COLUMN IF EXISTS "revenueCatEntitlement";
--------------------------------------------------------------------------------
/src/shared/constants/placeholders.ts:
--------------------------------------------------------------------------------
1 | export const PLACEHOLDERS = {
2 | PROFILE_IMAGE: "/images/placeholders/coach-avatar.png",
3 | HERO_IMAGE: "/images/placeholders/hero-image.png",
4 | DESIGN_BACKGROUND_IMAGE: "/images/placeholders/design-background-image.png",
5 | DEFAULT_HERO_IMAGE: "/images/patterns/7.png",
6 | };
7 |
--------------------------------------------------------------------------------
/src/entities/user/model/useCurrentSession.ts:
--------------------------------------------------------------------------------
1 | import { useSession } from "@/features/auth/lib/auth-client";
2 |
3 | export const useCurrentSession = () => {
4 | const session = useSession();
5 | const sessionData = session.data;
6 |
7 | if (!sessionData) {
8 | return null;
9 | }
10 |
11 | return sessionData;
12 | };
13 |
--------------------------------------------------------------------------------
/prisma/migrations_backup/20250623143952_remove_webhook_events/migration.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - You are about to drop the `webhook_events` table. If the table is not empty, all the data it contains will be lost.
5 |
6 | */
7 | -- DropTable
8 | DROP TABLE "webhook_events";
9 |
10 | -- DropEnum
11 | DROP TYPE "WebhookSource";
12 |
--------------------------------------------------------------------------------
/src/components/ui/loader.tsx:
--------------------------------------------------------------------------------
1 | import { Loader2 } from "lucide-react";
2 |
3 | import { cn } from "@/shared/lib/utils";
4 |
5 | import type { LucideProps } from "lucide-react";
6 |
7 | export const Loader = ({ className, ...props }: LucideProps) => {
8 | return ;
9 | };
10 |
--------------------------------------------------------------------------------
/src/shared/constants/workout-set-types.ts:
--------------------------------------------------------------------------------
1 | export const AVAILABLE_WORKOUT_SET_TYPES = ["TIME", "WEIGHT", "REPS", "BODYWEIGHT"] as const;
2 | export const ALL_WORKOUT_SET_TYPES = ["TIME", "WEIGHT", "REPS", "BODYWEIGHT", "NA"] as const;
3 | export const MAX_WORKOUT_SET_COLUMNS = 4;
4 | export const WORKOUT_SET_UNITS_TUPLE = ["kg", "lbs"] as const;
5 |
--------------------------------------------------------------------------------
/src/features/ads/hooks/useUserSubscription.ts:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useSession } from "@/features/auth/lib/auth-client";
4 |
5 | export function useUserSubscription() {
6 | const { data: session, ...rest } = useSession();
7 | const isPremium = session?.user?.isPremium || false;
8 |
9 | return { isPremium, ...rest };
10 | }
11 |
--------------------------------------------------------------------------------
/src/features/contact-feedback/model/contact-feedback.schema.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 |
3 | export const ContactFeedbackSchema = z.object({
4 | email: z.string().optional(),
5 | review: z.string().optional(),
6 | message: z.string(),
7 | });
8 |
9 | export type ContactFeedbackSchemaType = z.infer;
10 |
--------------------------------------------------------------------------------
/src/features/theme/ThemeProviders.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import { ThemeProvider as NextThemesProvider, ThemeProviderProps } from "next-themes";
5 |
6 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
7 | return {children} ;
8 | }
9 |
--------------------------------------------------------------------------------
/src/components/ui/theme-provider.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import { ThemeProvider as NextThemesProvider } from "next-themes";
5 |
6 | export function ThemeProvider({ children, ...props }: React.ComponentProps) {
7 | return {children} ;
8 | }
9 |
--------------------------------------------------------------------------------
/src/entities/user/types/session-user.ts:
--------------------------------------------------------------------------------
1 | import { User } from "@prisma/client";
2 |
3 | import { authClient } from "@/features/auth/lib/auth-client";
4 |
5 | export interface SessionUser extends Omit {
6 | image?: string | null;
7 | }
8 |
9 | export type Session = typeof authClient.$Infer.Session;
10 |
--------------------------------------------------------------------------------
/src/features/auth/signup/schema/signup.schema.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 |
3 | export const signUpSchema = z.object({
4 | firstName: z.string(),
5 | lastName: z.string(),
6 | email: z.string().email(),
7 | password: z.string().min(8),
8 | verifyPassword: z.string().min(8),
9 | });
10 |
11 | export type SignUpSchema = z.infer;
12 |
--------------------------------------------------------------------------------
/locales/server.ts:
--------------------------------------------------------------------------------
1 | import { createI18nServer } from "next-international/server";
2 |
3 | export const { getI18n, getScopedI18n, getStaticParams } = createI18nServer({
4 | en: () => import("./en"),
5 | fr: () => import("./fr"),
6 | es: () => import("./es"),
7 | "zh-CN": () => import("./zh-CN"),
8 | ru: () => import("./ru"),
9 | pt: () => import("./pt")
10 | });
--------------------------------------------------------------------------------
/nextauth.d.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/consistent-type-definitions */
2 | import type { DefaultSession } from "next-auth";
3 |
4 | declare module "next-auth" {
5 | interface Session {
6 | user: DefaultSession["user"] & {
7 | id: string;
8 | email: string;
9 | name?: string;
10 | image?: string;
11 | };
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/src/features/update-password/lib/hash.ts:
--------------------------------------------------------------------------------
1 | import crypto from "crypto";
2 | export const hashStringWithSalt = (string: string, salt: string) => {
3 | const hash = crypto.createHash("sha256");
4 |
5 | const saltedString = salt + string;
6 |
7 | hash.update(saltedString);
8 |
9 | const hashedString = hash.digest("hex");
10 |
11 | return hashedString;
12 | };
13 |
--------------------------------------------------------------------------------
/app/[locale]/(admin)/admin/settings/page.tsx:
--------------------------------------------------------------------------------
1 | export default function AdminSettings() {
2 | return (
3 |
4 |
5 |
Settings
6 |
Configuration and administration of the system.
7 |
8 |
9 | );
10 | }
11 |
--------------------------------------------------------------------------------
/app/[locale]/(app)/[slug]/layout.tsx:
--------------------------------------------------------------------------------
1 | import { ReactElement } from "react";
2 |
3 | interface RootLayoutProps {
4 | params: Promise<{ locale: string }>;
5 | children: ReactElement;
6 | }
7 |
8 | export default async function RootLayout({ children }: RootLayoutProps) {
9 |
10 | return (
11 |
12 | {children}
13 |
14 | )
15 | }
--------------------------------------------------------------------------------
/src/features/statistics/components/index.ts:
--------------------------------------------------------------------------------
1 | export { ExerciseCharts } from "./ExerciseStatisticsTab";
2 | export { WeightProgressionChart } from "./WeightProgressionChart";
3 | export { OneRepMaxChart } from "./OneRepMaxChart";
4 | export { VolumeChart } from "./VolumeChart";
5 | export { TimeframeSelector } from "./TimeframeSelector";
6 | export { ExerciseSelection } from "./ExerciseSelection";
7 |
--------------------------------------------------------------------------------
/src/entities/user/schemas/update-user.schema.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 |
3 | export const updateUserSchema = z.object({
4 | locale: z.string().optional(),
5 | firstName: z.string().optional(),
6 | lastName: z.string().optional(),
7 | image: z.string().optional(),
8 | revalidatePath: z.string().optional(),
9 | });
10 |
11 | export type UpdateUserInput = z.infer;
12 |
--------------------------------------------------------------------------------
/src/components/ads/HorizontalTopBanner.tsx:
--------------------------------------------------------------------------------
1 | import { HorizontalAdBanner } from "./HorizontalAdBanner";
2 |
3 | interface HorizontalTopBannerProps {
4 | adSlot?: string;
5 | ezoicPlacementId?: string;
6 | }
7 |
8 | export function HorizontalTopBanner({ adSlot, ezoicPlacementId }: HorizontalTopBannerProps) {
9 | return ;
10 | }
11 |
--------------------------------------------------------------------------------
/src/features/layout/model/use-sidebar.store.tsx:
--------------------------------------------------------------------------------
1 | import { create } from "zustand";
2 |
3 | interface SidebarState {
4 | currentPageId: string | null;
5 | setCurrentPageId: (currentPageId: string | null) => void;
6 | }
7 |
8 | export const useSidebarStore = create()((set) => ({
9 | currentPageId: null,
10 | setCurrentPageId: (currentPageId) => set(() => ({ currentPageId })),
11 | }));
12 |
--------------------------------------------------------------------------------
/prisma/migrations_backup/20250613095031_add_multi_column_support/migration.sql:
--------------------------------------------------------------------------------
1 | -- AlterTable
2 | ALTER TABLE "WorkoutSet" ADD COLUMN "types" "WorkoutSetType"[] DEFAULT ARRAY[]::"WorkoutSetType"[],
3 | ADD COLUMN "units" "WorkoutSetUnit"[] DEFAULT ARRAY[]::"WorkoutSetUnit"[],
4 | ADD COLUMN "valuesInt" INTEGER[] DEFAULT ARRAY[]::INTEGER[],
5 | ADD COLUMN "valuesSec" INTEGER[] DEFAULT ARRAY[]::INTEGER[];
6 |
--------------------------------------------------------------------------------
/src/components/ui/collapsible.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
4 |
5 | const Collapsible = CollapsiblePrimitive.Root;
6 |
7 | const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
8 |
9 | const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
10 |
11 | export { Collapsible, CollapsibleTrigger, CollapsibleContent };
12 |
--------------------------------------------------------------------------------
/src/shared/constants/paths.ts:
--------------------------------------------------------------------------------
1 | export const paths = {
2 | root: "/",
3 | signUp: "auth/signup",
4 | signIn: "auth/signin",
5 | forgotPassword: "auth/forgot-password",
6 | resetPassword: "auth/reset-password",
7 | verifyEmail: "auth/verify-email",
8 | profile: "profile",
9 | privacy: "/legal/privacy",
10 | terms: "/legal/terms",
11 | programs: "/programs",
12 | leaderboard: "/leaderboard",
13 | } as const;
14 |
--------------------------------------------------------------------------------
/prisma/migrations_backup/20250626204136_remove_payment_table/migration.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - You are about to drop the `payments` table. If the table is not empty, all the data it contains will be lost.
5 |
6 | */
7 | -- DropForeignKey
8 | ALTER TABLE "payments" DROP CONSTRAINT "payments_subscriptionId_fkey";
9 |
10 | -- DropTable
11 | DROP TABLE "payments";
12 |
13 | -- DropEnum
14 | DROP TYPE "PaymentStatus";
15 |
--------------------------------------------------------------------------------
/src/features/auth/ui/SignUpButton.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 |
3 | import { getI18n } from "locales/server";
4 | import { Button } from "@/components/ui/button";
5 |
6 | export const SignUpButton = async () => {
7 | const t = await getI18n();
8 |
9 | return (
10 |
11 | {t("commons.register")}
12 |
13 | );
14 | };
15 |
--------------------------------------------------------------------------------
/src/features/workout-builder/index.ts:
--------------------------------------------------------------------------------
1 | export { WorkoutStepper } from "./ui/workout-stepper";
2 | export { useWorkoutStepper } from "./hooks/use-workout-stepper";
3 | export { useWorkoutSession } from "../workout-session/model/use-workout-session";
4 | export { EQUIPMENT_CONFIG, getEquipmentByValue, getEquipmentLabel } from "./model/equipment-config";
5 | export type { WorkoutBuilderState, WorkoutBuilderStep, EquipmentItem } from "./types";
6 |
--------------------------------------------------------------------------------
/src/shared/lib/revenuecat/index.ts:
--------------------------------------------------------------------------------
1 | // RevenueCat integration exports
2 | export { RevenueCatConfig } from "./revenuecat.config";
3 | export { RevenueCatMapping } from "./revenuecat.mapping";
4 | export { RevenueCatApiClient, revenueCatApi } from "./revenuecat.api";
5 |
6 | // Test that our files compile correctly
7 | export type { } from "./revenuecat.config";
8 | export type { RevenueCatSubscriber, RevenueCatApiError } from "./revenuecat.api";
--------------------------------------------------------------------------------
/app/[locale]/@modal/(.)auth/login/page.tsx:
--------------------------------------------------------------------------------
1 | import { CredentialsLoginForm } from "@/features/auth/signin/ui/CredentialsLoginForm";
2 | import { Dialog, DialogContent } from "@/components/ui/dialog";
3 |
4 | export default function LoginModal() {
5 | return (
6 |
7 |
8 |
9 |
10 |
11 | );
12 | }
13 |
--------------------------------------------------------------------------------
/app/[locale]/(app)/tools/calorie-calculator/shared/types/index.ts:
--------------------------------------------------------------------------------
1 | // Calorie calculator types for better type safety
2 | export type CalculatorFormula = "mifflin" | "harris" | "katch" | "cunningham" | "oxford";
3 |
4 | export interface CalculatorConfig {
5 | formula: CalculatorFormula;
6 | requiresBodyFat: boolean;
7 | name: string;
8 | description: string;
9 | buttonGradient: {
10 | from: string;
11 | to: string;
12 | };
13 | }
14 |
--------------------------------------------------------------------------------
/prisma/migrations_backup/20250505114841_add_user_role/migration.sql:
--------------------------------------------------------------------------------
1 | -- CreateEnum
2 | CREATE TYPE "UserRole" AS ENUM ('ADMIN', 'USER');
3 |
4 | -- AlterTable
5 | ALTER TABLE "session" ADD COLUMN "impersonatedBy" TEXT;
6 |
7 | -- AlterTable
8 | ALTER TABLE "user" ADD COLUMN "banExpires" TIMESTAMP(3),
9 | ADD COLUMN "banReason" TEXT,
10 | ADD COLUMN "banned" BOOLEAN DEFAULT false,
11 | ADD COLUMN "role" "UserRole" DEFAULT 'USER';
12 |
--------------------------------------------------------------------------------
/prisma/migrations_backup/20250626205121_remove_legacy_premium_fields/migration.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - You are about to drop the column `isPremium` on the `user` table. All the data in the column will be lost.
5 | - You are about to drop the column `premiumUntil` on the `user` table. All the data in the column will be lost.
6 |
7 | */
8 | -- AlterTable
9 | ALTER TABLE "user" DROP COLUMN "isPremium",
10 | DROP COLUMN "premiumUntil";
11 |
--------------------------------------------------------------------------------
/src/features/auth/ui/SignInButton.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import Link from "next/link";
3 |
4 | import { useI18n } from "locales/client";
5 | import { Button } from "@/components/ui/button";
6 |
7 | export const SignInButton = () => {
8 | const t = useI18n();
9 |
10 | return (
11 |
12 | {t("commons.login")}
13 |
14 | );
15 | };
16 |
--------------------------------------------------------------------------------
/src/components/svg/Youtube.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @next/next/no-img-element */
2 | import * as React from "react";
3 |
4 | export const YoutubeIcon: React.FC> = (props) => (
5 |
13 | );
14 |
--------------------------------------------------------------------------------
/src/shared/constants/errors.ts:
--------------------------------------------------------------------------------
1 | export const ERROR_MESSAGES = {
2 | USER_NOT_FOUND: "USER_NOT_FOUND",
3 | PAGE_NOT_FOUND: "PAGE_NOT_FOUND",
4 | UNAUTHORIZED: "UNAUTHORIZED",
5 | USERNAME_ALREADY_TAKEN: "USERNAME_ALREADY_TAKEN",
6 | INVALID_CURRENT_PASSWORD: "INVALID_CURRENT_PASSWORD",
7 | INVALID_NEW_PASSWORD: "INVALID_NEW_PASSWORD",
8 | PASSWORDS_DO_NOT_MATCH: "PASSWORDS_DO_NOT_MATCH",
9 | EMAIL_ALREADY_USED: "EMAIL_ALREADY_USED",
10 | };
11 |
--------------------------------------------------------------------------------
/src/components/ads/HorizontalBottomBanner.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { HorizontalAdBanner } from "@/components/ads/HorizontalAdBanner";
4 |
5 | interface HorizontalBottomBannerProps {
6 | adSlot?: string;
7 | ezoicPlacementId?: string;
8 | }
9 |
10 | export function HorizontalBottomBanner({ adSlot, ezoicPlacementId }: HorizontalBottomBannerProps) {
11 | return ;
12 | }
13 |
--------------------------------------------------------------------------------
/src/features/workout-session/hooks/use-donation-modal.ts:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useState } from "react";
4 |
5 | export function useDonationModal() {
6 | const [showModal, setShowModal] = useState(false);
7 |
8 | const openModal = () => {
9 | setShowModal(true);
10 | };
11 |
12 | const closeModal = () => {
13 | setShowModal(false);
14 | };
15 |
16 | return {
17 | showModal,
18 | openModal,
19 | closeModal,
20 | };
21 | }
--------------------------------------------------------------------------------
/src/features/workout-builder/hooks/use-workout-session.ts:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useWorkoutSessionStore } from "@/features/workout-session/model/workout-session.store";
4 |
5 | export function useWorkoutSession() {
6 | // Le paramètre sessionId n'est plus utilisé ici, la logique de persistance reste dans workoutSessionLocal
7 | // (si besoin, on peut l'utiliser pour charger une session spécifique dans le store)
8 | return useWorkoutSessionStore();
9 | }
10 |
--------------------------------------------------------------------------------
/src/shared/styles/additional-styles/utility-patterns.css:
--------------------------------------------------------------------------------
1 | /* Typography */
2 | * {
3 | }
4 | .h1 {
5 | @apply text-5xl font-extrabold;
6 | }
7 |
8 | .h2 {
9 | @apply text-4xl font-extrabold;
10 | }
11 |
12 | .h3 {
13 | @apply text-3xl font-extrabold;
14 | }
15 |
16 | .h4 {
17 | @apply text-2xl font-extrabold;
18 | }
19 |
20 | @media (min-width: 768px) {
21 | .h1 {
22 | @apply text-6xl;
23 | }
24 |
25 | .h2 {
26 | @apply text-5xl;
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/src/components/svg/Calendly.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @next/next/no-img-element */
2 | import * as React from "react";
3 |
4 | // On utilise l'image SVG du dossier public
5 | export const CalendlyIcon: React.FC> = (props) => (
6 |
14 | );
15 |
--------------------------------------------------------------------------------
/src/components/ui/title-with-dot.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/shared/lib/utils";
2 |
3 | export function TitleWithDot({ title, className }: { title: string; className?: string }) {
4 | return (
5 |
6 |
7 |
{title}
8 |
9 | );
10 | }
11 |
--------------------------------------------------------------------------------
/app/api/programs/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from "next/server";
2 |
3 | import { getPublicPrograms } from "@/features/programs/actions/get-public-programs.action";
4 |
5 | export async function GET() {
6 | try {
7 | const programs = await getPublicPrograms();
8 | return NextResponse.json(programs);
9 | } catch (error) {
10 | console.error("Error fetching programs:", error);
11 | return NextResponse.json({ error: "Failed to fetch programs" }, { status: 500 });
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/src/components/svg/IconCheckboxCheck.tsx:
--------------------------------------------------------------------------------
1 | const IconCheckboxCheck = ({ className }: any) => {
2 | return (
3 |
4 |
11 |
12 | );
13 | };
14 | export default IconCheckboxCheck;
15 |
--------------------------------------------------------------------------------
/src/features/workout-session/lib/workout-set-labels.ts:
--------------------------------------------------------------------------------
1 | import { TFunction } from "locales/client";
2 |
3 | import { WorkoutSetType } from "../types/workout-set";
4 |
5 | export function getWorkoutSetTypeLabels(t: TFunction): Record {
6 | return {
7 | TIME: t("workout_builder.session.time"),
8 | WEIGHT: t("workout_builder.session.weight"),
9 | REPS: t("workout_builder.session.reps"),
10 | BODYWEIGHT: t("workout_builder.session.bodyweight"),
11 | NA: "N/A",
12 | };
13 | }
14 |
--------------------------------------------------------------------------------
/src/shared/lib/prisma.ts:
--------------------------------------------------------------------------------
1 | import { PrismaClient } from "@prisma/client";
2 |
3 | const prismaClientSingleton = () => {
4 | return new PrismaClient();
5 | };
6 |
7 | type PrismaClientSingleton = ReturnType;
8 |
9 | const globalForPrisma = globalThis as unknown as {
10 | prisma: PrismaClientSingleton | undefined;
11 | };
12 |
13 | export const prisma = globalForPrisma.prisma ?? prismaClientSingleton();
14 |
15 | if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
16 |
--------------------------------------------------------------------------------
/src/components/ads/index.ts:
--------------------------------------------------------------------------------
1 | export { GoogleAdSense } from "./GoogleAdSense";
2 | export { AdWrapper } from "./AdWrapper";
3 | export { VerticalAdBanner } from "./VerticalAdBanner";
4 | export { VerticalLeftBanner } from "./VerticalLeftBanner";
5 | export { VerticalRightBanner } from "./VerticalRightBanner";
6 | export { HorizontalTopBanner } from "./HorizontalTopBanner";
7 | export { HorizontalBottomBanner } from "./HorizontalBottomBanner";
8 | export { AdBlockerForPremium } from "./AdBlockerForPremium";
9 | export { InArticle } from "./InArticle";
10 |
--------------------------------------------------------------------------------
/src/components/svg/UnderlineSvg.tsx:
--------------------------------------------------------------------------------
1 | import type { ComponentPropsWithoutRef } from "react";
2 |
3 | export const UnderlineSvg = (props: ComponentPropsWithoutRef<"svg">) => {
4 | return (
5 |
6 |
12 |
13 | );
14 | };
15 |
--------------------------------------------------------------------------------
/src/features/release-notes/index.ts:
--------------------------------------------------------------------------------
1 | export { ReleaseNotesDialog } from "./ui/release-notes-dialog";
2 | export { ChangelogNotificationBadge } from "./ui/changelog-notification-badge";
3 | export { useChangelogNotification } from "./hooks/use-changelog-notification";
4 | export type { ReleaseNotesDialogProps } from "./ui/release-notes-dialog";
5 | export type { ChangelogNotificationBadgeProps } from "./ui/changelog-notification-badge";
6 | export type { UseChangelogNotificationReturn, ChangelogNotificationConfig } from "./hooks/use-changelog-notification";
7 |
--------------------------------------------------------------------------------
/src/shared/lib/location/location.ts:
--------------------------------------------------------------------------------
1 | import { headers } from "next/headers";
2 |
3 | import { EU_COUNTRY_CODES } from "@/shared/lib/location/eu-countries";
4 |
5 | export async function getCountryCode() {
6 | const headersList = await headers();
7 |
8 | return headersList.get("x-vercel-ip-country") || "SE";
9 | }
10 |
11 | export async function isEU() {
12 | const countryCode = await getCountryCode();
13 |
14 | if (countryCode && EU_COUNTRY_CODES.includes(countryCode)) {
15 | return true;
16 | }
17 |
18 | return false;
19 | }
20 |
--------------------------------------------------------------------------------
/src/features/workout-builder/schema/get-exercises.schema.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 | import { ExerciseAttributeValueEnum } from "@prisma/client";
3 |
4 | export const getExercisesSchema = z.object({
5 | equipment: z.array(z.nativeEnum(ExerciseAttributeValueEnum)).min(1, "Au moins un équipement est requis"),
6 | muscles: z.array(z.nativeEnum(ExerciseAttributeValueEnum)).min(1, "Au moins un muscle est requis"),
7 | limit: z.number().int().min(1).max(10).default(3),
8 | });
9 |
10 | export type GetExercisesInput = z.infer;
11 |
--------------------------------------------------------------------------------
/src/features/layout/page-heading.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/shared/lib/utils";
2 | import { Card, CardContent } from "@/components/ui/card";
3 |
4 | type PageHeadingProps = {
5 | heading: string;
6 | className?: string;
7 | };
8 |
9 | const PageHeading = ({ heading, className }: PageHeadingProps) => {
10 | return (
11 |
12 | {heading}
13 |
14 | );
15 | };
16 |
17 | export default PageHeading;
18 |
--------------------------------------------------------------------------------
/src/features/premium/ui/index.ts:
--------------------------------------------------------------------------------
1 | // Premium UI Components
2 | export { PremiumUpgradeCard } from "./premium-upgrade-card";
3 | export { ConversionFlowNotification } from "./conversion-flow-notification";
4 |
5 | // New Pricing Page Components
6 | export { PricingHeroSection } from "./pricing-hero-section";
7 | export { FeatureComparisonTable } from "./feature-comparison-table";
8 | export { PricingFAQ } from "./pricing-faq";
9 | export { PricingTestimonials } from "./pricing-testimonials";
10 |
11 | // Note: Add other premium components exports here as needed
12 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "aliases": {
4 | "components": "@/components",
5 | "utils": "@/shared/lib/utils",
6 | "ui": "@/components/ui",
7 | "lib": "@/shared/lib",
8 | "hooks": "@/hooks"
9 | },
10 | "iconLibrary": "lucide",
11 | "rsc": true,
12 | "style": "new-york",
13 | "tailwind": {
14 | "config": "tailwind.config.ts",
15 | "css": "app/css/globals.css",
16 | "baseColor": "neutral",
17 | "cssVariables": true,
18 | "prefix": ""
19 | },
20 | "tsx": true
21 | }
22 |
--------------------------------------------------------------------------------
/next.config.ts:
--------------------------------------------------------------------------------
1 | import type { NextConfig } from "next";
2 |
3 | const nextConfig: NextConfig = {
4 | reactStrictMode: true,
5 | images: {
6 | unoptimized: true,
7 | domains: ["lh3.googleusercontent.com", "192.168.1.12", "localhost", "www.facebook.com", "api.dicebear.com"],
8 | remotePatterns: [
9 | {
10 | protocol: "https",
11 | hostname: "**.vercel.app",
12 | },
13 | {
14 | protocol: "https",
15 | hostname: "api.dicebear.com",
16 | },
17 | ],
18 | },
19 | };
20 |
21 | export default nextConfig;
22 |
--------------------------------------------------------------------------------
/prisma/migrations_backup/20250414170807_add_feedbacks/migration.sql:
--------------------------------------------------------------------------------
1 | -- CreateTable
2 | CREATE TABLE "Feedback" (
3 | "id" TEXT NOT NULL,
4 | "review" INTEGER NOT NULL,
5 | "message" TEXT NOT NULL,
6 | "email" TEXT,
7 | "userId" TEXT,
8 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
9 | "updatedAt" TIMESTAMP(3) NOT NULL,
10 |
11 | CONSTRAINT "Feedback_pkey" PRIMARY KEY ("id")
12 | );
13 |
14 | -- AddForeignKey
15 | ALTER TABLE "Feedback" ADD CONSTRAINT "Feedback_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
16 |
--------------------------------------------------------------------------------
/.github/pull_request_template.md:
--------------------------------------------------------------------------------
1 | ## 📝 Description
2 |
3 |
4 |
5 | ## 📋 Checklist
6 |
7 | - [ ] My code follows the project conventions
8 | - [ ] This PR includes breaking changes
9 | - [ ] I have updated documentation if necessary
10 |
11 | ## 🗃️ Prisma Migrations (if applicable)
12 |
13 | - [ ] I have created a migration
14 | - [ ] I have tested the migration locally
15 |
16 | ## 📸 Screenshots (if applicable)
17 |
18 |
19 |
20 | ## 🔗 Related Issues
21 |
22 |
23 |
--------------------------------------------------------------------------------
/prisma/migrations_backup/20250614153656_remove_value_int_value_sec_unit_from_workoutset/migration.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - You are about to drop the column `unit` on the `workout_sets` table. All the data in the column will be lost.
5 | - You are about to drop the column `valueInt` on the `workout_sets` table. All the data in the column will be lost.
6 | - You are about to drop the column `valueSec` on the `workout_sets` table. All the data in the column will be lost.
7 |
8 | */
9 | -- AlterTable
10 | ALTER TABLE "workout_sets" DROP COLUMN "unit",
11 | DROP COLUMN "valueInt",
12 | DROP COLUMN "valueSec";
13 |
--------------------------------------------------------------------------------
/src/features/workout-builder/model/favorite-exercises-synchronizer.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useEffect } from "react";
4 |
5 | import { useSession } from "@/features/auth/lib/auth-client";
6 |
7 | import { useSyncFavoriteExercises } from "../hooks/use-sync-favorite-exercises";
8 |
9 | export function FavoriteExercisesSynchronizer() {
10 | const { syncFavoriteExercises } = useSyncFavoriteExercises();
11 | const { data: session } = useSession();
12 |
13 | useEffect(() => {
14 | if (session?.user) {
15 | syncFavoriteExercises();
16 | }
17 | }, [session?.user]);
18 |
19 | return null;
20 | }
21 |
--------------------------------------------------------------------------------
/app/[locale]/(app)/about/page.tsx:
--------------------------------------------------------------------------------
1 | import { getLocalizedMdx } from "@/shared/lib/mdx/load-mdx";
2 |
3 | type PageProps = {
4 | params: Promise<{ locale: string }>;
5 | };
6 |
7 | export default async function AboutPage({ params }: PageProps) {
8 | const { locale } = await params;
9 | const content = await getLocalizedMdx("about", locale);
10 |
11 | return (
12 |
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/src/components/ads/AdPlaceholder.tsx:
--------------------------------------------------------------------------------
1 | interface AdPlaceholderProps {
2 | width: string;
3 | height: string;
4 | type?: string;
5 | }
6 |
7 | export function AdPlaceholder({ width, height, type = "Ad" }: AdPlaceholderProps) {
8 | return (
9 |
13 |
14 | {type} {width} × {height}
15 |
16 |
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/src/features/auth/model/useLogout.ts:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useRouter } from "next/navigation";
4 | import { useMutation, useQueryClient } from "@tanstack/react-query";
5 |
6 | import { authClient } from "@/features/auth/lib/auth-client";
7 |
8 | export const useLogout = (redirectUrl: string = "/") => {
9 | const router = useRouter();
10 | const queryClient = useQueryClient();
11 |
12 | return useMutation({
13 | mutationFn: async () => {
14 | await authClient.signOut();
15 | router.push(redirectUrl);
16 | queryClient.invalidateQueries({ queryKey: ["session"] });
17 | },
18 | });
19 | };
20 |
--------------------------------------------------------------------------------
/src/features/statistics/types/index.ts:
--------------------------------------------------------------------------------
1 | // Re-export types from shared
2 | export type {
3 | WeightProgressionPoint,
4 | WeightProgressionResponse,
5 | OneRepMaxPoint,
6 | OneRepMaxResponse,
7 | VolumePoint,
8 | VolumeResponse,
9 | ExerciseStatisticsResponse,
10 | StatisticsErrorResponse,
11 | StatisticsRequestParams,
12 | } from "@/shared/types/statistics.types";
13 |
14 | // Client-side specific types
15 | export interface ChartDimensions {
16 | width: number;
17 | height: number;
18 | padding: {
19 | top: number;
20 | right: number;
21 | bottom: number;
22 | left: number;
23 | };
24 | }
25 |
--------------------------------------------------------------------------------
/src/features/workout-session/model/use-workout-sessions.ts:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useQuery } from "@tanstack/react-query";
4 |
5 | import { useWorkoutSessionService } from "@/shared/lib/workout-session/use-workout-session.service";
6 | import { useSession } from "@/features/auth/lib/auth-client";
7 |
8 | export function useWorkoutSessions() {
9 | const { data: session } = useSession();
10 |
11 | const { getAll } = useWorkoutSessionService();
12 |
13 | return useQuery({
14 | queryKey: ["workout-sessions", session?.user?.id],
15 | queryFn: async () => {
16 | return getAll();
17 | },
18 | });
19 | }
20 |
--------------------------------------------------------------------------------
/src/features/workout-session/ui/workout-sessions-synchronizer.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useEffect } from "react";
4 | import { useSearchParams } from "next/navigation";
5 |
6 | import { useSyncWorkoutSessions } from "../model/use-sync-workout-sessions";
7 |
8 | export const WorkoutSessionsSynchronizer = () => {
9 | const { syncSessions } = useSyncWorkoutSessions();
10 | const searchParams = useSearchParams();
11 | const isSigninParam = searchParams.get("signin") === "true";
12 |
13 | useEffect(() => {
14 | if (isSigninParam) {
15 | syncSessions();
16 | }
17 | }, [isSigninParam]);
18 |
19 | return null;
20 | };
21 |
--------------------------------------------------------------------------------
/src/features/auth/ui/AuthButtonServer.tsx:
--------------------------------------------------------------------------------
1 | import { SignUpButton } from "@/features/auth/ui/SignUpButton";
2 | import { SignInButton } from "@/features/auth/ui/SignInButton";
3 | import { LoggedInButton } from "@/features/auth/ui/LoggedInButton";
4 | import { serverAuth } from "@/entities/user/model/get-server-session-user";
5 |
6 | export const AuthButtonServer = async () => {
7 | const user = await serverAuth();
8 |
9 | if (user) {
10 | return ;
11 | }
12 |
13 | return (
14 |
15 |
16 |
17 |
18 | );
19 | };
20 |
--------------------------------------------------------------------------------
/app/[locale]/(app)/tools/calorie-calculator/shared/components/index.ts:
--------------------------------------------------------------------------------
1 | // Export all shared components
2 | export { GenderSelector } from "./GenderSelector";
3 | export { WeightInput } from "./WeightInput";
4 | export { HeightInput } from "./HeightInput";
5 | export { AgeInput } from "./AgeInput";
6 | export { UnitSelector } from "./UnitSelector";
7 | export { ActivityLevelSelector } from "./ActivityLevelSelector";
8 | export { GoalSelector } from "./GoalSelector";
9 | export { FAQSection } from "./FAQSection";
10 | export { InfoButton } from "./InfoButton";
11 | export { InfoModal } from "./InfoModal";
12 | export { ResultsDisplay } from "./ResultsDisplay";
13 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | services:
2 | postgres:
3 | image: postgres:15
4 | ports:
5 | - "${DB_PORT:-5432}:5432"
6 | volumes:
7 | - pgdata:/var/lib/postgresql/data
8 | healthcheck:
9 | test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
10 | interval: 5s
11 | timeout: 5s
12 | retries: 5
13 | env_file: .env
14 |
15 | workout_cool:
16 | build:
17 | context: .
18 | dockerfile: Dockerfile
19 | ports:
20 | - "${APP_PORT:-3000}:3000"
21 | depends_on:
22 | postgres:
23 | condition: service_healthy
24 | env_file: .env
25 | volumes:
26 | pgdata:
27 |
--------------------------------------------------------------------------------
/src/features/layout/useSidebarToggle.ts:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useCallback, useState } from "react";
4 |
5 | export const useSidebarToggle = () => {
6 | const [isOpen, setIsOpen] = useState(false);
7 |
8 | const toggleSidebar = useCallback(() => {
9 | const sidebar = document.getElementById("sidebar");
10 | const overlay = document.getElementById("overlay");
11 |
12 | if (sidebar) {
13 | sidebar.classList.toggle("open");
14 | }
15 |
16 | if (overlay) {
17 | overlay.classList.toggle("open");
18 | }
19 |
20 | setIsOpen((prev) => !prev);
21 | }, []);
22 |
23 | return { isOpen, toggleSidebar };
24 | };
25 |
--------------------------------------------------------------------------------
/app/[locale]/(admin)/admin/programs/[id]/edit/page.tsx:
--------------------------------------------------------------------------------
1 | import { notFound } from "next/navigation";
2 |
3 | import { ProgramBuilder } from "@/features/admin/programs/ui/program-builder";
4 | import { getProgramById } from "@/features/admin/programs/actions/get-programs.action";
5 |
6 | interface ProgramEditPageProps {
7 | params: Promise<{ id: string }>;
8 | }
9 |
10 | export default async function ProgramEditPage({ params }: ProgramEditPageProps) {
11 | const { id } = await params;
12 |
13 | const program = await getProgramById(id);
14 |
15 | if (!program) {
16 | notFound();
17 | }
18 |
19 | return ;
20 | }
21 |
--------------------------------------------------------------------------------
/src/components/ads/VerticalLeftBanner.tsx:
--------------------------------------------------------------------------------
1 | import { env } from "@/env";
2 |
3 | import { VerticalAdBanner } from "./VerticalAdBanner";
4 |
5 | export function VerticalLeftBanner() {
6 | const hasAdSlot = env.NEXT_PUBLIC_VERTICAL_LEFT_BANNER_AD_SLOT;
7 | const hasEzoicPlacement = env.NEXT_PUBLIC_EZOIC_VERTICAL_LEFT_PLACEMENT_ID;
8 |
9 | if (!hasAdSlot && !hasEzoicPlacement) {
10 | return null;
11 | }
12 |
13 | return (
14 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/src/components/ui/skeleton.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | import { cn } from "@/shared/lib/utils";
4 |
5 | interface SkeletonProps extends React.HTMLAttributes {
6 | width?: string | number;
7 | height?: string | number;
8 | rounded?: string;
9 | }
10 |
11 | export function Skeleton({ width, height, rounded = "rounded", className, ...props }: SkeletonProps) {
12 | return (
13 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/src/components/pwa/ServiceWorkerRegistration.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useEffect } from "react";
4 |
5 | export function ServiceWorkerRegistration() {
6 | useEffect(() => {
7 | if ("serviceWorker" in navigator) {
8 | navigator.serviceWorker
9 | .register("/sw.js")
10 | .then((registration) => {
11 | console.log("SW registered: ", registration);
12 | // Check for updates
13 | registration.update();
14 | })
15 | .catch((registrationError) => {
16 | console.log("SW registration failed: ", registrationError);
17 | });
18 | }
19 | }, []);
20 |
21 | return null;
22 | }
--------------------------------------------------------------------------------
/src/components/ads/VerticalRightBanner.tsx:
--------------------------------------------------------------------------------
1 | import { env } from "@/env";
2 |
3 | import { VerticalAdBanner } from "./VerticalAdBanner";
4 |
5 | export function VerticalRightBanner() {
6 | const hasAdSlot = env.NEXT_PUBLIC_VERTICAL_RIGHT_BANNER_AD_SLOT;
7 | const hasEzoicPlacement = env.NEXT_PUBLIC_EZOIC_VERTICAL_RIGHT_PLACEMENT_ID;
8 |
9 | if (!hasAdSlot && !hasEzoicPlacement) {
10 | return null;
11 | }
12 |
13 | return (
14 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/src/components/ui/divider.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/shared/lib/utils";
2 |
3 | import type { ComponentProps } from "react";
4 |
5 | export type DividerProps = ComponentProps<"div">;
6 |
7 | export const Divider = ({ className, children, ...props }: DividerProps) => {
8 | return (
9 |
10 |
11 |
12 | {children}
13 |
14 | );
15 | };
16 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest a feature for this project
4 | title: ""
5 | labels: ""
6 | assignees: ""
7 | ---
8 |
9 | **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always
10 | frustrated when [...]
11 |
12 | **Describe the solution you'd like** A clear and concise description of what you want to happen.
13 |
14 | **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered.
15 |
16 | **Additional context** Add any other context or screenshots about the feature request here.
17 |
--------------------------------------------------------------------------------
/src/components/utils/TailwindIndicator.tsx:
--------------------------------------------------------------------------------
1 | export function TailwindIndicator() {
2 | if (process.env.NODE_ENV === "production") return null;
3 |
4 | return (
5 |
6 |
xs
7 |
sm
8 |
md
9 |
lg
10 |
xl
11 |
2xl
12 |
13 | );
14 | }
15 |
--------------------------------------------------------------------------------
/app/[locale]/(app)/(legal-and-payment)/layout.tsx:
--------------------------------------------------------------------------------
1 | import { LayoutParams } from "@/shared/types/next";
2 |
3 | type LocaleParams = Record & {
4 | locale: string;
5 | };
6 |
7 | export default function RouteLayout({ children, params: _ }: LayoutParams) {
8 | return (
9 |
10 | {/* Fixe l'espace sous le header flottant */}
11 |
12 |
13 | {/* Contenu principal centré avec marge */}
14 |
15 | {children}
16 |
17 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/scripts/setup.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | echo "Running Prisma migrations..."
4 | npx prisma migrate deploy
5 |
6 | echo "Generating Prisma client..."
7 | npx prisma generate
8 |
9 | if [ "$SEED_SAMPLE_DATA" = "true" ]; then
10 | echo "Seed sample data enabled, importing sample data..."
11 | # Import exercises if CSV exists
12 | if [ -f "./data/sample-exercises.csv" ]; then
13 | npx tsx scripts/import-exercises-with-attributes.ts ./data/sample-exercises.csv
14 | else
15 | echo "No exercises sample data found, skipping import."
16 | fi
17 | else
18 | echo "Skipping sample data import."
19 | fi
20 |
21 | echo "Starting the app..."
22 | exec "$@" # runs the CMD from the Dockerfile
23 |
--------------------------------------------------------------------------------
/app/[locale]/(app)/auth/verify-email/layout.tsx:
--------------------------------------------------------------------------------
1 | import { ReactElement } from "react";
2 | import { redirect } from "next/navigation";
3 |
4 | import { getServerUrl } from "@/shared/lib/server-url";
5 | import { paths } from "@/shared/constants/paths";
6 | import { serverRequiredUser } from "@/entities/user/model/get-server-session-user";
7 |
8 | interface RootLayoutProps {
9 | params: Promise<{ locale: string }>;
10 | children: ReactElement;
11 | }
12 |
13 | export default async function RootLayout({ children }: RootLayoutProps) {
14 | const auth = await serverRequiredUser();
15 |
16 | if (auth.emailVerified) {
17 | redirect(`${getServerUrl()}/${paths.root}`);
18 | }
19 |
20 | return children;
21 | }
22 |
--------------------------------------------------------------------------------
/app/robots.txt:
--------------------------------------------------------------------------------
1 | User-agent: *
2 | Allow: /
3 | Disallow: /admin/
4 | Disallow: /api/
5 | Disallow: /dashboard/
6 | Disallow: /preview/
7 | Disallow: /auth/
8 | Disallow: /onboarding/
9 | Disallow: /profile/
10 | Disallow: /_next/
11 | Disallow: /.*\?
12 |
13 | # Crawl delay
14 | Crawl-delay: 1
15 |
16 | # Specific bot configurations
17 | User-agent: Googlebot
18 | Allow: /
19 | Disallow: /admin/
20 | Disallow: /api/
21 | Disallow: /auth/
22 | Disallow: /onboarding/
23 | Disallow: /profile/
24 |
25 | User-agent: Bingbot
26 | Allow: /
27 | Disallow: /admin/
28 | Disallow: /api/
29 | Disallow: /auth/
30 | Disallow: /onboarding/
31 | Disallow: /profile/
32 |
33 | # Sitemap
34 | Sitemap: https://www.workout.cool/sitemap.xml
35 |
--------------------------------------------------------------------------------
/src/features/admin/programs/ui/create-program-button.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useState } from "react";
4 | import { Plus } from "lucide-react";
5 |
6 | import { CreateProgramModal } from "./create-program-modal";
7 |
8 | export function CreateProgramButton() {
9 | const [isModalOpen, setIsModalOpen] = useState(false);
10 |
11 | return (
12 | <>
13 | setIsModalOpen(true)}
16 | >
17 |
18 | Créer un programme
19 |
20 |
21 |
25 | >
26 | );
27 | }
--------------------------------------------------------------------------------
/app/api/programs/[slug]/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from "next/server";
2 |
3 | import { getProgramBySlug } from "@/features/programs/actions/get-program-by-slug.action";
4 |
5 | export async function GET(request: Request, { params }: { params: Promise<{ slug: string }> }) {
6 | try {
7 | const { slug } = await params;
8 | const program = await getProgramBySlug(slug);
9 |
10 | if (!program) {
11 | return NextResponse.json({ error: "Program not found" }, { status: 404 });
12 | }
13 |
14 | return NextResponse.json(program);
15 | } catch (error) {
16 | console.error("Error fetching program:", error);
17 | return NextResponse.json({ error: "Failed to fetch program" }, { status: 500 });
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/entities/user/model/get-server-session-user.ts:
--------------------------------------------------------------------------------
1 | import { notFound } from "next/navigation";
2 | import { headers } from "next/headers";
3 |
4 | import { auth } from "@/features/auth/lib/better-auth";
5 | export class AuthError extends Error {
6 | constructor(message: string) {
7 | super(message);
8 | }
9 | }
10 |
11 | export const serverAuth = async () => {
12 | const session = await auth.api.getSession({ headers: await headers() });
13 |
14 | if (session && session.user) {
15 | return session.user;
16 | }
17 |
18 | return null;
19 | };
20 |
21 | export const serverRequiredUser = async () => {
22 | const user = await serverAuth();
23 |
24 | if (!user) {
25 | notFound();
26 | }
27 |
28 | return user;
29 | };
30 |
--------------------------------------------------------------------------------
/app/[locale]/(app)/tools/heart-rate-zones/ui/components/ScrollToTopButton.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import React from "react";
4 |
5 | import { useScrollToTop } from "@/shared/hooks/useScrollToTop";
6 |
7 | interface ScrollToTopButtonProps {
8 | text: string;
9 | }
10 |
11 | export function ScrollToTopButton({ text }: ScrollToTopButtonProps) {
12 | const scrollToTop = useScrollToTop();
13 |
14 | return (
15 |
19 | {text}
20 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/src/shared/hooks/useBoolean.ts:
--------------------------------------------------------------------------------
1 | import { Dispatch, SetStateAction, useCallback, useState } from "react";
2 |
3 | export interface UseBooleanReturn {
4 | value: boolean;
5 | setValue: Dispatch>;
6 | setTrue: () => void;
7 | setFalse: () => void;
8 | toggle: () => void;
9 | }
10 |
11 | function useBoolean(defaultValue?: boolean): UseBooleanReturn {
12 | const [value, setValue] = useState(Boolean(defaultValue));
13 |
14 | const setTrue = useCallback(() => setValue(true), []);
15 | const setFalse = useCallback(() => setValue(false), []);
16 | const toggle = useCallback(() => setValue((prev) => !prev), []);
17 |
18 | return { value, setValue, setTrue, setFalse, toggle };
19 | }
20 |
21 | export default useBoolean;
22 |
--------------------------------------------------------------------------------
/src/shared/lib/mdx/load-mdx.ts:
--------------------------------------------------------------------------------
1 | import path from "path";
2 | import { readFile } from "fs/promises";
3 |
4 | import { compileMDX } from "next-mdx-remote/rsc";
5 |
6 | import { WorkoutLol } from "@/components/ui/workout-lol";
7 |
8 | export async function getLocalizedMdx(
9 | pageSlug: string, // ex: "privacy-policy"
10 | locale: string, // ex: "fr" or "en"
11 | ) {
12 | const filePath = path.join(process.cwd(), "content", pageSlug, `${locale}.mdx`);
13 |
14 | const source = await readFile(filePath, "utf-8");
15 |
16 | const { content } = await compileMDX({
17 | source,
18 | options: {
19 | parseFrontmatter: true,
20 | },
21 | components: {
22 | WorkoutLol,
23 | },
24 | });
25 |
26 | return content;
27 | }
28 |
--------------------------------------------------------------------------------
/src/shared/lib/network/use-network-status.ts:
--------------------------------------------------------------------------------
1 | // src/shared/lib/network/useNetworkStatus.ts
2 | import { useEffect, useState } from "react";
3 |
4 | export function useNetworkStatus() {
5 | const [isOnline, setIsOnline] = useState(typeof window !== "undefined" ? navigator.onLine : true);
6 |
7 | useEffect(() => {
8 | const handleOnline = () => setIsOnline(true);
9 | const handleOffline = () => setIsOnline(false);
10 | window.addEventListener("online", handleOnline);
11 | window.addEventListener("offline", handleOffline);
12 | return () => {
13 | window.removeEventListener("online", handleOnline);
14 | window.removeEventListener("offline", handleOffline);
15 | };
16 | }, []);
17 |
18 | return { isOnline };
19 | }
20 |
--------------------------------------------------------------------------------
/src/shared/lib/server-url.ts:
--------------------------------------------------------------------------------
1 | import { SiteConfig } from "@/shared/config/site-config";
2 |
3 | /**
4 | * This method return the server URL based on the environment.
5 | */
6 | export const getServerUrl = () => {
7 | if (typeof window !== "undefined") {
8 | return window.location.origin;
9 | }
10 |
11 | // If we are in production, we return the production URL.
12 | if (process.env.VERCEL_ENV === "production") {
13 | return SiteConfig.prodUrl;
14 | }
15 |
16 | // If we are in "stage" environment, we return the staging URL.
17 | if (process.env.VERCEL_URL) {
18 | return `https://${process.env.VERCEL_URL}`;
19 | }
20 |
21 | // If we are in development, we return the localhost URL.
22 | return "http://localhost:3000";
23 | };
24 |
--------------------------------------------------------------------------------
/src/shared/lib/workout-session/workout-session.api.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-unused-vars */
2 | import type { WorkoutSession } from "./types/workout-session";
3 |
4 | export const workoutSessionApi = {
5 | getAll: async (): Promise => {
6 | // TODO: fetch("/api/workout-sessions")
7 | return [];
8 | },
9 | create: async (session: WorkoutSession): Promise<{ id: string }> => {
10 | // TODO: POST /api/workout-sessions
11 | return { id: "server-uuid" };
12 | },
13 | update: async (id: string, data: Partial) => {
14 | // TODO: PATCH /api/workout-sessions/:id
15 | },
16 | complete: async (id: string) => {
17 | // TODO: PATCH /api/workout-sessions/:id/complete
18 | },
19 | };
20 |
--------------------------------------------------------------------------------
/src/components/svg/DotPattern.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/shared/lib/utils";
2 |
3 | import type { ComponentPropsWithoutRef } from "react";
4 |
5 | export type DotPatternProps = ComponentPropsWithoutRef<"div">;
6 |
7 | export const DotPattern = ({ children, className, ...props }: DotPatternProps) => {
8 | return (
9 |
10 |
18 |
{children}
19 |
20 | );
21 | };
22 |
--------------------------------------------------------------------------------
/src/shared/hooks/useIsMobile.ts:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useState, useEffect } from "react";
4 |
5 | export function useIsMobile(breakpoint: number = 768) {
6 | const [isMobile, setIsMobile] = useState(false);
7 |
8 | useEffect(() => {
9 | // Check if window is defined (client-side)
10 | if (typeof window === "undefined") return;
11 |
12 | const checkIsMobile = () => {
13 | setIsMobile(window.innerWidth < breakpoint);
14 | };
15 |
16 | // Initial check
17 | checkIsMobile();
18 |
19 | // Add event listener
20 | window.addEventListener("resize", checkIsMobile);
21 |
22 | // Cleanup
23 | return () => window.removeEventListener("resize", checkIsMobile);
24 | }, [breakpoint]);
25 |
26 | return isMobile;
27 | }
28 |
--------------------------------------------------------------------------------
/src/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { type VariantProps, cva } from "class-variance-authority";
3 | import * as LabelPrimitive from "@radix-ui/react-label";
4 |
5 | import { cn } from "@/shared/lib/utils";
6 |
7 | const labelVariants = cva("text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70");
8 |
9 | const Label = React.forwardRef<
10 | React.ElementRef,
11 | React.ComponentPropsWithoutRef & VariantProps
12 | >(({ className, ...props }, ref) => );
13 | Label.displayName = LabelPrimitive.Root.displayName;
14 |
15 | export { Label };
16 |
--------------------------------------------------------------------------------
/src/features/workout-session/types/workout-set.ts:
--------------------------------------------------------------------------------
1 | import { ExerciseWithAttributes } from "@/entities/exercise/types/exercise.types";
2 |
3 | export type WorkoutSetType = "TIME" | "WEIGHT" | "REPS" | "BODYWEIGHT" | "NA";
4 | export type WorkoutSetUnit = "kg" | "lbs";
5 |
6 | export interface WorkoutSet {
7 | id: string;
8 | setIndex: number;
9 | types: WorkoutSetType[]; // To support multiple columns
10 | valuesInt?: number[]; // To support multiple columns
11 | valuesSec?: number[]; // To support multiple columns
12 | units?: WorkoutSetUnit[]; // Pour supporter plusieurs colonnes
13 | completed: boolean;
14 | }
15 |
16 | export interface WorkoutSessionExercise extends ExerciseWithAttributes {
17 | id: string;
18 | order: number;
19 | sets: WorkoutSet[];
20 | }
21 |
--------------------------------------------------------------------------------
/src/shared/lib/analytics/events.ts:
--------------------------------------------------------------------------------
1 | export const LogEvents = {
2 | Registered: {
3 | name: "User Registered",
4 | channel: "registered",
5 | },
6 | EnrolledInProgram: {
7 | name: "User Enrolled in Program",
8 | channel: "program",
9 | },
10 | PremiumDiscovery: {
11 | name: "Premium Features Discovered",
12 | channel: "premium",
13 | },
14 | PaywallViewed: {
15 | name: "Paywall Viewed",
16 | channel: "premium",
17 | },
18 | PaywallPurchased: {
19 | name: "Paywall Purchase Completed",
20 | channel: "premium",
21 | },
22 | PaywallCancelled: {
23 | name: "Paywall Cancelled",
24 | channel: "premium",
25 | },
26 | PaywallRestored: {
27 | name: "Purchases Restored",
28 | channel: "premium",
29 | },
30 | };
31 |
--------------------------------------------------------------------------------
/prisma/migrations_backup/20250505191954_admin_and_user_lowercase/migration.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - The values [ADMIN,USER] on the enum `UserRole` will be removed. If these variants are still used in the database, this will fail.
5 |
6 | */
7 | -- AlterEnum
8 | BEGIN;
9 | CREATE TYPE "UserRole_new" AS ENUM ('admin', 'user');
10 | ALTER TABLE "user" ALTER COLUMN "role" DROP DEFAULT;
11 | ALTER TABLE "user" ALTER COLUMN "role" TYPE "UserRole_new" USING ("role"::text::"UserRole_new");
12 | ALTER TYPE "UserRole" RENAME TO "UserRole_old";
13 | ALTER TYPE "UserRole_new" RENAME TO "UserRole";
14 | DROP TYPE "UserRole_old";
15 | ALTER TABLE "user" ALTER COLUMN "role" SET DEFAULT 'user';
16 | COMMIT;
17 |
18 | -- AlterTable
19 | ALTER TABLE "user" ALTER COLUMN "role" SET DEFAULT 'user';
20 |
--------------------------------------------------------------------------------
/src/components/svg/VerifiedBadge.tsx:
--------------------------------------------------------------------------------
1 | import type { ComponentPropsWithoutRef } from "react";
2 |
3 | export const VerifiedBadge = (props: ComponentPropsWithoutRef<"svg">) => {
4 | return (
5 |
6 |
7 |
8 | );
9 | };
10 |
--------------------------------------------------------------------------------
/src/features/auth/reset-password/schema/reset-password.schema.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 |
3 | export const resetPasswordSchema = z
4 | .object({
5 | password: z
6 | .string()
7 | .min(8, "Le mot de passe doit contenir au moins 8 caractères")
8 | .regex(/[A-Z]/, "Le mot de passe doit contenir au moins une majuscule")
9 | .regex(/[a-z]/, "Le mot de passe doit contenir au moins une minuscule")
10 | .regex(/[0-9]/, "Le mot de passe doit contenir au moins un chiffre"),
11 | confirmPassword: z.string(),
12 | })
13 | .refine((data) => data.password === data.confirmPassword, {
14 | message: "Les mots de passe ne correspondent pas",
15 | path: ["confirmPassword"],
16 | });
17 |
18 | export type ResetPasswordFormData = z.infer;
19 |
--------------------------------------------------------------------------------
/src/shared/lib/workout-session/types/workout-session.ts:
--------------------------------------------------------------------------------
1 | import { ExerciseAttributeValueEnum } from "@prisma/client";
2 |
3 | import { WorkoutSessionExercise } from "@/features/workout-session/types/workout-set";
4 |
5 | export const workoutSessionStatuses = ["active", "completed", "synced"] as const;
6 | export type WorkoutSessionStatus = (typeof workoutSessionStatuses)[number];
7 |
8 | export interface WorkoutSession {
9 | id: string; // local: "local-xxx", server: uuid
10 | userId: string;
11 | status?: WorkoutSessionStatus;
12 | startedAt: string;
13 | endedAt?: string;
14 | duration?: number;
15 | exercises: WorkoutSessionExercise[];
16 | currentExerciseIndex?: number;
17 | isActive?: boolean;
18 | serverId?: string; // If synced
19 | muscles: ExerciseAttributeValueEnum[];
20 | }
21 |
--------------------------------------------------------------------------------
/app/api/programs/[slug]/progress/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from "next/server";
2 |
3 | import { getProgramProgressBySlug } from "@/features/programs/actions/get-program-progress-by-slug.action";
4 |
5 | export async function GET(request: Request, { params }: { params: Promise<{ slug: string }> }) {
6 | try {
7 | const { slug } = await params;
8 |
9 | const progress = await getProgramProgressBySlug(slug);
10 |
11 | if (!progress) {
12 | return NextResponse.json({ error: "Program progress not found" }, { status: 404 });
13 | }
14 |
15 | return NextResponse.json(progress);
16 | } catch (error) {
17 | console.error("Error fetching program progress:", error);
18 | return NextResponse.json({ error: "Failed to fetch program progress" }, { status: 500 });
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/prisma/migrations_backup/20250615170916_add_cascade_delete_workout_sessions/migration.sql:
--------------------------------------------------------------------------------
1 | -- DropForeignKey
2 | ALTER TABLE "workout_session_exercises" DROP CONSTRAINT "workout_session_exercises_workoutSessionId_fkey";
3 |
4 | -- DropForeignKey
5 | ALTER TABLE "workout_sets" DROP CONSTRAINT "workout_sets_workoutSessionExerciseId_fkey";
6 |
7 | -- AddForeignKey
8 | ALTER TABLE "workout_session_exercises" ADD CONSTRAINT "workout_session_exercises_workoutSessionId_fkey" FOREIGN KEY ("workoutSessionId") REFERENCES "workout_sessions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
9 |
10 | -- AddForeignKey
11 | ALTER TABLE "workout_sets" ADD CONSTRAINT "workout_sets_workoutSessionExerciseId_fkey" FOREIGN KEY ("workoutSessionExerciseId") REFERENCES "workout_session_exercises"("id") ON DELETE CASCADE ON UPDATE CASCADE;
12 |
--------------------------------------------------------------------------------
/src/shared/lib/location/eu-countries.ts:
--------------------------------------------------------------------------------
1 | export const EU_COUNTRY_CODES = [
2 | "AT",
3 | "BE",
4 | "BG",
5 | "HR",
6 | "CY",
7 | "CZ",
8 | "DK",
9 | "EE",
10 | "FI",
11 | "FR",
12 | "DE",
13 | "GR",
14 | "HU",
15 | "IE",
16 | "IT",
17 | "LV",
18 | "LT",
19 | "LU",
20 | "MT",
21 | "NL",
22 | "PL",
23 | "PT",
24 | "RO",
25 | "SK",
26 | "SI",
27 | "ES",
28 | "SE",
29 | "GB",
30 | "GI",
31 | "IS",
32 | "LI",
33 | "NO",
34 | "CH",
35 | "ME",
36 | "MK",
37 | "RS",
38 | "TR",
39 | "AL",
40 | "BA",
41 | "XK",
42 | "AD",
43 | "BY",
44 | "MD",
45 | "MC",
46 | "RU",
47 | "UA",
48 | "VA",
49 | "AX",
50 | "FO",
51 | "GL",
52 | "SJ",
53 | "IM",
54 | "JE",
55 | "GG",
56 | "RS",
57 | "ME",
58 | "XK",
59 | "RS",
60 | ];
61 |
--------------------------------------------------------------------------------
/src/features/consent-banner/model/tracking-consent.action.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { cookies } from "next/headers";
4 |
5 | import { Cookies } from "@/shared/constants/cookies";
6 | import { actionClient } from "@/shared/api/safe-actions";
7 | import { trackingConsentSchema } from "@/features/consent-banner/schema/tracking-consent.schema";
8 |
9 | export const trackingConsentAction = actionClient.schema(trackingConsentSchema).action(async ({ parsedInput: value }) => {
10 | const cookiesStore = await cookies();
11 |
12 | const oneYearFromNow = new Date();
13 | oneYearFromNow.setFullYear(oneYearFromNow.getFullYear() + 1);
14 |
15 | cookiesStore.set({
16 | name: Cookies.TrackingConsent,
17 | value: value ? "1" : "0",
18 | expires: oneYearFromNow,
19 | });
20 |
21 | return value;
22 | });
23 |
--------------------------------------------------------------------------------
/src/shared/lib/premium/use-premium-redirect.ts:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useEffect } from "react";
4 | import { useRouter, useSearchParams } from "next/navigation";
5 |
6 | import { useIsPremium } from "./use-premium";
7 |
8 | export function usePremiumRedirect() {
9 | const router = useRouter();
10 | const searchParams = useSearchParams();
11 | const isPremium = useIsPremium();
12 |
13 | useEffect(() => {
14 | // Check if user just became premium and has a return URL
15 | if (isPremium) {
16 | const returnUrl = searchParams.get("return");
17 | if (returnUrl) {
18 | // Small delay to ensure premium status is fully updated
19 | setTimeout(() => {
20 | router.push(returnUrl);
21 | }, 1000);
22 | }
23 | }
24 | }, [isPremium, searchParams, router]);
25 | }
--------------------------------------------------------------------------------
/app/[locale]/(admin)/admin/programs/page.tsx:
--------------------------------------------------------------------------------
1 | import { Suspense } from "react";
2 |
3 | import { ProgramsList } from "@/features/admin/programs/ui/programs-list";
4 | import { CreateProgramButton } from "@/features/admin/programs/ui/create-program-button";
5 |
6 | export default function AdminPrograms() {
7 | return (
8 |
9 |
10 |
11 |
Programs
12 |
Create, edit, view and delete programs.
13 |
14 |
15 |
16 |
17 |
Loading programs... }>
18 |
19 |
20 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/src/components/ui/separator.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as SeparatorPrimitive from "@radix-ui/react-separator";
5 |
6 | import { cn } from "@/shared/lib/utils";
7 |
8 | const Separator = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (
12 |
19 | ));
20 | Separator.displayName = SeparatorPrimitive.Root.displayName;
21 |
22 | export { Separator };
23 |
--------------------------------------------------------------------------------
/src/components/ui/star-button.tsx:
--------------------------------------------------------------------------------
1 | import { Star } from "lucide-react";
2 |
3 | import { cn } from "@/shared/lib/utils";
4 |
5 | interface StarButtonProps {
6 | isActive: boolean;
7 | isLoading: boolean;
8 | onClick?: (e: React.MouseEvent) => void;
9 | className?: string;
10 | children?: React.ReactNode;
11 | }
12 |
13 | export function StarButton({ isActive, isLoading, onClick, className, children }: StarButtonProps) {
14 | return (
15 |
20 |
21 | {children}
22 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/app/[locale]/(app)/auth/error.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useEffect } from "react";
4 |
5 | import { logger } from "@/shared/lib/logger";
6 | import { Card, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
7 | import { Button } from "@/components/ui/button";
8 |
9 | import type { ErrorParams } from "@/shared/types/next";
10 |
11 | export default function RouteError({ error, reset }: ErrorParams) {
12 | useEffect(() => {
13 | // Log the error to an error reporting service
14 | logger.error(error);
15 | }, [error]);
16 |
17 | return (
18 |
19 |
20 | Sorry, something went wrong. Please try again later.
21 |
22 |
23 | Try again
24 |
25 |
26 | );
27 | }
28 |
--------------------------------------------------------------------------------
/src/features/auth/lib/auth-client.ts:
--------------------------------------------------------------------------------
1 | import { createAuthClient } from "better-auth/react";
2 | import { adminClient, customSessionClient, inferAdditionalFields } from "better-auth/client/plugins";
3 |
4 | import { getServerUrl } from "@/shared/lib/server-url";
5 | import { auth } from "@/features/auth/lib/better-auth";
6 |
7 | export const authClient = createAuthClient({
8 | /** The base URL of the server (optional if you're using the same domain) */
9 | baseURL: getServerUrl(),
10 | plugins: [adminClient(), customSessionClient(), inferAdditionalFields()],
11 | });
12 |
13 | export const useIsAdmin = () => {
14 | const { data: sessionData, isPending: isSessionLoading } = useSession();
15 | return !isSessionLoading && sessionData?.user?.role?.includes("admin");
16 | };
17 |
18 | export const { useSession } = authClient;
19 |
--------------------------------------------------------------------------------
/src/features/statistics/hooks/use-chart-theme.ts:
--------------------------------------------------------------------------------
1 | import { useTheme } from "next-themes";
2 |
3 | export function useChartTheme() {
4 | const { theme, systemTheme } = useTheme();
5 | const currentTheme = theme === "system" ? systemTheme : theme;
6 | const isDark = currentTheme === "dark";
7 |
8 | return {
9 | isDark,
10 | colors: {
11 | background: isDark ? "#1f2937" : "#ffffff",
12 | cardBackground: isDark ? "#374151" : "#ffffff",
13 | text: isDark ? "#f9fafb" : "#374151",
14 | textSecondary: isDark ? "#d1d5db" : "#6b7280",
15 | textMuted: isDark ? "#9ca3af" : "#6b7280",
16 | border: isDark ? "#4b5563" : "#e5e7eb",
17 | grid: isDark ? "#4b5563" : "#e5e7eb",
18 | tooltipBackground: isDark ? "#374151" : "#ffffff",
19 | tooltipBorder: isDark ? "#4b5563" : "#e5e7eb",
20 | },
21 | };
22 | }
23 |
--------------------------------------------------------------------------------
/src/features/workout-builder/ui/muscles.module.css:
--------------------------------------------------------------------------------
1 | .svgContainer {
2 | text-align: center;
3 | margin: 2em 0;
4 | position: relative;
5 | }
6 |
7 | .illustration {
8 | width: 100%;
9 | }
10 |
11 | .illustration path {
12 | position: relative;
13 | z-index: 0;
14 | }
15 |
16 | .muscle {
17 | }
18 | .muscleContainer {
19 | z-index: 1;
20 | cursor: pointer;
21 | }
22 |
23 | .muscle.enabled.hover {
24 | fill: #69b0ee;
25 | cursor: pointer;
26 | }
27 |
28 | .muscle.enabled.active {
29 | fill: #228be6 !important;
30 | }
31 |
32 | .muscle.enabled {
33 | fill: #bdbdbd;
34 | }
35 |
36 | .loading {
37 | fill: #757575;
38 | animation: pulse 2s linear infinite;
39 | }
40 |
41 | @keyframes pulse {
42 | 0% {
43 | opacity: 0.5;
44 | }
45 | 50% {
46 | opacity: 0.8;
47 | }
48 | 100% {
49 | opacity: 0.5;
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/app/[locale]/(app)/auth/error/page.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 |
3 | import { Card, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
4 | import { buttonVariants } from "@/components/ui/button";
5 |
6 | export default async function AuthErrorPage({ params }: { params: Promise<{ error: string }> }) {
7 | const result = await params;
8 |
9 | return (
10 |
11 |
12 |
13 | Error
14 | {result.error}
15 |
16 |
17 |
18 | Home
19 |
20 |
21 |
22 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/src/features/leaderboard/ui/leaderboard-skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { Skeleton } from "@/components/ui/skeleton";
2 |
3 | const LeaderboardSkeleton: React.FC = () => (
4 |
5 | {[...Array(5)].map((_, i) => (
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | ))}
19 |
20 | );
21 |
22 | export default LeaderboardSkeleton;
23 |
--------------------------------------------------------------------------------
/src/features/leaderboard/hooks/use-top-workout-users.ts:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useQuery } from "@tanstack/react-query";
4 |
5 | import { getTopWorkoutUsersAction, LeaderboardPeriod } from "../actions/get-top-workout-users.action";
6 |
7 | export interface UseTopWorkoutUsersOptions {
8 | refetchInterval?: number;
9 | period?: LeaderboardPeriod;
10 | }
11 |
12 | export function useTopWorkoutUsers(options: UseTopWorkoutUsersOptions = {}) {
13 | const { refetchInterval, period = "all-time" } = options;
14 |
15 | return useQuery({
16 | queryKey: ["top-workout-users", period],
17 | queryFn: async () => {
18 | const result = await getTopWorkoutUsersAction({ period });
19 | return result?.data || [];
20 | },
21 | staleTime: 5 * 60 * 1000, // 5 minutes
22 | refetchInterval,
23 | refetchOnWindowFocus: false,
24 | retry: 3,
25 | });
26 | }
27 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.*
7 | .yarn/*
8 | !.yarn/patches
9 | !.yarn/plugins
10 | !.yarn/releases
11 | !.yarn/versions
12 |
13 | # testing
14 | /coverage
15 |
16 | # next.js
17 | /.next/
18 | /_next/
19 | /out/
20 |
21 | # production
22 | /build
23 |
24 | # misc
25 | .DS_Store
26 | *.pem
27 |
28 | # debug
29 | npm-debug.log*
30 | yarn-debug.log*
31 | yarn-error.log*
32 | .pnpm-debug.log*
33 |
34 | # env files (can opt-in for committing if needed)
35 | .env
36 | .env.local
37 | .env.test
38 | .env.development
39 | .env.production
40 |
41 | # vercel
42 | .vercel
43 |
44 | # typescript
45 | *.tsbuildinfo
46 | next-env.d.ts
47 |
48 | # editors
49 | .idea
50 | .zed
51 | scripts/private/
52 | scripts/personal/
53 | product-development/
54 | .claude/
55 | .cache/
56 |
--------------------------------------------------------------------------------
/src/components/premium/RemoveAdsText.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useRouter } from "next/navigation";
4 | import { Ban } from "lucide-react";
5 |
6 | import { useI18n } from "locales/client";
7 |
8 | export function RemoveAdsText() {
9 | const router = useRouter();
10 | const t = useI18n();
11 |
12 | const handleClick = () => {
13 | router.push("/premium");
14 | };
15 |
16 | return (
17 |
21 |
22 | {t("commons.remove_ads")}
23 |
24 | );
25 | }
26 |
--------------------------------------------------------------------------------
/src/features/theme/ui/ThemeSynchronizer.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useEffect } from "react";
4 | import { useTheme } from "next-themes";
5 |
6 | /**
7 | * Synchronizes the tag with the current theme (light/dark).
8 | * Ensures the browser UI (mobile address bar, etc.) matches the user's selected theme.
9 | */
10 | export function ThemeSynchronizer() {
11 | const { resolvedTheme } = useTheme();
12 |
13 | useEffect(() => {
14 | const themeColor = resolvedTheme === "dark" ? "#18181b" : "#f3f4f6";
15 | let meta = document.querySelector("meta[name=theme-color]");
16 | if (!meta) {
17 | meta = document.createElement("meta");
18 | meta.setAttribute("name", "theme-color");
19 | document.head.appendChild(meta);
20 | }
21 | meta.setAttribute("content", themeColor);
22 | }, [resolvedTheme]);
23 |
24 | return null;
25 | }
26 |
--------------------------------------------------------------------------------
/src/features/workout-builder/ui/favorite-button.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { cn } from "@/shared/lib/utils";
4 | import { StarButton } from "@/components/ui/star-button";
5 |
6 | interface FavoriteButtonProps {
7 | exerciseId: string;
8 | isFavorite: boolean;
9 | onToggle: (exerciseId: string) => void;
10 | className?: string;
11 | }
12 |
13 | export const FavoriteButton = ({ exerciseId, isFavorite, onToggle, className = "" }: FavoriteButtonProps) => {
14 | const handleToggle = (e: React.MouseEvent) => {
15 | e.stopPropagation();
16 |
17 | try {
18 | onToggle(exerciseId);
19 | } catch (error) {
20 | console.error("Failed to toggle favorite:", error);
21 | }
22 | };
23 |
24 | return (
25 |
26 | );
27 | };
28 |
--------------------------------------------------------------------------------
/app/[locale]/(app)/layout.tsx:
--------------------------------------------------------------------------------
1 | import { ReactElement } from "react";
2 |
3 | import { Header } from "@/features/layout/Header";
4 | import { Footer } from "@/features/layout/Footer";
5 | import { BottomNavigation } from "@/features/layout/BottomNavigation";
6 |
7 | interface RootLayoutProps {
8 | params: Promise<{ locale: string }>;
9 | children: ReactElement;
10 | }
11 |
12 | export default async function RootLayout({ children }: RootLayoutProps) {
13 | return (
14 |
15 |
16 |
{children}
17 |
18 |
19 |
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/prisma/migrations_backup/20250414174246_rename_feedbacks/migration.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - You are about to drop the `Feedback` table. If the table is not empty, all the data it contains will be lost.
5 |
6 | */
7 | -- DropForeignKey
8 | ALTER TABLE "Feedback" DROP CONSTRAINT "Feedback_userId_fkey";
9 |
10 | -- DropTable
11 | DROP TABLE "Feedback";
12 |
13 | -- CreateTable
14 | CREATE TABLE "feedbacks" (
15 | "id" TEXT NOT NULL,
16 | "review" INTEGER NOT NULL,
17 | "message" TEXT NOT NULL,
18 | "email" TEXT,
19 | "userId" TEXT,
20 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
21 | "updatedAt" TIMESTAMP(3) NOT NULL,
22 |
23 | CONSTRAINT "feedbacks_pkey" PRIMARY KEY ("id")
24 | );
25 |
26 | -- AddForeignKey
27 | ALTER TABLE "feedbacks" ADD CONSTRAINT "feedbacks_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
28 |
--------------------------------------------------------------------------------
/prisma/migrations_backup/20250707114920_add_user_favorite_exercises/migration.sql:
--------------------------------------------------------------------------------
1 | -- CreateTable
2 | CREATE TABLE "user_favorite_exercises" (
3 | "id" TEXT NOT NULL,
4 | "userId" TEXT NOT NULL,
5 | "exerciseId" TEXT NOT NULL,
6 | "updatedAt" TIMESTAMP(3) NOT NULL,
7 |
8 | CONSTRAINT "user_favorite_exercises_pkey" PRIMARY KEY ("id")
9 | );
10 |
11 | -- CreateIndex
12 | CREATE UNIQUE INDEX "user_favorite_exercises_userId_exerciseId_key" ON "user_favorite_exercises"("userId", "exerciseId");
13 |
14 | -- AddForeignKey
15 | ALTER TABLE "user_favorite_exercises" ADD CONSTRAINT "user_favorite_exercises_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
16 |
17 | -- AddForeignKey
18 | ALTER TABLE "user_favorite_exercises" ADD CONSTRAINT "user_favorite_exercises_exerciseId_fkey" FOREIGN KEY ("exerciseId") REFERENCES "exercises"("id") ON DELETE CASCADE ON UPDATE CASCADE;
19 |
--------------------------------------------------------------------------------
/src/entities/user/lib/display-name.ts:
--------------------------------------------------------------------------------
1 | import { SessionUser } from "@/entities/user/types/session-user";
2 |
3 | export function displayName(user: SessionUser): string {
4 | return user.name
5 | ? user.name
6 | : user.email
7 | .split("@")[0]
8 | .replaceAll(".", " ")
9 | .replace(/^\w/, (c) => c.toUpperCase());
10 | }
11 |
12 | export function displayFullName({ firstName, lastName }: { firstName: string; lastName: string }): string {
13 | return `${firstName} ${lastName}`;
14 | }
15 |
16 | export function displayFirstNameAndFirstLetterLastName(user: SessionUser): string {
17 | return `${user.firstName} ${user.lastName?.charAt(0)}.`;
18 | }
19 |
20 | export const displayFirstName = (user: SessionUser): string => {
21 | return user.firstName;
22 | };
23 |
24 | export const displayInitials = (user: SessionUser): string => {
25 | return user.firstName.charAt(0) + user.lastName.charAt(0);
26 | };
27 |
--------------------------------------------------------------------------------
/prisma/migrations_backup/20250117000000_add_statistics_indexes/migration.sql:
--------------------------------------------------------------------------------
1 | -- CreateIndex for exercise statistics queries
2 | -- Index for workout_session_exercises by exerciseId and session date
3 | CREATE INDEX "workout_session_exercises_exerciseId_idx" ON "workout_session_exercises"("exerciseId");
4 |
5 | -- Index for workout_sessions by userId and createdAt
6 | CREATE INDEX "workout_sessions_userId_createdAt_idx" ON "workout_sessions"("userId", "createdAt");
7 |
8 | -- Index for workout_sets by completed status and types array
9 | CREATE INDEX "workout_sets_completed_idx" ON "workout_sets"("completed");
10 |
11 | -- Composite index for efficient statistics queries
12 | CREATE INDEX "workout_sessions_userId_createdAt_desc_idx" ON "workout_sessions"("userId", "createdAt" DESC);
13 |
14 | -- Index for workoutSessionExerciseId lookups
15 | CREATE INDEX "workout_sets_workoutSessionExerciseId_idx" ON "workout_sets"("workoutSessionExerciseId");
--------------------------------------------------------------------------------
/src/features/leaderboard/hooks/use-user-position.ts:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useQuery } from "@tanstack/react-query";
4 |
5 | import { getUserPositionAction } from "../actions/get-user-position.action";
6 | import { LeaderboardPeriod } from "../actions/get-top-workout-users.action";
7 |
8 | interface UseUserPositionOptions {
9 | userId: string | undefined;
10 | period: LeaderboardPeriod;
11 | enabled?: boolean;
12 | }
13 |
14 | export function useUserPosition({ userId, period, enabled = true }: UseUserPositionOptions) {
15 | return useQuery({
16 | queryKey: ["user-position", userId, period],
17 | queryFn: async () => {
18 | if (!userId) return null;
19 | const result = await getUserPositionAction({ userId, period });
20 | return result?.data || null;
21 | },
22 | enabled: enabled && !!userId,
23 | staleTime: 5 * 60 * 1000, // 5 minutes
24 | refetchOnWindowFocus: false,
25 | });
26 | }
27 |
--------------------------------------------------------------------------------
/src/features/contact/support/contact-support.action.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import ContactSupportEmail from "@emails/ContactSupportEmail";
4 | import { sendEmail } from "@/shared/lib/mail/sendEmail";
5 | import { SiteConfig } from "@/shared/config/site-config";
6 | import { actionClient } from "@/shared/api/safe-actions";
7 |
8 | import { ContactSupportSchema } from "./contact-support.schema";
9 |
10 | export const contactSupportAction = actionClient.schema(ContactSupportSchema).action(async ({ parsedInput }) => {
11 | await sendEmail({
12 | from: SiteConfig.email.from,
13 | to: SiteConfig.email.contact,
14 | subject: `Support needed from ${parsedInput.email} - ${parsedInput.subject}`,
15 | text: parsedInput.message,
16 | react: ContactSupportEmail({ email: parsedInput.email, message: parsedInput.message, subject: parsedInput.subject }),
17 | });
18 | return { message: "Your message has been sent to support." };
19 | });
20 |
--------------------------------------------------------------------------------
/src/features/theme/ThemeToggle.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useTheme } from "next-themes";
4 | import { MoonIcon, SunIcon } from "lucide-react";
5 |
6 | import { Button } from "@/components/ui/button";
7 |
8 | export function ThemeToggle() {
9 | const { setTheme, resolvedTheme } = useTheme();
10 |
11 | return (
12 |
13 | setTheme(resolvedTheme === "light" ? "dark" : "light")}
16 | variant="ghost"
17 | >
18 | {resolvedTheme === "light" ? (
19 |
20 | ) : (
21 |
22 | )}
23 |
24 |
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/src/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { cn } from "@/shared/lib/utils";
4 |
5 | export interface TextareaProps extends React.TextareaHTMLAttributes {}
6 |
7 | const Textarea = React.forwardRef(({ className, ...props }, ref) => {
8 | return (
9 |
17 | );
18 | });
19 | Textarea.displayName = "Textarea";
20 |
21 | export { Textarea };
22 |
--------------------------------------------------------------------------------
/app/api/programs/[slug]/sessions/[sessionSlug]/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from "next/server";
2 |
3 | import { getSessionBySlug } from "@/features/programs/actions/get-session-by-slug.action";
4 |
5 | export async function GET(request: Request, { params }: { params: Promise<{ slug: string; sessionSlug: string }> }) {
6 | try {
7 | const { searchParams } = new URL(request.url);
8 | const locale = searchParams.get("locale") || "fr";
9 |
10 | const { slug, sessionSlug } = await params;
11 | const sessionDetail = await getSessionBySlug(slug, sessionSlug, locale as any);
12 |
13 | if (!sessionDetail) {
14 | return NextResponse.json({ error: "Session not found" }, { status: 404 });
15 | }
16 |
17 | return NextResponse.json(sessionDetail);
18 | } catch (error) {
19 | console.error("Error fetching session:", error);
20 | return NextResponse.json({ error: "Failed to fetch session" }, { status: 500 });
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/src/features/workout-builder/actions/pick-exercise.action.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { z } from "zod";
4 |
5 | import { actionClient } from "@/shared/api/safe-actions";
6 |
7 | const pickExerciseSchema = z.object({
8 | exerciseId: z.string(),
9 | });
10 |
11 | export const pickExerciseAction = actionClient.schema(pickExerciseSchema).action(async ({ parsedInput }) => {
12 | try {
13 | const { exerciseId } = parsedInput;
14 |
15 | // Pour l'instant, on retourne juste l'ID de l'exercice
16 | // Plus tard, on pourra ajouter de la logique pour marquer l'exercice comme "picked"
17 | // dans une base de données ou un système de préférences utilisateur
18 |
19 | return {
20 | success: true,
21 | exerciseId,
22 | message: "Exercise picked successfully",
23 | };
24 | } catch (error) {
25 | console.error("Error picking exercise:", error);
26 | return { serverError: "Failed to pick exercise" };
27 | }
28 | });
29 |
--------------------------------------------------------------------------------
/src/components/svg/LogoSvg.tsx:
--------------------------------------------------------------------------------
1 | import type { ComponentPropsWithoutRef } from "react";
2 |
3 | export type LogoSvgProps = ComponentPropsWithoutRef<"svg"> & { size?: number };
4 |
5 | export const LogoSvg = ({ size = 32, ...props }: LogoSvgProps) => {
6 | return (
7 |
8 | {/* Disque gauche */}
9 |
10 |
11 | {/* Barre centrale */}
12 |
13 |
14 | {/* Disque droit */}
15 |
16 |
17 | {/* Poignée centrale (optionnel pour plus de détail) */}
18 |
19 |
20 | );
21 | };
22 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2017",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "baseUrl": ".",
17 | "paths": {
18 | "@/*": ["./src/*"],
19 | "@/workoutcool/*": ["./src/*"],
20 | "@emails/*": ["emails/*"],
21 | "@public/*": ["public/*"]
22 | },
23 | "plugins": [
24 | {
25 | "name": "next"
26 | }
27 | ]
28 | },
29 | "exclude": ["node_modules", "src/utils/inapp.js", "src/utils/externalLinkOpener.js", "src/utils/browserEscape.js"],
30 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "tailwind.config.ts"]
31 | }
32 |
--------------------------------------------------------------------------------
/src/features/auth/ui/LoggedInButton.tsx:
--------------------------------------------------------------------------------
1 | import { UserDropdown } from "@/features/user/ui/UserDropdown";
2 | import { SessionUser } from "@/entities/user/types/session-user";
3 | import { displayName } from "@/entities/user/lib/display-name";
4 | import { Button } from "@/components/ui/button";
5 | import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
6 |
7 | export const LoggedInButton = ({ user, showName = true }: { user: SessionUser; showName?: boolean }) => {
8 | return (
9 |
10 |
11 |
12 | {user.email.slice(0, 1).toUpperCase()}
13 | {user.image && }
14 |
15 | {showName && {displayName(user)} }
16 |
17 |
18 | );
19 | };
20 |
--------------------------------------------------------------------------------
/src/entities/user/model/update-user-locale.ts:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useMutation } from "@tanstack/react-query";
4 |
5 | import { useCurrentUser } from "@/entities/user/model/useCurrentUser";
6 | import { updateUserAction } from "@/entities/user/model/update-user.action";
7 |
8 | interface UpdateUserLocaleParams {
9 | locale: string;
10 | }
11 |
12 | export function useUpdateUserLocale() {
13 | const user = useCurrentUser();
14 |
15 | return useMutation({
16 | mutationFn: async ({ locale }: UpdateUserLocaleParams) => {
17 | if (!user) {
18 | return;
19 | }
20 |
21 | const result = await updateUserAction({ locale });
22 |
23 | if (!result?.data?.success) {
24 | throw new Error("Failed to update user locale");
25 | }
26 |
27 | return result.data;
28 | },
29 | onError: (error) => {
30 | console.error("Failed to update user locale:", error);
31 | // silent fail, ux friendly
32 | },
33 | });
34 | }
35 |
--------------------------------------------------------------------------------
/src/features/email/email.action.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { actionClient } from "@/shared/api/safe-actions";
4 |
5 | import { EmailActionSchema } from "./email.schema";
6 |
7 | // export const addEmailAction = action(EmailActionSchema, async ({ email }) => {
8 | // try {
9 | // const userData = {
10 | // email,
11 | // };
12 |
13 | // const stripeCustomerId = await setupStripeCustomer(userData);
14 | // const resendContactId = await setupResendCustomer(userData);
15 |
16 | // await prisma.user.create({
17 | // data: {
18 | // ...userData,
19 | // stripeCustomerId,
20 | // resendContactId,
21 | // },
22 | // });
23 |
24 | // return { email };
25 | // } catch (error) {
26 | // throw new ActionError("The email is already in use");
27 | // }
28 | // });
29 |
30 | export const addEmailAction = actionClient.schema(EmailActionSchema).action(async ({ parsedInput: { email } }) => {
31 | return { email };
32 | });
33 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ""
5 | labels: ""
6 | assignees: ""
7 | ---
8 |
9 | **Describe the bug** A clear and concise description of what the bug is.
10 |
11 | **To Reproduce** Steps to reproduce the behavior:
12 |
13 | 1. Go to '...'
14 | 2. Click on '....'
15 | 3. Scroll down to '....'
16 | 4. See error
17 |
18 | **Expected behavior** A clear and concise description of what you expected to happen.
19 |
20 | **Screenshots** If applicable, add screenshots to help explain your problem.
21 |
22 | **Desktop (please complete the following information):**
23 |
24 | - OS: [e.g. iOS]
25 | - Browser [e.g. chrome, safari]
26 | - Version [e.g. 22]
27 |
28 | **Smartphone (please complete the following information):**
29 |
30 | - Device: [e.g. iPhone6]
31 | - OS: [e.g. iOS8.1]
32 | - Browser [e.g. stock browser, safari]
33 | - Version [e.g. 22]
34 |
35 | **Additional context** Add any other context about the problem here.
36 |
--------------------------------------------------------------------------------
/src/shared/lib/mail/sendEmail.ts:
--------------------------------------------------------------------------------
1 | import nodemailer from "nodemailer";
2 | import { render } from "@react-email/components";
3 |
4 | import { env } from "@/env";
5 |
6 | type EmailPayload = {
7 | from?: string;
8 | to: string;
9 | subject: string;
10 | text: string;
11 | react?: React.ReactElement;
12 | };
13 |
14 | const transporter = nodemailer.createTransport({
15 | host: env.SMTP_HOST,
16 | port: env.SMTP_PORT,
17 | secure: env.SMTP_SECURE,
18 | auth:
19 | env.SMTP_USER && env.SMTP_PASS
20 | ? {
21 | user: env.SMTP_USER,
22 | pass: env.SMTP_PASS,
23 | }
24 | : undefined,
25 | });
26 |
27 | export const sendEmail = async ({ from, to, subject, text, react }: EmailPayload) => {
28 | try {
29 | return transporter.sendMail({
30 | from: from ?? env.SMTP_FROM,
31 | to,
32 | subject,
33 | text,
34 | html: react ? await render(react) : undefined,
35 | });
36 | } catch (error) {
37 | console.error(error);
38 | }
39 | };
40 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:20-alpine AS base
2 |
3 | WORKDIR /app
4 | RUN npm install -g pnpm
5 |
6 | # Install dependencies
7 | FROM base AS deps
8 | COPY package.json pnpm-lock.yaml ./
9 | COPY prisma ./prisma
10 | RUN pnpm install --frozen-lockfile
11 |
12 | # Build the app
13 | FROM base AS builder
14 | COPY --from=deps /app/node_modules ./node_modules
15 | COPY --from=deps /app/prisma ./prisma
16 | COPY . .
17 | COPY .env.example .env
18 |
19 | RUN pnpm run build
20 |
21 | # Production image, copy only necessary files
22 | FROM base AS runner
23 | WORKDIR /app
24 |
25 | COPY --from=builder /app/public ./public
26 | COPY --from=builder /app/.next ./.next
27 | COPY --from=builder /app/node_modules ./node_modules
28 | COPY --from=builder /app/package.json ./package.json
29 | COPY --from=builder /app/prisma ./prisma
30 | COPY --from=builder /app/data ./data
31 |
32 | COPY scripts /app/scripts
33 | RUN chmod +x /app/scripts/setup.sh
34 |
35 | ENTRYPOINT ["/app/scripts/setup.sh"]
36 |
37 | EXPOSE 3000
38 | ENV PORT=3000
39 |
40 | CMD ["pnpm", "start"]
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | .PHONY: dev setup up down help
2 |
3 | help:
4 | @echo "🚀 Workout Cool Development Commands"
5 | @echo ""
6 | @echo " dev - Start development server (automatically sets up everything)"
7 | @echo " setup - One-time setup: database, schema, and sample data"
8 | @echo " db - Start PostgreSQL database only"
9 | @echo " down - Stop all services"
10 | @echo ""
11 |
12 |
13 | db:
14 | @echo "🐘 Starting PostgreSQL database..."
15 | docker compose up -d postgres
16 |
17 |
18 | setup: db
19 | @echo "📦 Installing dependencies..."
20 | pnpm install --frozen-lockfile
21 | @echo "🔄 Applying database migrations..."
22 | npx prisma migrate deploy
23 | npx prisma generate
24 | @echo "🌱 Seeding database with sample data..."
25 | pnpm run import:exercises-full ./data/sample-exercises.csv
26 | pnpm run db:seed-leaderboard
27 | @echo "✅ Setup complete!"
28 |
29 |
30 | dev: setup
31 | @echo "🚀 Starting Next.js development server..."
32 | pnpm dev
33 |
34 | down:
35 | @echo "🛑 Stopping all services..."
36 | docker compose down
37 |
--------------------------------------------------------------------------------
/prisma/migrations_backup/20250623144324_add_webhook_events/migration.sql:
--------------------------------------------------------------------------------
1 | -- CreateTable
2 | CREATE TABLE "webhook_events" (
3 | "id" TEXT NOT NULL,
4 | "provider" "PaymentProcessor" NOT NULL,
5 | "eventType" TEXT NOT NULL,
6 | "payload" JSONB NOT NULL,
7 | "headers" JSONB,
8 | "processed" BOOLEAN NOT NULL DEFAULT false,
9 | "processedAt" TIMESTAMP(3),
10 | "retryCount" INTEGER NOT NULL DEFAULT 0,
11 | "maxRetries" INTEGER NOT NULL DEFAULT 3,
12 | "error" TEXT,
13 | "resultingAction" TEXT,
14 | "relatedUserId" TEXT,
15 | "relatedPaymentId" TEXT,
16 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
17 |
18 | CONSTRAINT "webhook_events_pkey" PRIMARY KEY ("id")
19 | );
20 |
21 | -- CreateIndex
22 | CREATE INDEX "webhook_events_provider_processed_idx" ON "webhook_events"("provider", "processed");
23 |
24 | -- CreateIndex
25 | CREATE INDEX "webhook_events_relatedUserId_idx" ON "webhook_events"("relatedUserId");
26 |
27 | -- CreateIndex
28 | CREATE INDEX "webhook_events_createdAt_idx" ON "webhook_events"("createdAt");
29 |
--------------------------------------------------------------------------------
/src/features/auth/signin/model/useSignIn.ts:
--------------------------------------------------------------------------------
1 | import { useSearchParams } from "next/navigation";
2 |
3 | import { useI18n } from "locales/client";
4 | import { paths } from "@/shared/constants/paths";
5 | import { LoginSchema } from "@/features/auth/signin/schema/signin.schema";
6 | import { authClient } from "@/features/auth/lib/auth-client";
7 | import { brandedToast } from "@/components/ui/toast";
8 |
9 | export const useSignIn = () => {
10 | const t = useI18n();
11 | const searchParams = useSearchParams();
12 |
13 | const signIn = async (values: LoginSchema) => {
14 | const redirectUrl = searchParams.get("redirect");
15 | const callbackURL = redirectUrl || `${paths.root}?signin=true`;
16 |
17 | const response = await authClient.signIn.email({
18 | email: values.email,
19 | password: values.password,
20 | callbackURL,
21 | });
22 |
23 | if (response?.error) {
24 | brandedToast({ title: t("error.invalid_credentials"), variant: "error" });
25 | return;
26 | }
27 | };
28 |
29 | return {
30 | signIn,
31 | };
32 | };
33 |
--------------------------------------------------------------------------------
/src/features/workout-builder/actions/get-favorite-exercises.action.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { prisma } from "@/shared/lib/prisma";
4 | import { authenticatedActionClient } from "@/shared/api/safe-actions";
5 |
6 | export const getFavoriteExercises = authenticatedActionClient.action(async ({ ctx }) => {
7 | const { user } = ctx;
8 |
9 | try {
10 | const favorites = await prisma.userFavoriteExercise.findMany({
11 | where: {
12 | userId: user.id,
13 | },
14 | select: {
15 | exerciseId: true,
16 | updatedAt: true,
17 | },
18 | orderBy: {
19 | updatedAt: "desc",
20 | },
21 | });
22 |
23 | return {
24 | success: true,
25 | favorites: favorites.map((f) => ({
26 | exerciseId: f.exerciseId,
27 | updatedAt: f.updatedAt.toISOString(),
28 | })),
29 | };
30 | } catch (error) {
31 | console.error("Error getting favorite exercises:", error);
32 | throw new Error(`Failed to get favorites: ${error instanceof Error ? error.message : "Unknown error"}`);
33 | }
34 | });
35 |
--------------------------------------------------------------------------------
/src/shared/constants/statistics.ts:
--------------------------------------------------------------------------------
1 | export const STATISTICS_TIMEFRAMES = {
2 | FOUR_WEEKS: "4weeks",
3 | EIGHT_WEEKS: "8weeks",
4 | TWELVE_WEEKS: "12weeks",
5 | ONE_YEAR: "1year",
6 | } as const;
7 |
8 | export type StatisticsTimeframe = typeof STATISTICS_TIMEFRAMES[keyof typeof STATISTICS_TIMEFRAMES];
9 |
10 | export const DEFAULT_TIMEFRAME = STATISTICS_TIMEFRAMES.EIGHT_WEEKS;
11 |
12 | export const TIMEFRAME_DAYS = {
13 | [STATISTICS_TIMEFRAMES.FOUR_WEEKS]: 28,
14 | [STATISTICS_TIMEFRAMES.EIGHT_WEEKS]: 56,
15 | [STATISTICS_TIMEFRAMES.TWELVE_WEEKS]: 84,
16 | [STATISTICS_TIMEFRAMES.ONE_YEAR]: 365,
17 | } as const;
18 |
19 | export const TIMEFRAME_LABELS = {
20 | [STATISTICS_TIMEFRAMES.FOUR_WEEKS]: "4 Weeks",
21 | [STATISTICS_TIMEFRAMES.EIGHT_WEEKS]: "8 Weeks",
22 | [STATISTICS_TIMEFRAMES.TWELVE_WEEKS]: "12 Weeks",
23 | [STATISTICS_TIMEFRAMES.ONE_YEAR]: "1 Year",
24 | } as const;
25 |
26 | // Cache TTL for statistics data (1 hour in seconds)
27 | export const STATISTICS_CACHE_TTL = 3600;
28 |
29 | // Lombardi formula constant
30 | export const LOMBARDI_DIVISOR = 30;
--------------------------------------------------------------------------------
/app/[locale]/(app)/programs/page.tsx:
--------------------------------------------------------------------------------
1 | import { Metadata } from "next";
2 |
3 | import { Locale } from "locales/types";
4 | import { getI18n } from "locales/server";
5 | import { ProgramsPage } from "@/features/programs/ui/programs-page";
6 | import { Breadcrumbs } from "@/components/seo/breadcrumbs";
7 |
8 | export const metadata: Metadata = {
9 | title: "Programmes",
10 | description: "Découvrez nos programmes d'entraînement gamifiés pour tous les niveaux - Rejoins la communauté WorkoutCool !",
11 | };
12 |
13 | export default async function ProgramsRootPage({ params }: { params: Promise<{ locale: string }> }) {
14 | const { locale } = (await params) as { locale: Locale };
15 | const t = await getI18n();
16 |
17 | const breadcrumbItems = [
18 | {
19 | label: t("breadcrumbs.home"),
20 | href: `/${locale}`,
21 | },
22 | {
23 | label: t("programs.workout_programs"),
24 | current: true,
25 | },
26 | ];
27 |
28 | return (
29 | <>
30 |
31 |
32 | >
33 | );
34 | }
35 |
--------------------------------------------------------------------------------
/src/shared/lib/analytics/client.tsx:
--------------------------------------------------------------------------------
1 | import { OpenPanelComponent, type PostEventPayload, useOpenPanel } from "@openpanel/nextjs";
2 |
3 | import { env } from "@/env";
4 |
5 | const isProd = process.env.NODE_ENV === "production";
6 |
7 | const AnalyticsProvider = function () {
8 | if (!env.NEXT_PUBLIC_OPENPANEL_CLIENT_ID) {
9 | return null;
10 | }
11 |
12 | return (
13 |
19 | );
20 | };
21 |
22 | const track = (options: { event: string } & PostEventPayload["properties"]) => {
23 | if (!env.NEXT_PUBLIC_OPENPANEL_CLIENT_ID) {
24 | return;
25 | }
26 |
27 | if (!isProd) {
28 | console.log("Track", options);
29 | return;
30 | }
31 |
32 | // eslint-disable-next-line react-hooks/rules-of-hooks
33 | const { track: openTrack } = useOpenPanel();
34 |
35 | const { event, ...rest } = options;
36 |
37 | openTrack(event, rest);
38 | };
39 |
40 | export { AnalyticsProvider, track };
41 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Mathias Bradiceanu
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the
6 | "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish,
7 | 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
8 | following conditions:
9 |
10 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
11 |
12 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
13 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
14 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
15 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
16 |
--------------------------------------------------------------------------------
/src/components/ui/phone-frame-preview.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | import { cn } from "@/shared/lib/utils";
4 |
5 | interface PhoneFramePreviewProps extends React.HTMLAttributes {
6 | children: React.ReactNode;
7 | }
8 |
9 | export function PhoneFramePreview({ children, className, ...props }: PhoneFramePreviewProps) {
10 | return (
11 |
18 | {/* Notch */}
19 |
20 | {/* Screen Content */}
21 |
22 | {children}
23 | {/* Home Indicator Bar */}
24 |
25 |
26 |
27 | );
28 | }
29 |
--------------------------------------------------------------------------------
/app/[locale]/(app)/leaderboard/page.tsx:
--------------------------------------------------------------------------------
1 | import { Metadata } from "next";
2 |
3 | import { Locale } from "locales/types";
4 | import { getI18n } from "locales/server";
5 | import LeaderboardPage from "@/features/leaderboard/ui/leaderboard-page";
6 | import { Breadcrumbs } from "@/components/seo/breadcrumbs";
7 |
8 | export const metadata: Metadata = {
9 | title: "🏆 Workout Streak Leaderboard",
10 | description: "See who's dominating their fitness journey with the longest workout streaks! Join the leaderboard and track your progress.",
11 | };
12 |
13 | export default async function LeaderboardRootPage({ params }: { params: Promise<{ locale: string }> }) {
14 | const { locale } = (await params) as { locale: Locale };
15 | const t = await getI18n();
16 |
17 | const breadcrumbItems = [
18 | {
19 | label: t("breadcrumbs.home"),
20 | href: `/${locale}`,
21 | },
22 | {
23 | label: t("bottom_navigation.leaderboard"),
24 | current: true,
25 | },
26 | ];
27 |
28 | return (
29 | <>
30 |
31 |
32 | >
33 | );
34 | }
35 |
--------------------------------------------------------------------------------
/src/shared/lib/i18n-mapper.ts:
--------------------------------------------------------------------------------
1 | import { Locale } from "locales/types";
2 | import { getLocaleSuffix } from "@/shared/types/i18n.types";
3 |
4 | /**
5 | * Generic mapper for i18n fields
6 | * Gets the correct field value based on locale
7 | */
8 | export function getI18nField>(
9 | obj: T | null | undefined,
10 | fieldName: string,
11 | locale: Locale,
12 | defaultValue: string = ""
13 | ): string {
14 | if (!obj) return defaultValue;
15 |
16 | const suffix = getLocaleSuffix(locale);
17 | const fieldKey = suffix ? `${fieldName}${suffix}` : fieldName;
18 |
19 | return (obj[fieldKey] as string) || defaultValue;
20 | }
21 |
22 | /**
23 | * Maps common i18n fields for an object
24 | */
25 | export function mapI18nFields>(
26 | obj: T | null | undefined,
27 | locale: Locale
28 | ) {
29 | if (!obj) return null;
30 |
31 | return {
32 | title: getI18nField(obj, "title", locale),
33 | description: getI18nField(obj, "description", locale),
34 | slug: getI18nField(obj, "slug", locale),
35 | name: getI18nField(obj, "name", locale),
36 | };
37 | }
--------------------------------------------------------------------------------
/content/about/zh-CN.mdx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 |
3 | # 关于 Workout.cool
4 |
5 | ## 为什么选择 Workout.cool?
6 |
7 | Workout.cool 诞生于在原项目 被放弃后,为用户提供一个可靠、现代化且持续维护的健身平台的愿望。
8 |
9 | ## 我们的故事
10 |
11 | Workout.cool 是社区驱动的成果。
12 |
13 | 我是 项目的**第一位开源贡献者**。
14 |
15 | 这意味着我见证了这个项目的*诞生*、*成长*,然后被**出售**,最终被新所有者**抛弃**。
16 |
17 | 和许多用户一样,我感到**深深的沮丧**和*被抛弃的感觉*,看着一个我贡献了这么多的工具消失,功能请求得不到回应并逐渐过时。
18 |
19 | ---
20 |
21 | *几个月来*,我试图联系新所有者——尽管尝试了很多次(*大约15次*),但**从未收到过任何回复**。
22 |
23 | 面对这种**沉默**和**社区的困境**,我决定**自己动手**:
24 |
25 | > 与其让所有这些工作消失,**我重新启动了一个更加雄心勃勃、现代化、对所有人开放的项目。**
26 |
27 | 这个项目不是由利润驱动,而是由**激情**和为开源健身社区服务的愿望驱动。
28 |
29 | **必须有人来拯救这个社区——_我决定成为那个人!_**
30 |
31 | ## 开源与社区
32 |
33 | Workout.cool 是开源的,确保透明度、模块化和可扩展性。
34 | 欢迎所有人贡献——代码、文档或想法!
35 |
36 | - [在 GitHub 上查看项目](https://github.com/Snouzy/workout-cool)
37 | - [买杯咖啡支持我们](https://ko-fi.com/workoutcool)
38 |
39 | ## 我们的合作伙伴
40 |
41 | 我们很自豪能与 [Fit'Distance](https://fitdistance.io) 合作,感谢他们让我们所有的运动视频都免费且完整。他们的支持使我们能够为整个社区提供高质量的健身内容。
42 |
43 | ## 加入我们的使命!
44 |
45 | 想要贡献、建议功能或仅仅支持项目?
46 | 联系我们或在 GitHub 上创建 issue!
47 |
48 | **[hello@workout.cool](mailto:hello@workout.cool)**
--------------------------------------------------------------------------------
/src/features/workout-builder/hooks/use-exercises.ts:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useQuery } from "@tanstack/react-query";
4 | import { ExerciseAttributeValueEnum } from "@prisma/client";
5 |
6 | import { getExercisesAction } from "../actions/get-exercises.action";
7 |
8 | interface UseExercisesProps {
9 | equipment: ExerciseAttributeValueEnum[];
10 | muscles: ExerciseAttributeValueEnum[];
11 | enabled?: boolean;
12 | }
13 |
14 | export function useExercises({ equipment, muscles, enabled = true }: UseExercisesProps) {
15 | return useQuery({
16 | queryKey: ["exercises", equipment.sort(), muscles.sort()],
17 | queryFn: async () => {
18 | if (equipment.length === 0 || muscles.length === 0) {
19 | return [];
20 | }
21 |
22 | const result = await getExercisesAction({
23 | equipment,
24 | muscles,
25 | limit: 3,
26 | });
27 |
28 | if (result?.serverError) {
29 | throw new Error(result.serverError);
30 | }
31 |
32 | return result?.data || [];
33 | },
34 | enabled: enabled && equipment.length > 0 && muscles.length > 0,
35 | staleTime: 5 * 60 * 1000, // 5 minutes
36 | });
37 | }
38 |
--------------------------------------------------------------------------------
/app/api/webhooks/stripe/route.ts:
--------------------------------------------------------------------------------
1 | import { NextRequest, NextResponse } from "next/server";
2 |
3 | import { PremiumManager } from "@/shared/lib/premium/premium.manager";
4 |
5 | /**
6 | * POST /api/webhooks/stripe
7 | *
8 | * Handle Stripe webhooks - New Premium System Only
9 | * Simple, clean implementation without legacy fallbacks
10 | */
11 | export async function POST(request: NextRequest) {
12 | try {
13 | const body = await request.text();
14 | const signature = request.headers.get("stripe-signature");
15 |
16 | if (!signature) {
17 | return NextResponse.json({ error: "Missing signature" }, { status: 401 });
18 | }
19 |
20 | const result = await PremiumManager.processWebhook("stripe", body, signature);
21 |
22 | if (result.success) {
23 | return NextResponse.json({ received: true });
24 | } else {
25 | console.error("Webhook processing failed:", result.error);
26 | return NextResponse.json({ error: result.error }, { status: 400 });
27 | }
28 | } catch (error) {
29 | console.error("Stripe webhook error:", error);
30 | return NextResponse.json({ error: "Webhook handler failed" }, { status: 400 });
31 | }
32 | }
--------------------------------------------------------------------------------
/src/components/ads/AdWrapper.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { ReactNode } from "react";
4 |
5 | import { useUserSubscription } from "@/features/ads/hooks/useUserSubscription";
6 | import { env } from "@/env";
7 |
8 | interface AdWrapperProps {
9 | children: ReactNode;
10 | fallback?: ReactNode;
11 | forceShow?: boolean;
12 | }
13 |
14 | export function AdWrapper({ children, fallback = null, forceShow = false }: AdWrapperProps) {
15 | const { isPremium, isPending } = useUserSubscription();
16 |
17 | if (!env.NEXT_PUBLIC_SHOW_ADS) {
18 | return null;
19 | }
20 |
21 | // Show ads in development to preview layout
22 | if (process.env.NODE_ENV === "development") {
23 | return <>{children}>;
24 | }
25 |
26 | // Force show ads in development if forceShow is true
27 | if (forceShow) {
28 | return <>{children}>;
29 | }
30 |
31 | // Don't show ads while loading to prevent layout shift
32 | if (isPending) {
33 | return <>{fallback}>;
34 | }
35 |
36 | // TODO: don't show ads to self hosted users
37 | // Don't show ads to premium users
38 | if (isPremium) {
39 | return <>{fallback}>;
40 | }
41 |
42 | return <>{children}>;
43 | }
44 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | ### ✅ Review & Contribution Flow
2 |
3 | - Before starting, **create an issue** for the task you want to work on.
4 | - **Assign yourself** to the issue so it’s clear who’s working on it.
5 | - Keep PRs focused: one issue = one PR (preferably small and scoped).
6 | - All PRs need **at least one maintainer review**.
7 | - We use **"Squash and merge"** to keep history clean.
8 | - Address review comments quickly and respectfully.
9 |
10 | ---
11 |
12 | ### 🤔 Need Help?
13 |
14 | - **General questions** → use GitHub Discussions
15 | - **Bug reports or features** → open an Issue
16 | - **Live chat** → [Join our Discord](https://discord.gg/NtrsUBuHUB)
17 |
18 | ---
19 |
20 | ### 📚 Useful Links
21 |
22 | - [Feature-Sliced Design](https://feature-sliced.design/)
23 | - [Next.js Docs](https://nextjs.org/docs)
24 | - [Prisma Docs](https://www.prisma.io/docs/)
25 |
26 | ---
27 |
28 | ### 🌟 Recognition
29 |
30 | We credit contributors in:
31 |
32 | - the GitHub contributors list
33 | - release notes (for impactful work)
34 | - internal documentation if relevant
35 |
36 | Thanks again for contributing to Workout Cool! 💪
37 |
38 | Questions? Just open an issue or ping a maintainer.
39 |
--------------------------------------------------------------------------------
/emails/DeleteAccountEmail.tsx:
--------------------------------------------------------------------------------
1 | import { Link, Section, Text } from "@react-email/components";
2 |
3 | import { SiteConfig } from "@/shared/config/site-config";
4 |
5 | import { BaseEmailLayout } from "./utils/BaseEmailLayout";
6 |
7 | export default function DeleteAccountEmail({ email }: { email: string }) {
8 | return (
9 |
10 |
11 | Hello,
12 |
13 | You account with email{" "}
14 |
15 | {email}
16 | {" "}
17 | has been deleted.
18 |
19 | This action is irreversible.
20 | If you have any questions, please contact us at {SiteConfig.email.contact}.
21 |
22 |
23 | Best,
24 | - {SiteConfig.maker.name} from {SiteConfig.title}
25 |
26 |
27 | );
28 | }
29 |
--------------------------------------------------------------------------------
/src/features/form/SubmitButton.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useFormStatus } from "react-dom";
4 |
5 | import { Loader } from "@/components/ui/loader";
6 | import { Button } from "@/components/ui/button";
7 |
8 | import type { ComponentPropsWithoutRef } from "react";
9 | import type { ButtonProps } from "@/components/ui/button";
10 |
11 | export const SubmitButton = (props: ButtonProps) => {
12 | const { pending } = useFormStatus();
13 |
14 | return (
15 |
16 | {props.children}
17 |
18 | );
19 | };
20 |
21 | export const LoadingButton = ({ loading, ...props }: ButtonProps & { loading?: boolean }) => {
22 | return (
23 |
24 | {loading ? (
25 | <>
26 | {props.children}
27 | >
28 | ) : (
29 | props.children
30 | )}
31 |
32 | );
33 | };
34 |
35 | export const SubmitButtonUnstyled = (props: ComponentPropsWithoutRef<"button">) => {
36 | const { pending } = useFormStatus();
37 |
38 | return ;
39 | };
40 |
--------------------------------------------------------------------------------
/app/api/premium/checkout/route.ts:
--------------------------------------------------------------------------------
1 | import { NextRequest, NextResponse } from "next/server";
2 |
3 | import { PremiumManager } from "@/shared/lib/premium/premium.manager";
4 | import { serverRequiredUser } from "@/entities/user/model/get-server-session-user";
5 |
6 | /**
7 | * POST /api/premium/checkout
8 | *
9 | * Create checkout session for premium subscription
10 | * Body: { planId: string, provider?: string }
11 | */
12 | export async function POST(request: NextRequest) {
13 | try {
14 | const user = await serverRequiredUser();
15 |
16 | const { planId, provider = "stripe" } = await request.json();
17 |
18 | if (!planId) {
19 | return NextResponse.json({ success: false, error: "Plan ID is required" }, { status: 400 });
20 | }
21 |
22 | const result = await PremiumManager.createCheckout(user.id, planId, provider);
23 |
24 | return NextResponse.json(result);
25 | } catch (error) {
26 | console.error("Checkout creation error:", error);
27 |
28 | return NextResponse.json(
29 | {
30 | success: false,
31 | error: "Failed to create checkout session",
32 | provider: "stripe",
33 | },
34 | { status: 500 },
35 | );
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/app/[locale]/(app)/auth/(auth-layout)/signup/page.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 |
3 | import { getI18n } from "locales/server";
4 | import { paths } from "@/shared/constants/paths";
5 | import { SignUpForm } from "@/features/auth/signup/ui/signup-form";
6 |
7 | export const metadata = {
8 | title: "Sign Up - Workout.cool",
9 | description: "Créez votre compte pour commencer",
10 | };
11 |
12 | export default async function AuthSignUpPage() {
13 | const t = await getI18n();
14 |
15 | return (
16 |
17 |
18 |
{t("register_title")}
19 |
{t("register_description")}
20 |
21 |
22 |
23 |
24 |
25 |
26 | {t("register_terms")}{" "}
27 |
28 | {t("register_privacy")}
29 | {" "}
30 | .
31 |
32 |
33 |
34 | );
35 | }
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "background_color": "#f3f4f6",
3 | "categories": ["health", "fitness", "sports"],
4 | "description": "Your personal workout companion - track workouts, build routines, and stay motivated",
5 | "display": "standalone",
6 | "icons": [
7 | {
8 | "src": "/images/favicon-16x16.png",
9 | "sizes": "16x16",
10 | "type": "image/png"
11 | },
12 | {
13 | "src": "/images/favicon-32x32.png",
14 | "sizes": "32x32",
15 | "type": "image/png"
16 | },
17 | {
18 | "src": "/apple-touch-icon.png",
19 | "sizes": "180x180",
20 | "type": "image/png",
21 | "purpose": "any maskable"
22 | },
23 | {
24 | "src": "/android-chrome-192x192.png",
25 | "sizes": "192x192",
26 | "type": "image/png",
27 | "purpose": "any maskable"
28 | },
29 | {
30 | "src": "/android-chrome-512x512.png",
31 | "sizes": "512x512",
32 | "type": "image/png",
33 | "purpose": "any maskable"
34 | }
35 | ],
36 | "lang": "en",
37 | "name": "Workout Cool",
38 | "orientation": "portrait",
39 | "scope": "/",
40 | "short_name": "Workout Cool",
41 | "start_url": "/",
42 | "theme_color": "#FF5722"
43 | }
44 |
--------------------------------------------------------------------------------
/src/components/ads/InArticle.tsx:
--------------------------------------------------------------------------------
1 | import { env } from "@/env";
2 | import { AdPlaceholder } from "@/components/ads/AdPlaceholder";
3 |
4 | import { GoogleAdSense } from "./GoogleAdSense";
5 | import { AdWrapper } from "./AdWrapper";
6 |
7 | export function InArticle({ adSlot }: { adSlot: string }) {
8 | const isDevelopment = process.env.NODE_ENV === "development";
9 |
10 | if (!env.NEXT_PUBLIC_AD_CLIENT) {
11 | return null;
12 | }
13 |
14 | return (
15 |
16 |
17 |
18 | {isDevelopment ? (
19 |
20 | ) : (
21 |
33 | )}
34 |
35 |
36 |
37 | );
38 | }
39 |
--------------------------------------------------------------------------------
/src/components/ui/toaster.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { cn } from "@/shared/lib/utils";
4 | import { useToast } from "@/components/ui/use-toast";
5 | import { Toast, ToastProvider, ToastTitle, ToastViewport } from "@/components/ui/toast";
6 |
7 | export function Toaster() {
8 | const { toasts } = useToast();
9 |
10 | return (
11 |
12 | {toasts.map(function ({ id, icon, image, title, description, action, close, actionClassName, ...props }) {
13 | return (
14 |
15 |
16 | {image &&
{image}
}
17 |
18 | {icon}
19 |
20 | {title && {title} }
21 | {description}
22 |
23 | {action}
24 |
25 | {close}
26 |
27 |
28 |
29 | );
30 | })}
31 |
32 |
33 | );
34 | }
35 |
--------------------------------------------------------------------------------
/src/features/contact-feedback/model/contact-feedback.action.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { prisma } from "@/shared/lib/prisma";
4 | import { sendEmail } from "@/shared/lib/mail/sendEmail";
5 | import { SiteConfig } from "@/shared/config/site-config";
6 | import { actionClient } from "@/shared/api/safe-actions";
7 | import { serverAuth } from "@/entities/user/model/get-server-session-user";
8 |
9 | import { ContactFeedbackSchema } from "./contact-feedback.schema";
10 |
11 | export const contactFeedbackAction = actionClient.schema(ContactFeedbackSchema).action(async ({ parsedInput }) => {
12 | const user = await serverAuth();
13 | const email = user?.email ?? parsedInput.email;
14 |
15 | const feedback = await prisma.feedbacks.create({
16 | data: {
17 | message: parsedInput.message,
18 | review: Number(parsedInput.review) || 0,
19 | userId: user?.id,
20 | email,
21 | },
22 | });
23 |
24 | await sendEmail({
25 | from: SiteConfig.email.from,
26 | to: SiteConfig.email.contact,
27 | subject: `New feedback from ${email}`,
28 | text: `Review: ${feedback.review}\n\nMessage: ${feedback.message}`,
29 | });
30 |
31 | return { message: "Your feedback has been sent to support." };
32 | });
33 |
--------------------------------------------------------------------------------
/src/features/page/layout.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/shared/lib/utils";
2 | import { Typography } from "@/components/ui/typography";
3 |
4 | import type { ComponentPropsWithoutRef } from "react";
5 |
6 | export const Layout = (props: ComponentPropsWithoutRef<"div">) => {
7 | return
;
8 | };
9 |
10 | export const LayoutHeader = (props: ComponentPropsWithoutRef<"div">) => {
11 | return
;
12 | };
13 |
14 | export const LayoutTitle = (props: ComponentPropsWithoutRef<"h1">) => {
15 | return ;
16 | };
17 |
18 | export const LayoutDescription = (props: ComponentPropsWithoutRef<"p">) => {
19 | return ;
20 | };
21 |
22 | export const LayoutActions = (props: ComponentPropsWithoutRef<"div">) => {
23 | return
;
24 | };
25 |
26 | export const LayoutContent = (props: ComponentPropsWithoutRef<"div">) => {
27 | return
;
28 | };
29 |
--------------------------------------------------------------------------------
/src/components/ui/sonner.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Toaster as Sonner } from "sonner";
4 | import { useTheme } from "next-themes";
5 |
6 | type ToasterProps = React.ComponentProps;
7 |
8 | const ToastSonner = ({ ...props }: ToasterProps) => {
9 | const { theme = "system" } = useTheme();
10 |
11 | return (
12 | svg]:size-[18px] border-0 hover:opacity-70 top-[22px] absolute rtl:left-2 ltr:right-2 ltr:ml-auto rtl:mr-auto",
25 | },
26 | }}
27 | {...props}
28 | />
29 | );
30 | };
31 |
32 | export { ToastSonner };
33 |
--------------------------------------------------------------------------------
/src/entities/user/model/use-auto-locale.ts:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useEffect, useRef } from "react";
4 |
5 | import { useChangeLocale, useCurrentLocale } from "locales/client";
6 | import { useUpdateUserLocale } from "@/entities/user/model/update-user-locale";
7 |
8 | export function useAutoLocale() {
9 | const currentLocale = useCurrentLocale();
10 | const changeLocale = useChangeLocale();
11 | const updateUserLocale = useUpdateUserLocale();
12 | const hasAutoDetected = useRef(false);
13 |
14 | useEffect(() => {
15 | // Only run auto-detection once on mount
16 | if (hasAutoDetected.current) return;
17 |
18 | const detectedLocale = document.cookie
19 | .split("; ")
20 | .find((row) => row.startsWith("detected-locale="))
21 | ?.split("=")[1];
22 |
23 | // Only change if we have a detected locale different from current
24 | if (detectedLocale && detectedLocale !== currentLocale) {
25 | hasAutoDetected.current = true;
26 |
27 | // Change locale on client
28 | changeLocale(detectedLocale as any);
29 |
30 | // Save to database silently
31 | updateUserLocale.mutate({ locale: detectedLocale });
32 | }
33 | }, []); // Remove dependencies to run only once
34 | }
35 |
--------------------------------------------------------------------------------
/src/components/svg/GoogleSvg.tsx:
--------------------------------------------------------------------------------
1 | import type { ComponentPropsWithoutRef } from "react";
2 |
3 | export type GoogleSvgProps = ComponentPropsWithoutRef<"svg"> & { size?: number };
4 |
5 | export const GoogleSvg = ({ size = 32, ...props }: GoogleSvgProps) => {
6 | return (
7 |
8 |
12 |
16 |
20 |
24 |
25 |
26 | );
27 | };
28 |
--------------------------------------------------------------------------------
/src/components/ui/Bento.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/shared/lib/utils";
2 |
3 | import { Typography } from "./typography";
4 |
5 | export const BentoGrid = ({ className, children }: { className?: string; children?: React.ReactNode }) => {
6 | return {children}
;
7 | };
8 |
9 | export const BentoGridItem = ({
10 | className,
11 | title,
12 | description,
13 | header,
14 | icon,
15 | }: {
16 | className?: string;
17 | title?: string | React.ReactNode;
18 | description?: string | React.ReactNode;
19 | header?: React.ReactNode;
20 | icon?: React.ReactNode;
21 | }) => {
22 | return (
23 |
29 | {header}
30 |
31 | {icon}
32 | {title}
33 | {description}
34 |
35 |
36 | );
37 | };
38 |
--------------------------------------------------------------------------------
/app/[locale]/(app)/(legal-and-payment)/legal/privacy/page.tsx:
--------------------------------------------------------------------------------
1 | import { getLocalizedMdx } from "@/shared/lib/mdx/load-mdx";
2 | import { Typography } from "@/components/ui/typography";
3 |
4 | type PageProps = {
5 | params: Promise<{ locale: string }>;
6 | };
7 |
8 | export default async function PrivacyPolicyPage({ params }: PageProps) {
9 | const { locale } = await params;
10 | const content = await getLocalizedMdx("privacy-policy", locale);
11 |
12 | return (
13 |
14 |
15 |
25 |
26 |
{content}
27 |
28 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/app/[locale]/(app)/tools/calorie-calculator/shared/components/InfoButton.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import React from "react";
4 | import { InfoIcon } from "lucide-react";
5 |
6 | import { useIsMobile } from "@/shared/hooks/useIsMobile";
7 |
8 | interface InfoButtonProps {
9 | onClick: () => void;
10 | tooltip?: React.ReactNode;
11 | }
12 |
13 | export function InfoButton({ onClick, tooltip }: InfoButtonProps) {
14 | const isMobile = useIsMobile();
15 |
16 | return (
17 |
18 |
19 |
24 |
25 | {!isMobile && tooltip && (
26 |
27 | {tooltip}
28 |
29 | )}
30 |
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/src/components/ui/local-alert.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import Link from "next/link";
3 |
4 | import { useI18n } from "locales/client";
5 | import { cn } from "@/shared/lib/utils";
6 | import { paths } from "@/shared/constants/paths";
7 | import { Alert, AlertDescription } from "@/components/ui/alert";
8 |
9 | interface LocalAlertProps {
10 | className?: string;
11 | }
12 |
13 | export const LocalAlert = ({ className }: LocalAlertProps) => {
14 | const t = useI18n();
15 |
16 | return (
17 |
18 |
19 | {t("profile.alert.title")}
20 |
21 |
22 | {t("profile.alert.create_account")}
23 |
24 | {t("commons.or").toLocaleLowerCase()}
25 |
26 | {t("profile.alert.log_in")}
27 |
28 | {t("profile.alert.to_ensure_it_is_not_getting_lost")}
29 |
30 |
31 | );
32 | };
33 |
--------------------------------------------------------------------------------
/src/entities/program-session/types/program-session.types.ts:
--------------------------------------------------------------------------------
1 | import { ExerciseAttributeValueEnum } from "@prisma/client";
2 |
3 | import { I18nText, I18nSlug, I18nField } from "@/shared/types/i18n.types";
4 | import { ExerciseWithAttributes, SuggestedSet } from "@/entities/exercise/types/exercise.types";
5 |
6 | // Base session type
7 | export interface BaseProgramSession extends I18nText, I18nSlug {
8 | id: string;
9 | weekId: string;
10 | sessionNumber: number;
11 | equipment: ExerciseAttributeValueEnum[];
12 | estimatedMinutes: number;
13 | isPremium: boolean;
14 | }
15 |
16 | // Program week type
17 | export interface ProgramWeek extends I18nText {
18 | id: string;
19 | programId: string;
20 | weekNumber: number;
21 | createdAt: Date;
22 | updatedAt: Date;
23 | }
24 |
25 | // Program exercise (exercise in the context of a program session)
26 | export interface ProgramExercise extends I18nField<"instructions"> {
27 | id: string;
28 | sessionId: string;
29 | exerciseId: string;
30 | order: number;
31 | exercise: ExerciseWithAttributes;
32 | suggestedSets: SuggestedSet[];
33 | }
34 |
35 | // Session with exercises
36 | export interface ProgramSessionWithExercises extends BaseProgramSession {
37 | exercises: ProgramExercise[];
38 | }
39 |
--------------------------------------------------------------------------------
/app/api/premium/billing-portal/route.ts:
--------------------------------------------------------------------------------
1 | import { NextRequest, NextResponse } from "next/server";
2 |
3 | import { PremiumManager } from "@/shared/lib/premium/premium.manager";
4 | import { serverRequiredUser } from "@/entities/user/model/get-server-session-user";
5 |
6 | /**
7 | * POST /api/premium/billing-portal
8 | *
9 | * Create billing portal session for subscription management
10 | * Body: { returnUrl?: string, provider?: string }
11 | */
12 | export async function POST(request: NextRequest) {
13 | try {
14 | const user = await serverRequiredUser();
15 | const { returnUrl, provider = "stripe" } = await request.json();
16 |
17 | const result = await PremiumManager.createBillingPortal(user.id, provider, returnUrl);
18 |
19 | if (result.success) {
20 | return NextResponse.json({ success: true, url: result.checkoutUrl });
21 | } else {
22 | return NextResponse.json({ success: false, error: result.error }, { status: 400 });
23 | }
24 | } catch (error) {
25 | console.error("Billing portal creation error:", error);
26 |
27 | return NextResponse.json(
28 | {
29 | success: false,
30 | error: "Failed to create billing portal session",
31 | },
32 | { status: 500 },
33 | );
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/src/components/ui/ToastSonner.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Toaster as Sonner } from "sonner";
4 | import { useTheme } from "next-themes";
5 |
6 | type ToasterProps = React.ComponentProps;
7 |
8 | const ToastSonner = ({ ...props }: ToasterProps) => {
9 | const { theme = "system" } = useTheme();
10 |
11 | return (
12 | svg]:size-[15px] border-0 hover:opacity-70 !top-[12px] absolute ml-auto [--toast-close-button-end:2px] ",
26 | },
27 | }}
28 | {...props}
29 | />
30 | );
31 | };
32 |
33 | export { ToastSonner };
34 |
--------------------------------------------------------------------------------
/app/api/revenuecat/link-user/route.ts:
--------------------------------------------------------------------------------
1 | import { NextRequest, NextResponse } from "next/server";
2 |
3 | /**
4 | * Get Premium Status
5 | *
6 | * GET /api/revenuecat/link-user
7 | *
8 | * Gets the current user's premium status
9 | * This endpoint is kept for backward compatibility
10 | */
11 | export async function GET(request: NextRequest) {
12 | try {
13 | const { getMobileCompatibleSession } = await import("@/shared/api/mobile-auth");
14 | const { PremiumService } = await import("@/shared/lib/premium/premium.service");
15 |
16 | // Get authenticated user
17 | const session = await getMobileCompatibleSession(request);
18 | const user = session?.user;
19 |
20 | if (!user) {
21 | console.log("No user found");
22 | return NextResponse.json({
23 | premiumStatus: {
24 | isPremium: false,
25 | },
26 | });
27 | }
28 |
29 | // Get premium status
30 | const premiumStatus = await PremiumService.checkUserPremiumStatus(user.id);
31 |
32 | return NextResponse.json({
33 | premiumStatus,
34 | });
35 | } catch (error) {
36 | console.error("Error getting premium status:", error);
37 |
38 | return NextResponse.json({ error: "Failed to get premium status" }, { status: 500 });
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/features/auth/forgot-password/model/useForgotPassword.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useState } from "react";
4 |
5 | import { useI18n } from "locales/client";
6 | import { getServerUrl } from "@/shared/lib/server-url";
7 | import { paths } from "@/shared/constants/paths";
8 | import { authClient } from "@/features/auth/lib/auth-client";
9 |
10 | export const useForgotPassword = () => {
11 | const t = useI18n();
12 | const [isLoading, setIsLoading] = useState(false);
13 | const [isEmailSent, setIsEmailSent] = useState(false);
14 |
15 | const forgotPassword = async (email: string, setFieldError: (message: string) => void) => {
16 | setIsLoading(true);
17 | try {
18 | const { error } = await authClient.forgetPassword({
19 | email,
20 | redirectTo: `${getServerUrl()}/${paths.resetPassword}`,
21 | });
22 |
23 | if (error) {
24 | setFieldError(t("error.sending_email"));
25 | return;
26 | }
27 |
28 | setIsEmailSent(true);
29 | } catch (error) {
30 | console.error(error);
31 | setFieldError(t("error.generic_error"));
32 | } finally {
33 | setIsLoading(false);
34 | }
35 | };
36 |
37 | return {
38 | forgotPassword,
39 | isLoading,
40 | isEmailSent,
41 | };
42 | };
43 |
--------------------------------------------------------------------------------
/src/features/leaderboard/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import utc from "dayjs/plugin/utc";
2 | import timezone from "dayjs/plugin/timezone";
3 | import dayjs from "dayjs";
4 |
5 | // Initialize dayjs plugins
6 | dayjs.extend(utc);
7 | dayjs.extend(timezone);
8 |
9 | const PARIS_TZ = "Europe/Paris";
10 |
11 | export type LeaderboardPeriod = "all-time" | "weekly" | "monthly";
12 |
13 | export function getDateRangeForPeriod(period: LeaderboardPeriod): { startDate: Date | undefined; endDate: Date } {
14 | const now = dayjs().tz(PARIS_TZ);
15 |
16 | switch (period) {
17 | case "weekly": {
18 | // Start of current week (Monday) in Paris timezone
19 | const startOfWeek = now.startOf("week").add(1, "day"); // dayjs week starts on Sunday, add 1 for Monday
20 | return {
21 | startDate: startOfWeek.toDate(),
22 | endDate: now.toDate(),
23 | };
24 | }
25 | case "monthly": {
26 | // Start of current month in Paris timezone
27 | const startOfMonth = now.startOf("month");
28 | return {
29 | startDate: startOfMonth.toDate(),
30 | endDate: now.toDate(),
31 | };
32 | }
33 | case "all-time":
34 | default:
35 | return {
36 | startDate: undefined,
37 | endDate: now.toDate(),
38 | };
39 | }
40 | }
--------------------------------------------------------------------------------
/src/shared/hooks/use-premium-plans.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query";
2 |
3 | interface PremiumPlan {
4 | id: string;
5 | internalId: string;
6 | name: string;
7 | type: string;
8 | priceMonthly: number;
9 | priceYearly: number;
10 | currency: "EUR" | "USD" | "GBP";
11 | features: string[];
12 | }
13 |
14 | interface PlansResponse {
15 | plans: PremiumPlan[];
16 | detectedRegion: string;
17 | debug?: {
18 | headers: {
19 | country: string | null;
20 | acceptLanguage: string | null;
21 | timezone: string | null;
22 | };
23 | };
24 | }
25 |
26 | export function usePremiumPlans() {
27 | return useQuery({
28 | queryKey: ["premium-plans"],
29 | queryFn: async () => {
30 | // Get user timezone for better region detection
31 | const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
32 |
33 | const response = await fetch(`/api/premium/plans?tz=${encodeURIComponent(timezone)}`);
34 | if (!response.ok) {
35 | throw new Error("Failed to fetch plans");
36 | }
37 | const data = await response.json();
38 | return data;
39 | },
40 | staleTime: 1000 * 60 * 30, // 30 minutes
41 | gcTime: 1000 * 60 * 60, // 1 hour (previously cacheTime)
42 | });
43 | }
44 |
--------------------------------------------------------------------------------
/src/components/svg/DiscordSvg.tsx:
--------------------------------------------------------------------------------
1 | export const DiscordSvg = ({ className, ...props }: React.SVGProps) => (
2 |
3 |
4 |
5 | );
6 |
--------------------------------------------------------------------------------
/app/[locale]/(app)/tools/heart-rate-zones/seo/page-content.ts:
--------------------------------------------------------------------------------
1 | import { Locale } from "locales/types";
2 |
3 | interface PageContent {
4 | heroSubtitle: string;
5 | }
6 |
7 | // Page content separate from SEO metadata
8 | export const HEART_RATE_ZONES_CONTENT: Record = {
9 | en: {
10 | heroSubtitle: "Discover your personalized training zones to optimize performance, burn more fat, and improve cardiovascular fitness",
11 | },
12 | es: {
13 | heroSubtitle:
14 | "Descubre tus zonas de entrenamiento personalizadas para optimizar el rendimiento, quemar más grasa y mejorar tu condición cardiovascular",
15 | },
16 | fr: {
17 | heroSubtitle:
18 | "Découvrez vos zones d'entraînement personnalisées pour optimiser vos performances, brûler plus de graisses et améliorer votre condition cardiovasculaire",
19 | },
20 | pt: {
21 | heroSubtitle:
22 | "Descubra suas zonas de treino personalizadas para otimizar o desempenho, queimar mais gordura e melhorar sua condição cardiovascular",
23 | },
24 | ru: {
25 | heroSubtitle:
26 | "Откройте персональные тренировочные зоны для оптимизации результатов, сжигания жира и улучшения сердечно-сосудистой системы",
27 | },
28 | "zh-CN": {
29 | heroSubtitle: "发现您的个性化训练区间,优化运动表现,燃烧更多脂肪,改善心血管健康",
30 | },
31 | };
32 |
--------------------------------------------------------------------------------
/src/features/admin/programs/actions/delete-program.action.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { headers } from "next/headers";
4 | import { revalidatePath } from "next/cache";
5 | import { UserRole } from "@prisma/client";
6 |
7 | import { prisma } from "@/shared/lib/prisma";
8 | import { auth } from "@/features/auth/lib/better-auth";
9 |
10 | export async function deleteProgram(programId: string) {
11 | const session = await auth.api.getSession({
12 | headers: await headers(),
13 | });
14 |
15 | if (!session || session.user?.role !== UserRole.admin) {
16 | throw new Error("Unauthorized");
17 | }
18 |
19 | // Check if program has enrollments
20 | const program = await prisma.program.findUnique({
21 | where: { id: programId },
22 | include: {
23 | enrollments: {
24 | take: 1,
25 | },
26 | },
27 | });
28 |
29 | if (!program) {
30 | throw new Error("Program not found");
31 | }
32 |
33 | if (program.enrollments.length > 0) {
34 | throw new Error("Cannot delete program with active enrollments");
35 | }
36 |
37 | // Delete program (cascade will handle weeks, sessions, exercises, etc.)
38 | await prisma.program.delete({
39 | where: { id: programId },
40 | });
41 |
42 | revalidatePath("/admin/programs");
43 |
44 | return { success: true };
45 | }
--------------------------------------------------------------------------------
/src/shared/api/safe-actions.ts:
--------------------------------------------------------------------------------
1 | import { createSafeActionClient } from "next-safe-action";
2 |
3 | import { serverAuth } from "@/entities/user/model/get-server-session-user";
4 |
5 | export class ActionError extends Error {
6 | constructor(message: string) {
7 | super(message);
8 | }
9 | }
10 |
11 | type HandleReturnedServerError = (e: Error) => string;
12 |
13 | const handleReturnedServerError: HandleReturnedServerError = (e) => {
14 | if (e instanceof ActionError) {
15 | return e.message;
16 | }
17 |
18 | return "An unexpected error occurred.";
19 | };
20 |
21 | export const actionClient = createSafeActionClient({
22 | handleServerError: handleReturnedServerError,
23 | });
24 |
25 | const getUser = async () => {
26 | const user = await serverAuth();
27 |
28 | if (!user) {
29 | throw new ActionError("Session not found!");
30 | }
31 |
32 | if (!user.id || !user.email) {
33 | throw new ActionError("Session is not valid!");
34 | }
35 |
36 | return user;
37 | };
38 |
39 | export const authenticatedActionClient = createSafeActionClient({
40 | handleServerError: handleReturnedServerError,
41 | } as const).use(async ({ next, clientInput: _clientInput, metadata: _metadata }) => {
42 | const user = await getUser();
43 |
44 | return await next({ ctx: { user } });
45 | });
46 |
--------------------------------------------------------------------------------
/src/features/contact-feedback/ui/ReviewInput.tsx:
--------------------------------------------------------------------------------
1 | import { Angry, Frown, Meh, SmilePlus } from "lucide-react";
2 |
3 | import { useI18n } from "locales/client";
4 | import { cn } from "@/shared/lib/utils";
5 | import { InlineTooltip } from "@/components/ui/tooltip";
6 |
7 | export const ReviewInput = ({ onChange, value }: { onChange: (value: string) => void; value?: string }) => {
8 | const t = useI18n();
9 |
10 | const options = [
11 | { value: "1", icon: Angry, tooltip: t("commons.extremely_dissatisfied") },
12 | { value: "2", icon: Frown, tooltip: t("commons.somewhat_dissatisfied") },
13 | { value: "3", icon: Meh, tooltip: t("commons.neutral") },
14 | { value: "4", icon: SmilePlus, tooltip: t("commons.satisfied") },
15 | ];
16 |
17 | return (
18 | <>
19 | {options.map((item) => (
20 |
21 | onChange(item.value)}
26 | type="button"
27 | >
28 |
29 |
30 |
31 | ))}
32 | >
33 | );
34 | };
35 |
--------------------------------------------------------------------------------
/src/features/workout-session/actions/delete-workout-session.action.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { z } from "zod";
4 |
5 | import { prisma } from "@/shared/lib/prisma";
6 | import { actionClient } from "@/shared/api/safe-actions";
7 |
8 | const deleteWorkoutSessionSchema = z.object({
9 | id: z.string(),
10 | });
11 |
12 | export const deleteWorkoutSessionAction = actionClient.schema(deleteWorkoutSessionSchema).action(async ({ parsedInput }) => {
13 | try {
14 | const { id } = parsedInput;
15 |
16 | const session = await prisma.workoutSession.findUnique({
17 | where: { id },
18 | select: { userId: true },
19 | });
20 |
21 | if (!session) {
22 | console.error("❌ Session not found:", id);
23 | return { serverError: "Session not found" };
24 | }
25 |
26 | // Supprimer la session (cascade supprimera automatiquement les exercices et sets)
27 | await prisma.workoutSession.delete({
28 | where: { id },
29 | });
30 |
31 | if (process.env.NODE_ENV === "development") {
32 | console.log("✅ Workout session deleted successfully:", id);
33 | }
34 |
35 | return { success: true };
36 | } catch (error) {
37 | console.error("❌ Error deleting workout session:", error);
38 | return { serverError: "Failed to delete workout session" };
39 | }
40 | });
41 |
--------------------------------------------------------------------------------
/src/shared/lib/workout-session/equipments.ts:
--------------------------------------------------------------------------------
1 | import { ExerciseAttributeValueEnum } from "@prisma/client";
2 |
3 | import { TFunction } from "locales/client";
4 |
5 | export const allEquipmentValues = [
6 | ExerciseAttributeValueEnum.BODY_ONLY,
7 | ExerciseAttributeValueEnum.DUMBBELL,
8 | ExerciseAttributeValueEnum.BARBELL,
9 | ExerciseAttributeValueEnum.KETTLEBELLS,
10 | ExerciseAttributeValueEnum.BANDS,
11 | ];
12 |
13 | export const getEquipmentTranslation = (value: ExerciseAttributeValueEnum, t: TFunction) => {
14 | const equipmentKeys: Partial> = {
15 | [ExerciseAttributeValueEnum.BODY_ONLY]: "bodyweight",
16 | [ExerciseAttributeValueEnum.DUMBBELL]: "dumbbell",
17 | [ExerciseAttributeValueEnum.BARBELL]: "barbell",
18 | [ExerciseAttributeValueEnum.KETTLEBELLS]: "kettlebell",
19 | [ExerciseAttributeValueEnum.BANDS]: "band",
20 | [ExerciseAttributeValueEnum.WEIGHT_PLATE]: "plate",
21 | [ExerciseAttributeValueEnum.PULLUP_BAR]: "pullup_bar",
22 | [ExerciseAttributeValueEnum.BENCH]: "bench",
23 | };
24 |
25 | const key = equipmentKeys[value];
26 | return {
27 | label: t(`workout_builder.equipment.${key}.label` as keyof typeof t),
28 | description: t(`workout_builder.equipment.${key}.description` as keyof typeof t),
29 | };
30 | };
31 |
--------------------------------------------------------------------------------
/app/[locale]/(admin)/admin/layout.tsx:
--------------------------------------------------------------------------------
1 | import { ReactElement } from "react";
2 | import { redirect } from "next/navigation";
3 | import { UserRole } from "@prisma/client";
4 |
5 | import { AdminSidebar } from "@/features/admin/layout/admin-sidebar/ui/admin-sidebar";
6 | import { AdminHeader } from "@/features/admin/layout/admin-sidebar/ui/admin-header";
7 | import { serverRequiredUser } from "@/entities/user/model/get-server-session-user";
8 |
9 | interface AdminLayoutProps {
10 | params: Promise<{ locale: string }>;
11 | children: ReactElement;
12 | }
13 |
14 | export default async function AdminLayout({ children }: AdminLayoutProps) {
15 | const user = await serverRequiredUser();
16 |
17 | if (user.role !== UserRole.admin) {
18 | redirect("/");
19 | }
20 |
21 | return (
22 |
23 | {/* Sidebar */}
24 |
25 |
26 | {/* Main content */}
27 |
28 | {/* Header */}
29 |
30 |
31 | {/* Page content */}
32 |
33 | {children}
34 |
35 |
36 |
37 | );
38 | }
39 |
--------------------------------------------------------------------------------
/src/entities/exercise/shared/muscles.tsx:
--------------------------------------------------------------------------------
1 | import { ExerciseAttributeNameEnum } from "@prisma/client";
2 |
3 | import { ExerciseAttribute, ExerciseWithAttributes } from "@/entities/exercise/types/exercise.types";
4 |
5 | export const getAttributeName = (attr: ExerciseAttribute) => {
6 | return typeof attr.attributeName === "string" ? attr.attributeName : attr.attributeName.name;
7 | };
8 |
9 | export const getAttributeValue = (attr: ExerciseAttribute) => {
10 | return typeof attr.attributeValue === "string" ? attr.attributeValue : attr.attributeValue.value;
11 | };
12 |
13 | const getAttributesByName = (attributes: ExerciseAttribute[], name: ExerciseAttributeNameEnum) => {
14 | return attributes.filter((attr) => getAttributeName(attr) === name);
15 | };
16 |
17 | export const getPrimaryMuscle = (attributes: ExerciseAttribute[]): ExerciseAttribute | undefined => {
18 | return getAttributesByName(attributes, ExerciseAttributeNameEnum.PRIMARY_MUSCLE)[0];
19 | };
20 |
21 | export const getSecondaryMuscles = (attributes: ExerciseAttribute[]) => {
22 | return getAttributesByName(attributes, ExerciseAttributeNameEnum.SECONDARY_MUSCLE);
23 | };
24 |
25 | export const getExerciseAttributesValueOf = (exercise: ExerciseWithAttributes, name: ExerciseAttributeNameEnum) => {
26 | return getAttributesByName(exercise.attributes, name).map(getAttributeValue);
27 | };
28 |
--------------------------------------------------------------------------------
/app/[locale]/(app)/premium/page.tsx:
--------------------------------------------------------------------------------
1 | import { Metadata } from "next";
2 |
3 | import { PremiumUpgradeCard } from "@/features/premium/ui/premium-upgrade-card";
4 |
5 | export const metadata: Metadata = {
6 | title: "Premium Plans - Train freely, support the mission",
7 | description:
8 | "Join thousands of fitness enthusiasts who believe in open-source training freedom. Support our mission while unlocking advanced features.",
9 | keywords: ["premium", "fitness", "workout", "open-source", "subscription", "training"],
10 | openGraph: {
11 | title: "Premium Plans - Support the Workout.cool Mission 💪",
12 | description: "For passionate fitness enthusiasts who believe in open-source and training freedom. Core features always free!",
13 | type: "website",
14 | },
15 | twitter: {
16 | card: "summary_large_image",
17 | title: "Premium Plans - Workout.cool",
18 | description: "Train freely, support the mission. Join the passionate fitness community!",
19 | },
20 | };
21 |
22 | export default function PremiumPage() {
23 | return (
24 |
25 | {/* Main Content */}
26 |
29 |
30 | {/* Mobile Sticky CTA */}
31 | {/*
*/}
32 |
33 | );
34 | }
35 |
--------------------------------------------------------------------------------
/src/components/ads/EzoicAd.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useEffect } from "react";
4 |
5 | interface EzoicAdProps {
6 | placementId: string;
7 | className?: string;
8 | }
9 |
10 | declare global {
11 | interface Window {
12 | ezstandalone: {
13 | cmd: Array<() => void>;
14 | showAds: (placementId?: string | string[]) => void;
15 | };
16 | }
17 | }
18 |
19 | export function EzoicAd({ placementId, className = "" }: EzoicAdProps) {
20 | const divId = `ezoic-pub-ad-placeholder-${placementId}`;
21 |
22 | useEffect(() => {
23 | const loadEzoicAd = () => {
24 | if (typeof window !== "undefined" && window.ezstandalone) {
25 | try {
26 | window.ezstandalone.cmd = window.ezstandalone.cmd || [];
27 | window.ezstandalone.cmd.push(function () {
28 | if (window.ezstandalone && window.ezstandalone.showAds) {
29 | window.ezstandalone.showAds(placementId);
30 | }
31 | });
32 | } catch (error) {
33 | console.error("Error loading Ezoic ad:", error);
34 | }
35 | }
36 | };
37 |
38 | // Delay slightly to ensure Ezoic scripts are loaded
39 | const timeoutId = setTimeout(loadEzoicAd, 100);
40 |
41 | return () => clearTimeout(timeoutId);
42 | }, [placementId]);
43 |
44 | return
;
45 | }
46 |
--------------------------------------------------------------------------------
/src/entities/exercise/types/exercise.types.ts:
--------------------------------------------------------------------------------
1 | import { ExerciseAttributeNameEnum, ExerciseAttributeValueEnum } from "@prisma/client";
2 |
3 | // import { I18nName, I18nField } from "@/shared/types/i18n.types";
4 |
5 | // Base exercise type
6 | export interface BaseExercise {
7 | id: string;
8 | fullVideoUrl?: string | null;
9 | fullVideoImageUrl?: string | null;
10 | introduction: string | null;
11 | introductionEn: string | null;
12 | name: string;
13 | nameEn: string | null;
14 | description: string;
15 | descriptionEn: string | null;
16 | createdAt: Date;
17 | updatedAt: Date;
18 | }
19 |
20 | export interface ExerciseAttribute {
21 | id: string;
22 | exerciseId: string;
23 | attributeNameId: string;
24 | attributeValueId: string;
25 | attributeName: ExerciseAttributeNameEnum | { name: ExerciseAttributeNameEnum; id: string };
26 | attributeValue: ExerciseAttributeValueEnum | { value: ExerciseAttributeValueEnum; id: string };
27 | }
28 |
29 | // Exercise with attributes
30 | export interface ExerciseWithAttributes extends BaseExercise {
31 | attributes: ExerciseAttribute[];
32 | }
33 |
34 | // Suggested set for program exercises
35 | export interface SuggestedSet {
36 | id: string;
37 | programExerciseId: string;
38 | setIndex: number;
39 | types: string[];
40 | valuesInt: number[];
41 | valuesSec: number[];
42 | units: string[];
43 | }
44 |
--------------------------------------------------------------------------------
/src/components/ui/link.tsx:
--------------------------------------------------------------------------------
1 | import { forwardRef } from "react";
2 | import NextLink from "next/link";
3 |
4 | import { cn } from "@/shared/lib/utils";
5 |
6 | import type { ComponentProps } from "react";
7 |
8 | interface LinkProps extends ComponentProps {
9 | variant?: "default" | "nav" | "footer" | "button";
10 | size?: "sm" | "base" | "lg";
11 | }
12 |
13 | export const Link = forwardRef(
14 | ({ className, variant = "default", size = "base", children, ...props }, ref) => {
15 | const variants = {
16 | default: "link link-hover text-base-content hover:text-primary transition-colors dark:text-gray-200 dark:hover:text-primary",
17 | nav: "link link-hover text-base-content/80 hover:text-base-content transition-colors dark:text-gray-200 dark:hover:text-primary",
18 | footer: "link link-hover text-base-content/70 hover:text-base-content transition-colors dark:text-gray-200 dark:hover:text-primary",
19 | button: "btn btn-link no-underline hover:underline",
20 | };
21 |
22 | const sizes = {
23 | sm: "text-sm",
24 | base: "text-base",
25 | lg: "text-lg",
26 | };
27 |
28 | return (
29 |
30 | {children}
31 |
32 | );
33 | },
34 | );
35 |
36 | Link.displayName = "Link";
37 |
--------------------------------------------------------------------------------
/src/shared/config/site-config.ts:
--------------------------------------------------------------------------------
1 | export const SiteConfig = {
2 | title: "Workout Cool",
3 | description: "Modern fitness coaching platform with comprehensive exercise database",
4 | keywords: [
5 | "fitness",
6 | "workout",
7 | "exercise",
8 | "training",
9 | "muscle building",
10 | "strength training",
11 | "bodybuilding",
12 | "fitness app",
13 | "workout planner",
14 | "exercise database",
15 | ],
16 | prodUrl: "https://workout.cool",
17 | logo: "/images/logo.png",
18 | domain: "workout.cool",
19 | appIcon: "/images/logo4.jpg",
20 | company: {
21 | name: "Workout Cool",
22 | address: "34 avenue des champ Elysée 75008 Paris, France",
23 | },
24 | brand: {
25 | primary: "#007291",
26 | },
27 | email: {
28 | from: "Workout Cool ",
29 | contact: "hello@workout.cool",
30 | },
31 | maker: {
32 | image: "https://workout.cool/images/me/twitter-en.jpg",
33 | website: "https://workout.cool",
34 | twitter: "https://twitter.com/workout_cool",
35 | name: "Workout Cool",
36 | },
37 | auth: {
38 | password: false,
39 | },
40 | seo: {
41 | ogImage: {
42 | width: 1200,
43 | height: 630,
44 | },
45 | twitterHandle: "@snouzy_biceps",
46 | applicationName: "Workout Cool",
47 | category: "fitness",
48 | classification: "Fitness & Health",
49 | },
50 | };
51 |
--------------------------------------------------------------------------------
/app/[locale]/(app)/(legal-and-payment)/legal/terms/page.tsx:
--------------------------------------------------------------------------------
1 | import { getLocalizedMdx } from "@/shared/lib/mdx/load-mdx";
2 | import { Layout, LayoutContent } from "@/features/page/layout";
3 | import { Typography } from "@/components/ui/typography";
4 |
5 | type PageProps = {
6 | params: Promise<{ locale: string }>;
7 | };
8 |
9 | export default async function TermsPage({ params }: PageProps) {
10 | const { locale } = await params;
11 | const content = await getLocalizedMdx("terms", locale);
12 |
13 | return (
14 |
15 |
16 |
26 |
27 |
28 | {content}
29 |
30 |
31 |
32 | );
33 | }
34 |
--------------------------------------------------------------------------------
/app/[locale]/(app)/(legal-and-payment)/legal/sales-terms/page.tsx:
--------------------------------------------------------------------------------
1 | import { getLocalizedMdx } from "@/shared/lib/mdx/load-mdx";
2 | import { Layout, LayoutContent } from "@/features/page/layout";
3 | import { Typography } from "@/components/ui/typography";
4 |
5 | type PageProps = {
6 | params: Promise<{ locale: string }>;
7 | };
8 |
9 | export default async function SalesTermsPage({ params }: PageProps) {
10 | const { locale } = await params;
11 | const content = await getLocalizedMdx("sales-terms", locale);
12 |
13 | return (
14 |
15 |
16 |
26 |
27 |
28 | {content}
29 |
30 |
31 |
32 | );
33 | }
34 |
--------------------------------------------------------------------------------
/src/features/workout-builder/types/index.ts:
--------------------------------------------------------------------------------
1 | import { StaticImageData } from "next/image";
2 | import { ExerciseAttributeValueEnum, WorkoutSet } from "@prisma/client";
3 |
4 | import { ExerciseWithAttributes } from "@/entities/exercise/types/exercise.types";
5 |
6 | // Re-export the type for consistency
7 | export type { ExerciseWithAttributes };
8 |
9 | export interface WorkoutBuilderState {
10 | currentStep: number;
11 | selectedEquipment: ExerciseAttributeValueEnum[];
12 | selectedMuscles: ExerciseAttributeValueEnum[];
13 | selectedExercises: string[];
14 | }
15 |
16 | export type WorkoutBuilderStep = 1 | 2 | 3;
17 |
18 | export interface StepperStepProps {
19 | stepNumber: number;
20 | title: string;
21 | description: string;
22 | isActive: boolean;
23 | isCompleted: boolean;
24 | }
25 |
26 | export interface EquipmentItem {
27 | value: ExerciseAttributeValueEnum;
28 | label: string;
29 | icon: StaticImageData;
30 | description?: string;
31 | className?: string;
32 | }
33 |
34 | // Types pour les exercices avec leurs attributs
35 | export type WorkoutBuilderExerciseWithAttributes = ExerciseWithAttributes & {
36 | order: number;
37 | };
38 |
39 | export type ExerciseWithAttributesAndSets = ExerciseWithAttributes & {
40 | sets: WorkoutSet[];
41 | };
42 |
43 | export interface ExercisesByMuscle {
44 | muscle: ExerciseAttributeValueEnum;
45 | exercises: ExerciseWithAttributes[];
46 | }
47 |
--------------------------------------------------------------------------------
/app/[locale]/(app)/auth/verify-request/page.tsx:
--------------------------------------------------------------------------------
1 | import Image from "next/image";
2 |
3 | import { SiteConfig } from "@/shared/config/site-config";
4 | import { Typography } from "@/components/ui/typography";
5 | import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
6 |
7 | interface VerifyRequestPageParams {
8 | params: Promise>;
9 | searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
10 | }
11 |
12 | export default async function AuthVerifyRequestPage({ params: _p, searchParams: _s }: VerifyRequestPageParams) {
13 | return (
14 |
15 |
16 |
17 | {SiteConfig.title}
18 |
19 |
20 |
21 |
22 | Almost There!
23 |
24 | {
25 | "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!"
26 | }
27 |
28 |
29 |
30 |
31 |
32 | );
33 | }
34 |
--------------------------------------------------------------------------------
/src/components/ads/VerticalAdBanner.tsx:
--------------------------------------------------------------------------------
1 | import { env } from "@/env";
2 |
3 | import { GoogleAdSense } from "./GoogleAdSense";
4 | import { EzoicAd } from "./EzoicAd";
5 | import { AdWrapper } from "./AdWrapper";
6 | import { AdPlaceholder } from "./AdPlaceholder";
7 |
8 | interface VerticalAdBannerProps {
9 | adSlot: string;
10 | ezoicPlacementId?: string;
11 | position?: "left" | "right";
12 | }
13 |
14 | export function VerticalAdBanner({ adSlot, ezoicPlacementId, position = "left" }: VerticalAdBannerProps) {
15 | const isDevelopment = process.env.NODE_ENV === "development";
16 | const useEzoic = env.NEXT_PUBLIC_AD_PROVIDER === "ezoic" && ezoicPlacementId;
17 |
18 | if (!env.NEXT_PUBLIC_AD_CLIENT && !useEzoic) {
19 | return null;
20 | }
21 |
22 | return (
23 |
24 |
25 | {isDevelopment ? (
26 |
27 | ) : useEzoic ? (
28 |
29 | ) : (
30 |
35 | )}
36 |
37 |
38 | );
39 | }
40 |
--------------------------------------------------------------------------------
/src/entities/user/model/update-user.action.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { revalidatePath } from "next/cache";
4 |
5 | import { prisma } from "@/shared/lib/prisma";
6 | import { mobileAuthenticatedActionClient } from "@/shared/api/mobile-safe-actions";
7 | import { updateUserSchema } from "@/entities/user/schemas/update-user.schema";
8 |
9 | export const updateUserAction = mobileAuthenticatedActionClient.schema(updateUserSchema).action(async ({ parsedInput, ctx }) => {
10 | const { user } = ctx as { user: any };
11 |
12 | if (!user) {
13 | throw new Error("Unauthenticated");
14 | }
15 |
16 | const { locale, firstName, lastName, image, revalidatePath: path } = parsedInput;
17 |
18 | // Build update object with only provided fields
19 | const updateData: Record = {};
20 | if (locale !== undefined) updateData.locale = locale;
21 | if (firstName !== undefined) updateData.firstName = firstName;
22 | if (lastName !== undefined) updateData.lastName = lastName;
23 | if (image !== undefined) updateData.image = image;
24 |
25 | // Only perform update if there are fields to update
26 | if (Object.keys(updateData).length > 0) {
27 | await prisma.user.update({
28 | where: { id: user.id },
29 | data: updateData,
30 | });
31 | }
32 |
33 | // Revalidate path if provided
34 | if (path) {
35 | revalidatePath(path);
36 | }
37 |
38 | return { success: true };
39 | });
40 |
--------------------------------------------------------------------------------
/src/features/workout-builder/actions/sync-favorite-exercises.action.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { z } from "zod";
4 |
5 | import { prisma } from "@/shared/lib/prisma";
6 | import { authenticatedActionClient } from "@/shared/api/safe-actions";
7 |
8 | const syncFavoriteExercisesSchema = z.object({
9 | exerciseIds: z.array(z.string()),
10 | });
11 |
12 | export const syncFavoriteExercisesAction = authenticatedActionClient
13 | .schema(syncFavoriteExercisesSchema)
14 | .action(async ({ parsedInput, ctx }) => {
15 | const { user } = ctx;
16 | const { exerciseIds } = parsedInput;
17 |
18 | try {
19 | await prisma.$transaction(async (tx) => {
20 | // Delete all current favorites
21 | await tx.userFavoriteExercise.deleteMany({
22 | where: { userId: user.id },
23 | });
24 |
25 | // Create new favorites
26 | if (exerciseIds.length > 0) {
27 | await tx.userFavoriteExercise.createMany({
28 | data: exerciseIds.map((exerciseId) => ({
29 | userId: user.id,
30 | exerciseId,
31 | })),
32 | });
33 | }
34 | });
35 |
36 | return { success: true };
37 | } catch (error) {
38 | console.error("Error syncing favorite exercises:", error);
39 | throw new Error(`Failed to sync favorites: ${error instanceof Error ? error.message : "Unknown error"}`);
40 | }
41 | });
42 |
--------------------------------------------------------------------------------
/src/components/ui/hover-card.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
5 |
6 | import { cn } from "@/shared/lib/utils";
7 |
8 | const HoverCard = HoverCardPrimitive.Root;
9 |
10 | const HoverCardTrigger = HoverCardPrimitive.Trigger;
11 |
12 | const HoverCardContent = React.forwardRef<
13 | React.ElementRef,
14 | React.ComponentPropsWithoutRef
15 | >(({ className, align = "center", sideOffset = 6, ...props }, ref) => (
16 |
26 | ));
27 | HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
28 |
29 | export { HoverCard, HoverCardTrigger, HoverCardContent };
30 |
--------------------------------------------------------------------------------
/src/shared/lib/premium/providers/base-provider.ts:
--------------------------------------------------------------------------------
1 | import type { CheckoutResult, PremiumPlan } from "@/shared/types/premium.types";
2 |
3 | /**
4 | * Base Payment Provider Interface
5 | *
6 | * KISS approach: Simple interface that any provider can implement
7 | * Easy to switch between Stripe, LemonSqueezy, PayPal, etc.
8 | */
9 | export interface PaymentProvider {
10 | name: string;
11 |
12 | /**
13 | * Create checkout session for a plan
14 | */
15 | createCheckoutSession(userId: string, plan: PremiumPlan, options?: CheckoutOptions): Promise;
16 |
17 | /**
18 | * Verify webhook signature
19 | */
20 | verifyWebhookSignature(payload: string, signature: string, secret: string): boolean;
21 |
22 | /**
23 | * Process webhook event
24 | */
25 | processWebhook(payload: any, signature: string): Promise;
26 | }
27 |
28 | export interface CheckoutOptions {
29 | successUrl?: string;
30 | cancelUrl?: string;
31 | metadata?: Record;
32 | }
33 |
34 | export interface WebhookResult {
35 | success: boolean;
36 | userId?: string;
37 | action?: "subscription_created" | "subscription_updated" | "subscription_cancelled" | "payment_succeeded" | "payment_failed";
38 | expiresAt?: Date;
39 | planId?: string;
40 | platform?: "WEB" | "IOS" | "ANDROID";
41 | paymentId?: string;
42 | amount?: number;
43 | currency?: string;
44 | error?: string;
45 | }
46 |
--------------------------------------------------------------------------------