chore: first commit

This commit is contained in:
Mathias
2025-06-11 15:23:25 +02:00
commit 77b44b32e0
300 changed files with 26963 additions and 0 deletions
+165
View File
@@ -0,0 +1,165 @@
## 🧑‍💻 Development Guidelines
This project follows **Next.js (App Router)** and is structured using **Feature-Sliced Design (FSD)** for modularity, scalability, and clear
separation of concerns.
Use this prompt and coding standards to ensure consistency across the codebase:
---
### 🔧 Code Style and Structure
- Write concise, expressive, and idiomatic **TypeScript**
- Use **functional programming** patterns (avoid classes and side effects)
- Prefer **composition** over inheritance, and modularization over duplication
- Organize each `feature/`, `entity/`, or `widget/` with:
- model/ → logic (React Query, actions, hooks)
- schema/ → Zod schemas for validation ui/ → client components (TSX)
- lib/ → pure helper functions
- types/ → interfaces & TS types
- All external dependencies (**API**, `localStorage`, `Date`) must be **abstracted** in `shared/lib/`
- Avoid direct calls to:
- `fetch` → use actions or `shared/api/`
- `new Date()` → use `shared/lib/date` abstraction
- `localStorage` → wrap in `shared/lib/storage`
---
### 🧠 Naming Conventions
- Use `kebab-case` for **directories** (e.g. `features/auth/signup`)
- Use **named exports** (no default exports for components)
- Use descriptive names with **auxiliary verbs** (e.g. `isLoading`, `hasError`, `canSubmit`)
- Components:
- Pure UI: `src/components/ui/`
- Shared logic: `src/shared/lib/`
- Composition: `src/widgets/`
---
### 📐 TypeScript Usage
- Use `interface` over `type` for objects
- Avoid `enum`; use `as const` object maps instead
- Use `infer` and `z.infer<typeof schema>` for accurate form types
- Types live in `types/` or colocated with usage
---
### 📦 Feature Architecture
**Keep React component logic inside the relevant feature:**
features/auth/signup/ ├── model/ → useSignUp.ts, signup.action.ts ├── schema/ → signup.schema.ts ├── ui/ → signup-form.tsx
If reusable between many features (e.g. `User`, `Link`, `Session`), move logic to `entities/`.
---
### 🧪 Error Handling & Validation
- Use **Zod** for schema validation
- Prefer early returns & guard clauses
- Use `ActionError` in server actions and handle them with `next-safe-action`
- Wrap React components in `ErrorBoundary` (or `shared/ui/ErrorBoundaries.tsx`)
- Display user-friendly errors via `toast()` or `<Alert />`
---
### 💅 UI & Styling
- Use **Shadcn UI**, **Radix**, and **Tailwind CSS** with **mobile-first** responsive design
- Design theme:
- **Minimal**, professional with a **slightly playful touch**
- Inspired by **Apple**, tailored to fitness coaches
- Emphasize visuals: badges, progress bars, illustrations
- Use `lucide-react` icons, subtle borders, hover feedback
- Avoid drop shadows; prefer light borders and soft hover effects
- Animations:
- Elegant and performant (use `framer-motion` if needed)
- Use `transition`, `duration-xxx`, and `ease-xxx` from Tailwind
- UX Principles:
- Clear hierarchy
- Responsive: no overflow, no overlap
- All buttons and interactive elements should provide feedback
- Use @tailwind.config.ts for the theme.
- **UI Stack**:
- **Shadcn UI**, **Radix UI**, and **Tailwind CSS** (mobile-first approach)
- Icons: **lucide-react**
- **Design Language**:
- 🎨 **Modern & minimalist**, inspired by **Apples design system**, with a **slightly more colorful palette**
- Interface should be **clean**, **cohesive**, and **functional** without sacrificing features
- Avoid drop shadows; prefer **subtle borders** where relevant
- Ensure a **clear visual hierarchy** and **intuitive navigation**
- **Interactive Components**:
- Buttons and inputs must be **elegant**, with **subtle visual feedback** (hover, click, validation)
- Use **addictive micro-interactions** sparingly to enhance engagement without clutter
- **Animations**:
- Use Tailwinds built-in utilities: `transition`, `duration-xxx`, `ease-xxx` for basic transitions
- Use `framer-motion` for advanced animations only if necessary
- ✅ **Performance comes first**: animations must be smooth and lightweight
- **Responsiveness**:
- Fully responsive layout: **no overlapping**, **no overflow**
- Consistent behavior across all devices, from mobile to desktop
- **User Experience**:
- All interactive elements must provide **clear visual feedback**
- Interfaces should remain **simple to navigate**, even when **feature-rich**
---
### 🧱 Rendering & Performance
- Favor **Server Components** (`RSC`) and SSR for pages and logic
- Limit `'use client'` usage — only where needed:
- form states, event listeners, animations
- Wrap all client components in `<Suspense />` with fallback
- Use dynamic import for non-critical UI (e.g. `Dialog`, `Chart`)
- Optimize media:
- Use **WebP** images with width/height
- Enable lazy loading where possible
---
### 🔍 Data, Forms, Actions
- Use `@tanstack/react-query` for client state
- Use `next-safe-action` for server mutations and queries
- All actions should:
- Have clear schema (`schema/`)
- Model expected errors with `ActionError`
- Return typed output
- Use the clientAction from `@/shared/api/safe-actions`
- Use `Form`, `FormField`, `FormMessage` from Shadcn for all forms
---
### 🧭 Routing & Navigation
- All routes defined in `app/`, avoid logic here
- Use constants in `shared/constants/paths.ts`
- For search parameters, use `nuqs` (`useQueryState`) — never manipulate `router.query` directly
- Follow Next.js App Router standards for layouts and segments
---
- [Feature-Sliced Design](https://feature-sliced.design/)
- [Shadcn UI](https://ui.shadcn.com/)
- [Zod](https://zod.dev/)
+41
View File
@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+2
View File
@@ -0,0 +1,2 @@
public-hoist-pattern[]=*import-in-the-middle*
public-hoist-pattern[]=*require-in-the-middle*
+6
View File
@@ -0,0 +1,6 @@
{
"plugins": ["prettier-plugin-sort-json"],
"printWidth": 140,
"proseWrap": "always",
"singleQuote": false
}
+3
View File
@@ -0,0 +1,3 @@
{
"typescript.tsdk": "node_modules/typescript/lib"
}
+34
View File
@@ -0,0 +1,34 @@
### Review Process
- All PRs require at least one review from a maintainer
- Address review feedback promptly
- Keep discussions constructive and respectful
- PRs will be merged using "Squash and merge"
## Getting Help
### Where to Ask Questions
- **GitHub Discussions**: For general questions and ideas
- **GitHub Issues**: For bug reports and feature requests
- **Discord**: [Join our community](https://discord.gg/workout-cool) for real-time chat (incoming)
### Resources
- [Feature-Sliced Design](https://feature-sliced.design/)
- [Next.js Documentation](https://nextjs.org/docs)
- [Prisma Documentation](https://www.prisma.io/docs/)
## Recognition
Contributors are recognized in:
- GitHub contributors list
- Project documentation
- Release notes for significant contributions
Thank you for contributing to Workout Cool! 🏋️‍♂️💪
---
**Questions?** Feel free to open an issue or reach out to the maintainers.
+221
View File
@@ -0,0 +1,221 @@
<div align="center">
<h1>Workout Cool</h1>
<h3><em>Modern fitness coaching platform with comprehensive exercise database</em></h3>
<p>
<img src="https://img.shields.io/github/contributors/mathiasbradiceanu/workout-cool?style=plastic" alt="Contributors">
<img src="https://img.shields.io/github/forks/mathiasbradiceanu/workout-cool" alt="Forks">
<img src="https://img.shields.io/github/stars/mathiasbradiceanu/workout-cool" alt="Stars">
<img src="https://img.shields.io/github/issues/mathiasbradiceanu/workout-cool" alt="Issues">
<img src="https://img.shields.io/github/languages/count/mathiasbradiceanu/workout-cool" alt="Languages">
<img src="https://img.shields.io/github/repo-size/mathiasbradiceanu/workout-cool" alt="Repository Size">
</p>
</div>
## About
A comprehensive fitness coaching platform that allows trainers to manage their clients, create workout plans, track progress, and access a
vast exercise database with detailed instructions and video demonstrations.
## Features
- 🏋️ **Comprehensive Exercise Database** - Thousands of exercises with detailed descriptions, videos, and muscle targeting
- 👨‍🏫 **Coach Management** - Tools for fitness coaches to manage their clients
- 📊 **Progress Tracking** - Monitor client progress and workout statistics
- 🎯 **Custom Workouts** - Create personalized workout routines
- 🌐 **Multi-language Support** - English and French translations
- 📱 **Responsive Design** - Works seamlessly on desktop and mobile
## Tech Stack
- **Framework**: Next.js 14 with App Router
- **Database**: PostgreSQL with Prisma ORM
- **Authentication**: Better Auth
- **Language**: TypeScript
- **Architecture**: Feature-Sliced Design (FSD)
## Quick Start
### Prerequisites
- Node.js 18+
- PostgreSQL database
- pnpm (recommended) or npm
### Installation
1. **Clone the repository**
```bash
git clone https://github.com/mathiasbradiceanu/workout-cool.git
cd workout-cool
```
2. **Install dependencies**
```bash
pnpm install
```
3. **Set up environment variables**
```bash
cp .env.example .env
```
Fill in your database URL and other required environment variables:
```env
DATABASE_URL="postgresql://username:password@localhost:5432/workout_cool"
BETTER_AUTH_SECRET="your-secret-key"
# ... other variables
```
4. **Set up the database**
```bash
npx prisma migrate deploy
npx prisma generate
```
5. **Start the development server**
```bash
pnpm dev
```
6. **Open your browser** Navigate to [http://localhost:3000](http://localhost:3000)
## Exercise Database Import
The project includes a comprehensive exercise database. To import a sample of exercises:
### Prerequisites for Import
1. **Prepare your CSV file**
Your CSV should have these columns:
```
id,name,name_en,description,description_en,full_video_url,full_video_image_url,introduction,introduction_en,slug,slug_en,attribute_name,attribute_value
```
You can use the provided example.
### Import Commands
```bash
# Import exercises from a CSV file
pnpm run import:exercises-full /path/to/your/exercises.csv
# Example with the provided sample data
pnpm run import:exercises-full ./data/sample-exercises.csv
```
### CSV Format Example
```csv
id,name,name_en,description,description_en,full_video_url,full_video_image_url,introduction,introduction_en,slug,slug_en,attribute_name,attribute_value
157,"Fentes arrières à la barre","Barbell Reverse Lunges","<p>Stand upright...</p>","<p>Stand upright...</p>",https://youtube.com/...,https://img.youtube.com/...,slug-fr,slug-en,TYPE,STRENGTH
157,"Fentes arrières à la barre","Barbell Reverse Lunges","<p>Stand upright...</p>","<p>Stand upright...</p>",https://youtube.com/...,https://img.youtube.com/...,slug-fr,slug-en,PRIMARY_MUSCLE,QUADRICEPS
```
### Available Attribute Types
- **TYPE**: `STRENGTH`, `CARDIO`, `PLYOMETRICS`, `STRETCHING`, etc.
- **PRIMARY_MUSCLE**: `QUADRICEPS`, `CHEST`, `BACK`, `SHOULDERS`, etc.
- **SECONDARY_MUSCLE**: Secondary muscle groups targeted
- **EQUIPMENT**: `BARBELL`, `DUMBBELL`, `BODYWEIGHT`, `MACHINE`, etc.
- **MECHANICS_TYPE**: `COMPOUND`, `ISOLATION`
## Project Architecture
This project follows **Feature-Sliced Design (FSD)** principles with Next.js App Router:
src/ ├── app/ # Next.js pages, routes and layouts ├── processes/ # Business flows (multi-feature) ├── widgets/ # Composable UI with logic
(Sidebar, Header) ├── features/ # Business units (auth, exercise-management) ├── entities/ # Domain entities (user, exercise, workout) ├──
shared/ # Shared code (UI, lib, config, types) └── styles/ # Global CSS, themes
### Architecture Principles
- **Feature-driven**: Each feature is independent and reusable
- **Clear domain isolation**: `shared` → `entities` → `features` → `widgets` → `app`
- **Consistency**: Between business logic, UI, and data layers
### Example Feature Structure
features/ └── exercise-management/ ├── ui/ # UI components (ExerciseForm, ExerciseCard) ├── model/ # Hooks, state management (useExercises)
├── lib/ # Utilities (exercise-helpers) └── api/ # Server actions or API calls
## Contributing
We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details.
### Development Workflow
1. Fork the repository
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
3. Make your changes
4. Commit your changes (`git commit -m 'feat: add amazing feature'`)
5. Push to the branch (`git push origin feature/amazing-feature`)
6. Open a Pull Request
### Code Style
- Follow TypeScript best practices
- Use Feature-Sliced Design architecture
- Write meaningful commit messages
- Add tests for new features
## Deployment
### Using Docker
```bash
# Build the Docker image
docker build -t workout-cool .
# Run the container
docker run -p 3000:3000 workout-cool
```
### Manual Deployment
```bash
# Build the application
pnpm build
# Run database migrations
export DATABASE_URL="your-production-db-url"
npx prisma migrate deploy
# Start the production server
pnpm start
```
## Resources
- [Feature-Sliced Design](https://feature-sliced.design/)
- [Next.js Documentation](https://nextjs.org/docs)
- [Prisma Documentation](https://www.prisma.io/docs/)
- [Better Auth](https://github.com/better-auth/better-auth)
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
[![MIT License](https://img.shields.io/badge/License-MIT-green.svg)](https://choosealicense.com/licenses/mit/)
## Support
If you found this project helpful, consider:
- ⭐ Starring the repository
- 🐛 Reporting bugs
- 💡 Suggesting new features
- 🤝 Contributing to the codebase
---
<div align="center">
Made with ❤️ by fitness enthusiasts for the fitness community
</div>
@@ -0,0 +1,23 @@
import { LayoutParams } from "@/shared/types/next";
import { Footer } from "@/features/layout/Footer";
type LocaleParams = Record<string, string> & {
locale: string;
};
export default function RouteLayout({ children, params: _ }: LayoutParams<LocaleParams>) {
return (
<div className="bg-muted/30 text-foreground flex min-h-screen flex-col">
{/* Fixe l'espace sous le header flottant */}
<div className="h-16" />
{/* Contenu principal centré avec marge */}
<main className="flex-1 px-4 py-12">
<div className="mx-auto w-full max-w-4xl">{children}</div>
</main>
{/* Pied de page */}
<Footer />
</div>
);
}
@@ -0,0 +1,30 @@
import { getLocalizedMdx } from "@/shared/lib/mdx/load-mdx";
import { Typography } from "@/components/ui/typography";
type PageProps = {
params: Promise<{ locale: string }>;
};
export default async function PrivacyPolicyPage({ params }: PageProps) {
const { locale } = await params;
const content = await getLocalizedMdx("privacy-policy", locale);
return (
<div className="bg-muted/50 py-12">
<div className="container mx-auto max-w-4xl px-4">
<header className="mb-10 text-center">
<Typography className="mb-2 text-3xl md:text-4xl" variant="h1">
{locale === "fr" ? "Politique de Confidentialité" : "Privacy Policy"}
</Typography>
<p className="text-muted-foreground text-base md:text-lg">
{locale === "fr"
? "Voici comment nous traitons vos données personnelles."
: "How we handle your personal data at Workout Cool."}
</p>
</header>
<div className="prose prose-neutral max-w-none dark:prose-invert">{content}</div>
</div>
</div>
);
}
@@ -0,0 +1,33 @@
import { getLocalizedMdx } from "@/shared/lib/mdx/load-mdx";
import { Layout, LayoutContent } from "@/features/page/layout";
import { Typography } from "@/components/ui/typography";
type PageProps = {
params: Promise<{ locale: string }>;
};
export default async function SalesTermsPage({ params }: PageProps) {
const { locale } = await params;
const content = await getLocalizedMdx("sales-terms", locale);
return (
<div className="bg-muted/50 py-12">
<div className="container mx-auto max-w-4xl px-4">
<header className="mb-10 text-center">
<Typography className="mb-2 text-3xl md:text-4xl" variant="h1">
{locale === "fr" ? "Conditions Générales de Vente" : "General Terms of Sale"}
</Typography>
<p className="text-muted-foreground text-base md:text-lg">
{locale === "fr"
? "Les conditions qui régissent lachat dun abonnement Workout Cool."
: "The terms governing the purchase of a Workout Cool subscription."}
</p>
</header>
<Layout>
<LayoutContent className="prose prose-neutral max-w-none dark:prose-invert">{content}</LayoutContent>
</Layout>
</div>
</div>
);
}
@@ -0,0 +1,33 @@
import { getLocalizedMdx } from "@/shared/lib/mdx/load-mdx";
import { Layout, LayoutContent } from "@/features/page/layout";
import { Typography } from "@/components/ui/typography";
type PageProps = {
params: Promise<{ locale: string }>;
};
export default async function TermsPage({ params }: PageProps) {
const { locale } = await params;
const content = await getLocalizedMdx("terms", locale);
return (
<div className="bg-muted/50 py-12">
<div className="container mx-auto max-w-4xl px-4">
<header className="mb-10 text-center">
<Typography className="mb-2 text-3xl md:text-4xl" variant="h1">
{locale === "fr" ? "Conditions Générales dUtilisation" : "Terms of Use"}
</Typography>
<p className="text-muted-foreground text-base md:text-lg">
{locale === "fr"
? "Merci de lire attentivement ces conditions avant dutiliser nos services."
: "Please read these terms carefully before using our services."}
</p>
</header>
<Layout>
<LayoutContent className="prose prose-neutral max-w-none dark:prose-invert">{content}</LayoutContent>
</Layout>
</div>
</div>
);
}
@@ -0,0 +1,30 @@
import Link from "next/link";
import { buttonVariants } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Layout, LayoutContent, LayoutDescription, LayoutHeader, LayoutTitle } from "@/features/page/layout";
export default function CancelPaymentPage() {
return (
<Layout>
<LayoutHeader>
<Badge variant="outline">Payment failed</Badge>
<LayoutTitle>We&apos;re sorry, but we couldn&apos;t process your payment</LayoutTitle>
<LayoutDescription>
We encountered an issue processing your payment.
<br /> Please check your payment details and try again. <br />
If the problem persists, don&apos;t hesitate to contact us for assistance.
<br />
We&apos;re here to help you resolve this smoothly.
</LayoutDescription>
</LayoutHeader>
<LayoutContent className="flex items-center gap-2">
<Link className={buttonVariants({ variant: "default" })} href="/">
Home
</Link>
{/* <ContactSupportDialog /> */}
</LayoutContent>
</Layout>
);
}
@@ -0,0 +1,25 @@
import Link from "next/link";
import { Layout, LayoutContent, LayoutDescription, LayoutHeader, LayoutTitle } from "@/features/page/layout";
import { buttonVariants } from "@/components/ui/button";
export default function SuccessPaymentPage() {
return (
<>
<Layout>
<LayoutHeader>
<LayoutTitle>Thank You for Your Purchase!</LayoutTitle>
<LayoutDescription>
Your payment was successful! You now have full access to all our premium resources. If you have any questions, we&apos;re here
to help.
</LayoutDescription>
</LayoutHeader>
<LayoutContent>
<Link className={buttonVariants({ size: "large" })} href="/">
Get Started
</Link>
</LayoutContent>
</Layout>
</>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { ReactElement } from "react";
interface RootLayoutProps {
params: Promise<{ locale: string }>;
children: ReactElement;
}
export default async function RootLayout({ children }: RootLayoutProps) {
return (
<div>
{children}
</div>
)
}
+22
View File
@@ -0,0 +1,22 @@
import { getI18n } from "locales/server";
import { SiteConfig } from "@/shared/config/site-config";
import type { MetadataRoute } from "next";
export default async function manifest(): Promise<MetadataRoute.Manifest> {
const t = await getI18n();
return {
name: SiteConfig.title,
short_name: SiteConfig.title,
description: SiteConfig.description,
start_url: "/",
display: "standalone",
background_color: "#fff",
theme_color: SiteConfig.brand.primary,
icons: [
{ src: "/android-chrome-192x192.png", sizes: "192x192", type: "image/png" },
{ src: "/android-chrome-512x512.png", sizes: "512x512", type: "image/png" },
],
};
}
@@ -0,0 +1,5 @@
import { Page404 } from "@/features/page/Page404";
export default function NotFoundPage() {
return <Page404 />;
}
@@ -0,0 +1,5 @@
import { Page404 } from "@/features/page/Page404";
export default function AdminCatchAll() {
return <Page404 />;
}
+31
View File
@@ -0,0 +1,31 @@
import { ReactElement } from "react";
import { redirect } from "next/navigation";
import { UserRole } from "@prisma/client";
import { AuthenticatedHeader } from "@/features/layout/authenticated-header";
import { AdminSidebar } from "@/features/admin/layout/admin-sidebar/ui/admin-sidebar";
import { serverRequiredUser } from "@/entities/user/model/get-server-session-user";
interface AdminLayoutProps {
params: Promise<{ locale: string }>;
children: ReactElement;
}
export default async function AdminLayout({ children }: AdminLayoutProps) {
const user = await serverRequiredUser();
if (user.role !== UserRole.admin) {
redirect("/");
}
return (
<div className="main-content">
<AuthenticatedHeader />
<AdminSidebar />
<div className="mt-[60px] p-4 px-2 transition-all sm:px-4 lg:ml-[260px]" id="main-content">
{children}
</div>
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { Page404 } from "@/widgets/404";
export default function NotFoundPage() {
return <Page404 />;
}
+28
View File
@@ -0,0 +1,28 @@
import { redirect } from "next/navigation";
import { UserRole } from "@prisma/client";
import { UsersTable } from "@/features/admin/users/list/ui/users-table";
import { getUsersAction } from "@/entities/user/model/get-users.actions";
import { serverRequiredUser } from "@/entities/user/model/get-server-session-user";
export default async function AdminDashboardPage() {
const user = await serverRequiredUser();
// Rediriger si l'utilisateur n'est pas admin
if (user.role !== UserRole.admin) {
redirect("/");
}
// Récupérer les données initiales des utilisateurs
const initialUsers = await getUsersAction({
page: 1,
limit: 10,
});
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">Tableau de bord administrateur</h1>
<UsersTable initialUsers={initialUsers} />
</div>
);
}
@@ -0,0 +1,5 @@
import { ForgotPasswordForm } from "@/features/auth/forgot-password/ui/forgot-password-form";
export default async function ForgotPasswordPage() {
return <ForgotPasswordForm />;
}
@@ -0,0 +1,49 @@
import { redirect } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
import { headers } from "next/headers";
import { getI18n } from "locales/server";
import Logo from "@public/logo.png";
import { paths } from "@/shared/constants/paths";
import { auth } from "@/features/auth/lib/better-auth";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import type { LayoutParams } from "@/shared/types/next";
export default async function AuthLayout(props: LayoutParams<{}>) {
const t = await getI18n();
const headerStore = await headers();
const searchParams = Object.fromEntries(new URLSearchParams(headerStore.get("searchParams") || ""));
const translatedError = t(`next_auth_errors.${searchParams.error}` as keyof typeof t);
const user = await auth.api.getSession({ headers: headerStore });
if (user) {
redirect(`/${paths.dashboard}`);
}
return (
<>
<div>
<div className="flex justify-center gap-2">
<Link className="flex items-center gap-2 font-medium" href={`/${paths.dashboard}`}>
<Image alt="workout cool logo" className="w-16" height={64} src={Logo} width={64} />
</Link>
</div>
{searchParams.error && (
<Alert className="mb-4" variant="error">
<AlertTitle>{translatedError}</AlertTitle>
<AlertDescription>{t("signin_error_subtitle")}</AlertDescription>
</Alert>
)}
<div className="flex flex-1 items-center justify-center">
<div className="w-full max-w-md">{props.children}</div>
</div>
</div>
</>
);
}
@@ -0,0 +1,5 @@
import { ResetPasswordForm } from "@/features/auth/reset-password/ui/reset-password-form";
export default function ResetPasswordPage() {
return <ResetPasswordForm />;
}
@@ -0,0 +1,19 @@
import { cookies } from "next/headers";
import { isEU } from "@/shared/lib/location/location";
import { Cookies } from "@/shared/constants/cookies";
import { ConsentBanner } from "@/features/consent-banner/ui/consent-banner";
import { CredentialsLoginForm } from "@/features/auth/signin/ui/CredentialsLoginForm";
export default async function AuthSignInPage() {
const cookiesStore = await cookies();
const isEuropeanUnion = await isEU();
const showTrackingConsent = isEuropeanUnion && !cookiesStore.has(Cookies.TrackingConsent);
return (
<>
<CredentialsLoginForm />
{showTrackingConsent && <ConsentBanner />}
</>
);
}
@@ -0,0 +1,38 @@
import Link from "next/link";
import { getI18n } from "locales/server";
import { SignUpForm } from "@/features/auth/signup/ui/signup-form";
export const metadata = {
title: "Sign Up - Workout.cool",
description: "Créez votre compte pour commencer",
};
export default async function AuthSignUpPage() {
const t = await getI18n();
return (
<div className="container mx-auto max-w-lg px-4 py-8">
<div className="mb-8 space-y-2">
<h1 className="text-3xl font-bold tracking-tight">{t("register_title")}</h1>
<p className="text-muted-foreground">{t("register_description")}</p>
</div>
<SignUpForm />
<div className="text-muted-foreground mt-6 text-center text-sm">
<p>
{t("register_terms")}{" "}
<Link className="font-medium text-primary underline-offset-4 hover:underline" href="/terms">
{t("register_privacy")}
</Link>{" "}
{t("register_privacy_link")}{" "}
<Link className="font-medium text-primary underline-offset-4 hover:underline" href="/privacy">
{t("register_privacy_link_2")}
</Link>
.
</p>
</div>
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
"use client";
import { useEffect } from "react";
import { logger } from "@/shared/lib/logger";
import { Card, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import type { ErrorParams } from "@/shared/types/next";
export default function RouteError({ error, reset }: ErrorParams) {
useEffect(() => {
// Log the error to an error reporting service
logger.error(error);
}, [error]);
return (
<Card>
<CardHeader>
<CardTitle>Sorry, something went wrong. Please try again later.</CardTitle>
</CardHeader>
<CardFooter>
<Button onClick={reset}>Try again</Button>
</CardFooter>
</Card>
);
}
+24
View File
@@ -0,0 +1,24 @@
import Link from "next/link";
import { Card, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
export default async function AuthErrorPage({ params }: { params: Promise<{ error: string }> }) {
const result = await params;
return (
<div className="flex h-full flex-col">
<Card>
<CardHeader>
<CardTitle>Error</CardTitle>
<CardDescription>{result.error}</CardDescription>
</CardHeader>
<CardFooter className="flex items-center gap-2">
<Link className={buttonVariants({ size: "small" })} href="/">
Home
</Link>
</CardFooter>
</Card>
</div>
);
}
+3
View File
@@ -0,0 +1,3 @@
export default function AuthLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
+3
View File
@@ -0,0 +1,3 @@
export default function AuthSignOutPage() {
return <div>AuthSignOutPage</div>;
}
+21
View File
@@ -0,0 +1,21 @@
import { ReactElement } from "react";
import { redirect } from "next/navigation";
import { getServerUrl } from "@/shared/lib/server-url";
import { paths } from "@/shared/constants/paths";
import { serverRequiredUser } from "@/entities/user/model/get-server-session-user";
interface RootLayoutProps {
params: Promise<{ locale: string }>;
children: ReactElement;
}
export default async function RootLayout({ children }: RootLayoutProps) {
const auth = await serverRequiredUser();
if (auth.emailVerified) {
redirect(`${getServerUrl()}/${paths.dashboard}`);
}
return <div>{children}</div>;
}
+7
View File
@@ -0,0 +1,7 @@
"use client";
import { VerifyEmailPage } from "@/features/auth/verify-email/ui/verify-email-page";
export default function VerifyEmailRootPage() {
return <VerifyEmailPage />;
}
+33
View File
@@ -0,0 +1,33 @@
import Image from "next/image";
import { SiteConfig } from "@/shared/config/site-config";
import { Typography } from "@/components/ui/typography";
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
interface VerifyRequestPageParams {
params: Promise<Record<string, string>>;
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}
export default async function AuthVerifyRequestPage({ params: _p, searchParams: _s }: VerifyRequestPageParams) {
return (
<div className="h-full">
<header className="flex items-center gap-2 px-4 pt-4">
<Image alt="app icon" height={32} src={SiteConfig.appIcon} width={32} />
<Typography variant="h2">{SiteConfig.title}</Typography>
</header>
<div className="flex h-full items-center justify-center">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Almost There!</CardTitle>
<CardDescription>
{
"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!"
}
</CardDescription>
</CardHeader>
</Card>
</div>
</div>
);
}
+135
View File
@@ -0,0 +1,135 @@
import Image from "next/image";
import { Inter, Permanent_Marker } from "next/font/google";
import { GeistSans } from "geist/font/sans";
import { GeistMono } from "geist/font/mono";
import { cn } from "@/shared/lib/utils";
import { getServerUrl } from "@/shared/lib/server-url";
import { FB_PIXEL_ID } from "@/shared/lib/facebook/fb-pixel";
import { SiteConfig } from "@/shared/config/site-config";
import { Header } from "@/features/layout/Header";
import { Footer } from "@/features/layout/Footer";
import { TailwindIndicator } from "@/components/utils/TailwindIndicator";
import { NextTopLoader } from "@/components/ui/next-top-loader";
import FacebookPixel from "@/components/FacebookPixel";
import { Providers } from "./providers";
import type { ReactElement } from "react";
import type { Metadata } from "next";
import "@/shared/styles/globals.css";
export const metadata: Metadata = {
title: SiteConfig.title,
description: SiteConfig.description,
metadataBase: new URL(getServerUrl()),
};
const inter = Inter({
subsets: ["latin"],
variable: "--font-inter",
display: "swap",
});
const permanentMarker = Permanent_Marker({
weight: "400",
subsets: ["latin"],
variable: "--font-permanent-marker",
display: "swap",
});
export const preferredRegion = ["fra1", "sfo1", "iad1"];
interface RootLayoutProps {
params: Promise<{ locale: string }>;
children: ReactElement;
}
export default async function RootLayout({ params, children }: RootLayoutProps) {
const { locale } = await params;
return (
<>
<html className="h-full" dir="ltr" lang={locale} suppressHydrationWarning>
<head>
<meta charSet="UTF-8" />
<meta content="width=device-width, initial-scale=1" name="viewport" />
{/* SEO */}
<meta content="index, follow" name="robots" />
<meta content="Workout Cool" name="author" />
{/* Favicon */}
<link href="/apple-touch-icon.png" rel="apple-touch-icon" sizes="180x180" />
<link href="/images/favicon-32x32.png" rel="icon" sizes="32x32" type="image/png" />
<link href="/images/favicon-16x16.png" rel="icon" sizes="16x16" type="image/png" />
<link href="/images/favicon.ico" rel="shortcut icon" />
{/* Open Graph */}
<meta content={SiteConfig.title} property="og:title" />
<meta content={SiteConfig.description} property="og:description" />
<meta content={"https://www.workout.cool"} property="og:url" />
<meta content="website" property="og:type" />
<meta content={`${getServerUrl()}/images/default-og-image_${locale}.png`} property="og:image" />
{/* Twitter */}
<meta content="summary_large_image" name="twitter:card" />
<meta content="@workout_cool" name="twitter:site" />
<meta content={SiteConfig.title} name="twitter:title" />
<meta content={SiteConfig.description} name="twitter:description" />
<meta content={`${getServerUrl()}/images/default-og-image_${locale}.png`} name="twitter:image" />
{/* Canonical */}
<link href="https://www.workout.cool" rel="canonical" />
{/* Open Graph Locale */}
<meta content={locale === "fr" ? "fr_FR" : "en_US"} property="og:locale" />
<meta content="fr_FR" property="og:locale:alternate" />
<meta content="en_US" property="og:locale:alternate" />
<noscript>
<Image
alt="Facebook Pixel"
height="1"
src={`https://www.facebook.com/tr?id=${FB_PIXEL_ID}&ev=PageView&noscript=1`}
style={{ display: "none" }}
width="1"
/>
</noscript>
</head>
<body
className={cn(
"flex flex-col justify-between items-center p-8 min-h-screen max-sm:p-0 max-sm:min-h-full text-sm/[22px] font-normal text-black antialiased dark:text-gray-500",
GeistMono.variable,
GeistSans.variable,
inter.variable,
permanentMarker.variable,
)}
style={{
backgroundImage:
"radial-gradient(circle at 82% 60%, rgba(59, 59, 59,0.06) 0%, rgba(59, 59, 59,0.06) 69%,transparent 69%, transparent 100%),radial-gradient(circle at 36% 0%, rgba(185, 185, 185,0.06) 0%, rgba(185, 185, 185,0.06) 59%,transparent 59%, transparent 100%),radial-gradient(circle at 58% 82%, rgba(183, 183, 183,0.06) 0%, rgba(183, 183, 183,0.06) 17%,transparent 17%, transparent 100%),radial-gradient(circle at 71% 32%, rgba(19, 19, 19,0.06) 0%, rgba(19, 19, 19,0.06) 40%,transparent 40%, transparent 100%),radial-gradient(circle at 77% 5%, rgba(31, 31, 31,0.06) 0%, rgba(31, 31, 31,0.06) 52%,transparent 52%, transparent 100%),radial-gradient(circle at 96% 80%, rgba(11, 11, 11,0.06) 0%, rgba(11, 11, 11,0.06) 73%,transparent 73%, transparent 100%),radial-gradient(circle at 91% 59%, rgba(252, 252, 252,0.06) 0%, rgba(252, 252, 252,0.06) 44%,transparent 44%, transparent 100%),radial-gradient(circle at 52% 82%, rgba(223, 223, 223,0.06) 0%, rgba(223, 223, 223,0.06) 87%,transparent 87%, transparent 100%),radial-gradient(circle at 84% 89%, rgba(160, 160, 160,0.06) 0%, rgba(160, 160, 160,0.06) 57%,transparent 57%, transparent 100%),linear-gradient(90deg, rgb(254,242,164),rgb(166, 255, 237))",
}}
suppressHydrationWarning
>
<Providers locale={locale}>
<NextTopLoader color="#FF5722" delay={100} showSpinner={false} />
{/* Main Card Container */}
<div className="card bg-base-100 shadow-xl w-full max-w-3xl max-sm:rounded-none max-sm:h-full">
<div className="card-body p-0">
<Header />
<div className="px-2 sm:px-6 pb-6">{children}</div>
</div>
<Footer />
</div>
<TailwindIndicator />
<FacebookPixel />
</Providers>
</body>
</html>
</>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { Page404 } from "@/widgets/404";
export default function NotFoundPage() {
return <Page404 />;
}
+7
View File
@@ -0,0 +1,7 @@
import { LayoutParams } from "@/shared/types/next";
export default async function OnboardingLayout(props: LayoutParams<{}>) {
// TODO: add onboarding logic
return props.children;
}
+7
View File
@@ -0,0 +1,7 @@
export default async function OnboardingPage() {
return (
<main className="bg-muted flex min-h-screen flex-col items-center justify-center">
<div>Onboarding</div>
</main>
);
}
+15
View File
@@ -0,0 +1,15 @@
import React from "react";
import { getI18n } from "locales/server";
import { WorkoutStepper } from "@/features/workout-builder";
import { serverAuth } from "@/entities/user/model/get-server-session-user";
export default async function HomePage() {
const user = await serverAuth();
const t = await getI18n();
return (
<div className="bg-background text-foreground relative flex h-fit flex-col">
<WorkoutStepper />
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
"use client";
import { NuqsAdapter } from "nuqs/adapters/next/app";
import PlausibleProvider from "next-plausible";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProviderClient } from "locales/client";
import { AnalyticsProvider } from "@/shared/lib/analytics/client";
import { SiteConfig } from "@/shared/config/site-config";
import { DialogRenderer } from "@/features/dialogs-provider/DialogProvider";
import { ToastSonner } from "@/components/ui/ToastSonner";
import { Toaster } from "@/components/ui/toaster";
import { ThemeProvider } from "@/components/ui/theme-provider";
import type { PropsWithChildren } from "react";
const queryClient = new QueryClient();
export const Providers = ({ children, locale }: PropsWithChildren<{ locale: string }>) => {
return (
<>
<AnalyticsProvider />
<NuqsAdapter>
<QueryClientProvider client={queryClient}>
<I18nProviderClient locale={locale}>
<ThemeProvider attribute="class" defaultTheme="system" disableTransitionOnChange enableSystem>
<PlausibleProvider domain={SiteConfig.domain}>
<Toaster />
<ToastSonner />
<DialogRenderer />
<ReactQueryDevtools initialIsOpen={false} />
{children}
</PlausibleProvider>
</ThemeProvider>
</I18nProviderClient>
</QueryClientProvider>
</NuqsAdapter>
</>
);
};
+5
View File
@@ -0,0 +1,5 @@
import { toNextJsHandler } from "better-auth/next-js";
import { auth } from "@/features/auth/lib/better-auth";
export const { POST, GET } = toNextJsHandler(auth);
+38
View File
@@ -0,0 +1,38 @@
import { z } from "zod";
import { NextResponse } from "next/server";
import { logger } from "@/shared/lib/logger";
import type { NextRequest } from "next/server";
const StripeWebhookSchema = z.object({
type: z.string(),
created_at: z.string(),
data: z.any(),
});
/**
* Resends webhooks
*
* @docs How it work https://resend.com/docs/dashboard/webhooks/introduction
* @docs Event type https://resend.com/docs/dashboard/webhooks/event-types
*/
export const POST = async (req: NextRequest) => {
const body = await req.json();
const event = StripeWebhookSchema.parse(body);
switch (event.type) {
case "email.complained":
logger.warn("Email complained", event.data);
break;
case "email.bounced":
logger.warn("Email bounced", event.data);
break;
}
NextResponse.redirect("");
return NextResponse.json({
ok: true,
});
};
+6
View File
@@ -0,0 +1,6 @@
User-agent: *
Disallow: /admin
Disallow: /api/
Disallow: /dashboard/
Disallow: /preview/
Sitemap: https://www.workout.cool/sitemap.xml
+20
View File
@@ -0,0 +1,20 @@
import { MetadataRoute } from "next/types";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const staticRoutes = [
{
url: "https://www.workout.cool",
lastModified: new Date().toISOString(),
},
{
url: "https://www.workout.cool/auth/signin",
lastModified: new Date().toISOString(),
},
{
url: "https://www.workout.cool/auth/signup",
lastModified: new Date().toISOString(),
},
];
return staticRoutes;
}
+12
View File
@@ -0,0 +1,12 @@
import { WorkoutStepper } from "@/features/workout-builder";
export default function TestStepperPage() {
return (
<div className="min-h-screen bg-base-200 py-8">
<div className="container mx-auto">
<h1 className="text-3xl font-bold text-center mb-8">Workout Builder Test</h1>
<WorkoutStepper />
</div>
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"aliases": {
"components": "@/components",
"utils": "@/shared/lib/utils",
"ui": "@/components/ui",
"lib": "@/shared/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide",
"rsc": true,
"style": "new-york",
"tailwind": {
"config": "tailwind.config.ts",
"css": "app/css/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"tsx": true
}
+69
View File
@@ -0,0 +1,69 @@
# WorkoutCool Privacy Policy
## 1. Introduction
At **WorkoutCool**, we take our users' privacy very seriously.
This Privacy Policy explains how we collect, use, and protect your personal data.
By using our services, you agree to the practices described below.
## 2. Information We Collect
**Personal Information**: When you sign up, we collect your name, email address, and other details related to your activity on WorkoutCool.
**Usage Data**: We track interactions with WorkoutCool pages, including clicks, views, and link performance.
**Cookies**: We use cookies to enhance your user experience and monitor usage.
## 3. How We Use Your Information
- **Service delivery**: To provide, maintain, and improve our services.
- **Communication**: To send you updates, notifications, or marketing content (if you have consented).
- **Analytics**: To understand how our services are used and improve them continuously.
## 4. Sharing Data with Third Parties
We may share aggregated or anonymized data with trusted partners for marketing, analytics, or product improvement purposes.
No personal data is sold or shared without your explicit consent.
## 5. Data Retention
Your data is retained for as long as your account is active or necessary to provide our services.
Some data may be archived for legal, administrative, or security purposes.
## 6. Protecting Your Data
We implement industry-standard security measures to prevent unauthorized access, alteration, or deletion of your data.
## 7. Your Rights
You have the following rights:
- **Access**: Request a copy of your personal data.
- **Correction**: Request the correction of inaccurate or incomplete data.
- **Deletion**: Request the deletion of your data, unless retention is required by law.
## 8. Cookies
WorkoutCool uses cookies to:
- enhance navigation,
- track performance,
- personalize displayed content.
You can disable cookies in your browser settings.
## 9. Advertising & Compliance
We comply with advertising policies from platforms like Facebook and Google.
We do not use your data in a way that violates their guidelines or applicable regulations.
## 10. Changes to This Policy
This Privacy Policy may be updated from time to time.
We encourage you to review it regularly.
Continued use of our services implies acceptance of any changes.
## 11. Contact
If you have any questions or concerns regarding this policy, feel free to contact us at:
**[hello@WorkoutCool.io](mailto:hello@WorkoutCool.io)**
+69
View File
@@ -0,0 +1,69 @@
# Politique de Confidentialité WorkoutCool
## 1. Introduction
Chez **WorkoutCool**, nous accordons une grande importance à la confidentialité de nos utilisateurs.
Cette Politique de Confidentialité décrit la manière dont nous collectons, utilisons et protégeons vos informations personnelles.
En utilisant nos services, vous acceptez les pratiques décrites ci-dessous.
## 2. Informations que nous collectons
**Informations personnelles** : lorsque vous vous inscrivez, nous collectons votre nom, votre adresse e-mail, et dautres informations liées à votre activité sur WorkoutCool.
**Données dutilisation** : nous suivons les interactions avec les pages WorkoutCool, notamment les clics, les vues et les performances des liens.
**Cookies** : nous utilisons des cookies pour améliorer votre expérience utilisateur et suivre les interactions.
## 3. Utilisation de vos informations
- **Prestation de service** : fournir, maintenir et améliorer nos services.
- **Communication** : vous envoyer des mises à jour, notifications ou contenus marketing (si vous y avez consenti).
- **Analyse** : comprendre lutilisation de nos services et les améliorer en continu.
## 4. Partage des données avec des tiers
Nous pouvons partager certaines données agrégées ou anonymisées avec des partenaires de confiance à des fins de marketing, danalyse ou damélioration produit.
Aucune donnée personnelle nest vendue ou partagée sans votre consentement explicite.
## 5. Conservation des données
Vos données sont conservées tant que votre compte est actif ou nécessaires à la fourniture de nos services.
Certaines données peuvent être archivées à des fins légales, administratives ou de sécurité.
## 6. Protection de vos données
Nous mettons en œuvre des mesures de sécurité conformes aux standards de lindustrie pour prévenir l'accès, l'altération ou la suppression non autorisée de vos données.
## 7. Vos droits
Vous disposez des droits suivants :
- **Accès** : demander une copie de vos données personnelles.
- **Rectification** : corriger des données inexactes ou incomplètes.
- **Suppression** : demander la suppression de vos données, sauf obligation légale de conservation.
## 8. Cookies
WorkoutCool utilise des cookies pour :
- améliorer la navigation,
- suivre les performances,
- personnaliser le contenu affiché.
Vous pouvez les désactiver dans les paramètres de votre navigateur.
## 9. Publicité & conformité
Nous respectons les politiques publicitaires des plateformes comme Facebook ou Google.
Nous nutilisons pas vos données dune manière contraire à leurs directives ou à la réglementation en vigueur.
## 10. Modifications de cette politique
Cette Politique de Confidentialité peut être modifiée.
Nous vous invitons à la consulter régulièrement.
Lutilisation continue de nos services vaut acceptation des modifications.
## 11. Contact
Pour toute question ou demande concernant cette politique, vous pouvez nous écrire à :
**[hello@WorkoutCool.io](mailto:hello@WorkoutCool.io)**
+93
View File
@@ -0,0 +1,93 @@
# General Terms of Sale WorkoutCool
## ARTICLE 1: Purpose
These terms govern the provision of services offered via the WorkoutCool platform (SaaS model).
By subscribing to a plan or using the WorkoutCool application, the Client fully and unconditionally agrees to these Terms of Sale, along with the Terms of Use and Privacy Policy.
## ARTICLE 2: Definitions
- **Subscriber**: any individual or entity who has subscribed to a paid plan.
- **Subscription**: a paid service granting access to specific features of the application.
- **Application**: the WorkoutCool web application (and mobile app when available).
- **Client**: the user of the platform, whether for personal or professional use.
- **Company**: refers to WorkoutCool, operated by Mathias BRADICEANU.
## ARTICLE 3: Ordering and Activation
Subscriptions are purchased online via WorkoutCool.io.
Access is activated after successful payment validation.
The Company reserves the right to refuse or cancel any order, especially in the case of suspected fraud, abuse, or violation of these terms.
## ARTICLE 4: Pricing and Payment
Subscriptions are billed in advance for the selected period (monthly, yearly, etc.).
Failure to pay will result in immediate suspension of access without notice.
Prices are shown in euros, inclusive of all applicable taxes.
Pricing may change at any time, but only future renewals will be affected.
No refund will be issued in case of suspension due to breach of these terms.
## ARTICLE 5: Duration, Renewal, Trial Period and Withdrawal
Subscriptions are for a fixed term and renew automatically unless cancelled beforehand.
The Client may cancel at any time before the renewal date via their personal account.
A free 14-day trial is offered once per user.
Attempts to bypass this restriction (multiple accounts, false identities) may lead to immediate suspension and legal action.
The right of withdrawal applies in accordance with Article L221-28 of the French Consumer Code, **unless the service is used during the trial period**.
Any withdrawal request must be sent to [support@WorkoutCool.io](mailto:support@WorkoutCool.io) or by registered mail to the Company's registered address.
## ARTICLE 6: Client Obligations
The Client agrees to:
- Not share, transfer or sell access to their account
- Not exploit the trial period fraudulently
- Respect WorkoutCool intellectual property rights
- Avoid any action that harms the platforms integrity or security
- Provide accurate and up-to-date billing and contact information
- Pay their subscription on time
Any violation may result in suspension or deletion of the account without prior notice or compensation.
## ARTICLE 7: Liability
WorkoutCool shall not be liable for:
- Internet-related issues or client-side technical problems
- Temporary unavailability due to maintenance
- Illegal or improper use of the service by third parties
- Data loss due to the Client's failure to back up their content
No guarantee is made as to the suitability of the service for the Clients specific needs.
The Company may suspend, modify, or remove any service feature without obligation to compensate.
## ARTICLE 8: Indemnification
The Client agrees to indemnify and hold harmless WorkoutCool from any claim, loss, or liability arising from misuse, unlawful use, or breach of these terms, including legal and administrative fees.
## ARTICLE 9: Changes to the Terms
WorkoutCool reserves the right to update these Terms of Sale at any time.
The applicable version is the one available on the website at the time of the Client's order or renewal.
Clients are encouraged to consult the most recent version regularly.
## ARTICLE 10: Force Majeure
WorkoutCool shall not be held liable for any failure or delay caused by events beyond its reasonable control, such as:
natural disasters, pandemics, cyberattacks, outages, fire, war, strike, or any unforeseeable event.
## ARTICLE 11: Proof and Archiving
Digital records stored by WorkoutCool systems constitute valid proof of transactions and communications.
Invoices are available at any time in the users account.
## ARTICLE 12: Contact Complaints
For questions, complaints, or issues, Clients can contact WorkoutCool:
- By email: [support@WorkoutCool.io](mailto:support@WorkoutCool.io)
- By mail: Mathias BRADICEANU, Strada Fagului 40F, 077010 Afumați, Romania
- Through the in-app messaging system
+94
View File
@@ -0,0 +1,94 @@
# Conditions Générales de Vente WorkoutCool
## ARTICLE 1 : Objet
Les présentes conditions régissent la fourniture des services proposés via la plateforme WorkoutCool (en mode SaaS).
En souscrivant à un abonnement ou en utilisant l'application WorkoutCool, le Client accepte pleinement et sans réserve les présentes conditions générales de vente, ainsi que les Conditions Générales dUtilisation et la Politique de Confidentialité associées.
## ARTICLE 2 : Définitions
- **Abonné** : toute personne physique ou morale ayant souscrit à un abonnement payant.
- **Abonnement** : service donnant droit à laccès à des fonctionnalités spécifiques de l'application, contre paiement.
- **Application** : l'application web (et mobile le cas échéant) mise à disposition par WorkoutCool.
- **Client** : utilisateur de la plateforme, quil soit à titre personnel ou professionnel.
- **Société** : désigne WorkoutCool, éditée par Mathias BRADICEANU.
## ARTICLE 3 : Commande et activation
La souscription se fait en ligne sur WorkoutCool.io.
L'accès est activé après validation du paiement.
La Société se réserve le droit de refuser ou dannuler toute commande, notamment en cas de suspicion de fraude, dutilisation abusive ou de non-respect des présentes conditions.
## ARTICLE 4 : Conditions financières
Les abonnements sont payants et facturés à lavance pour la période choisie (mensuelle, annuelle…).
Tout défaut de paiement entraîne la suspension immédiate et sans préavis de l'accès au service.
Les prix sont indiqués en euros TTC, et peuvent être révisés à tout moment.
Toute modification tarifaire ne sappliquera quaux renouvellements futurs.
Aucun remboursement ne sera effectué en cas de suspension pour violation des présentes CGV.
## ARTICLE 5 : Durée, reconduction, période dessai et rétractation
Les abonnements sont souscrits pour une durée déterminée, renouvelable tacitement.
Le Client peut résilier son abonnement à tout moment via son espace personnel, avant la date de reconduction.
Une seule période dessai gratuite de 14 jours est autorisée par utilisateur.
Toute tentative de contournement (multi-comptes, fausse identité) pourra faire lobjet de poursuites.
Le droit de rétractation sapplique selon larticle L221-28 du Code de la consommation, **sauf si le service a été pleinement utilisé pendant lessai**.
Toute demande doit être adressée à [support@WorkoutCool.io](mailto:support@WorkoutCool.io), ou par courrier recommandé.
## ARTICLE 6 : Obligations du Client
Le Client sengage à :
- Ne pas céder ou partager son compte à des tiers
- Ne pas créer plusieurs comptes pour bénéficier à nouveau dun essai gratuit
- Ne pas perturber le bon fonctionnement de la plateforme
- Respecter les droits de propriété intellectuelle de WorkoutCool
- Ne pas détourner lutilisation du service à des fins illicites
- Renseigner des informations exactes et à jour
- Régler son abonnement dans les délais prévus
Toute infraction autorise WorkoutCool à suspendre ou supprimer le compte, sans préavis ni indemnité.
## ARTICLE 7 : Responsabilités
WorkoutCool ne pourra être tenu responsable des interruptions de service liées à :
- des maintenances techniques,
- des problèmes liés à Internet ou à lenvironnement du Client,
- des intrusions ou failles de sécurité imputables à des tiers,
- des pertes de données non sauvegardées par le Client.
Aucune garantie nest donnée quant à ladéquation du service aux besoins spécifiques du Client.
La Société se réserve le droit de modifier, suspendre ou retirer tout ou partie du service, sans obligation dindemnisation.
## ARTICLE 8 : Indemnisation
Le Client s'engage à indemniser WorkoutCool contre toute réclamation ou dommage résultant de l'utilisation fautive, illégale ou abusive du service, y compris les frais de défense et dexpertise.
## ARTICLE 9 : Modifications contractuelles
WorkoutCool peut modifier les présentes CGV à tout moment.
La version applicable est celle publiée au moment de la commande ou du renouvellement.
Le Client est invité à consulter régulièrement la dernière version disponible sur WorkoutCool.io.
## ARTICLE 10 : Force majeure
WorkoutCool ne pourra être tenu responsable dun manquement à ses obligations en cas de force majeure :
catastrophe naturelle, épidémie, cyberattaque, panne réseau, incendie, guerre, grève, ou tout événement imprévisible échappant à son contrôle raisonnable.
## ARTICLE 11 : Preuve et archivage
Les données enregistrées dans les systèmes informatiques de WorkoutCool constituent la preuve des commandes et des paiements.
Les factures sont disponibles dans lespace client et peuvent être téléchargées à tout moment.
## ARTICLE 12 : Contact Réclamation
Pour toute question ou réclamation :
- Par email : [support@WorkoutCool.io](mailto:support@WorkoutCool.io)
- Par courrier : Mathias BRADICEANU, Strada Fagului 40F, 077010 Afumați, Roumanie
- Via la messagerie interne de lApplication
+119
View File
@@ -0,0 +1,119 @@
# Terms of Use WorkoutCool
These Terms of Use ("Terms") define the conditions for accessing and using the services provided by the WorkoutCool platform and govern the rights and obligations between WorkoutCool and its users.
_Last updated: May 3, 2025_
## ARTICLE 1: Legal Notice
The website WorkoutCool.io is published by **Mathias BRADICEANU**.
The website is hosted by **Vercel Inc.**, 440 N Barranca Ave #4133, Covina, CA 91723, USA.
## ARTICLE 2: Access to the Platform
WorkoutCool allows users to:
- Create and manage a personalized bio link page
- Add links, media, and modules to a public profile
- Monitor engagement statistics (clicks, views, etc.)
- Customize appearance and content layout
Some features are available only through a paid subscription or during a free trial.
Access to the platform is provided “as is” and does not constitute any obligation of result.
## ARTICLE 3: Data Collection
WorkoutCool collects and stores personal data entered by users or automatically generated while using the service.
This includes names, email addresses, links, page content, and usage statistics.
Users may request access, correction, or deletion of their personal data, subject to legal obligations.
## ARTICLE 4: Intellectual Property
All elements of the WorkoutCool platform (text, images, code, logo, interface, web components, etc.) are protected by intellectual property law and remain the exclusive property of WorkoutCool or its partners.
Any reproduction, distribution, or commercial use without prior written consent is strictly prohibited.
User accounts and hosted content are non-transferable and subject to WorkoutCool approval.
## ARTICLE 5: User Responsibilities
Users agree to:
- Provide accurate and lawful information
- Not share illegal, offensive, defamatory, or misleading content
- Secure their account and credentials
- Comply with applicable laws and community standards
- Not use the platform for fraudulent, unauthorized commercial, or competitive purposes
In case of violation, WorkoutCool may suspend or delete the offending account without notice or refund.
## ARTICLE 6: Availability and Disclaimer of Warranty
WorkoutCool makes every effort to provide a stable and secure service.
However, the platform is provided **without any express or implied warranty**, including but not limited to availability, performance, compatibility, or error-free operation.
Technical support is not contractually guaranteed unless stated in a specific offer.
Users are solely responsible for backing up their content and data.
## ARTICLE 7: Limitation of Liability
WorkoutCool shall not be held liable for any direct or indirect damages, including material or immaterial losses, arising from:
- Service interruption
- Data loss or corruption
- Errors, delays, or technical failures
- Improper or illegal use of the platform by users or third parties
If WorkoutCool is found liable, its responsibility is expressly limited to the amount of the last subscription payment made by the user.
## ARTICLE 8: Suspension or Termination of Account
WorkoutCool reserves the right to suspend or terminate any account:
- In case of violation of these Terms
- In case of fraudulent or suspicious behavior
- In case of illegal or inappropriate content
- In case of excessive or abusive use of the service
No refund will be issued in case of suspension or termination for breach of contract.
## ARTICLE 9: Service Modifications
WorkoutCool may modify its services, features, pricing, or access conditions at any time.
Users will be informed of major changes within a reasonable timeframe.
Continued use of the platform implies acceptance of such changes.
## ARTICLE 10: External Links
User-generated pages may contain links to third-party websites.
WorkoutCool is not responsible for the content, security, or performance of external websites.
## ARTICLE 11: Reversibility
In the event of account deletion or platform shutdown, users are responsible for exporting and securing their data beforehand.
WorkoutCool does not guarantee automated data portability to third-party services unless explicitly stated in a dedicated offer.
## ARTICLE 12: Indemnification
Users agree to defend, indemnify, and hold harmless WorkoutCool from any claims, liabilities, losses, damages, or expenses (including legal fees) arising from:
- Violation of these Terms
- Content published via the platform
- Activities carried out through their account, even if unauthorized
## ARTICLE 13: Contact
For any questions regarding these Terms, you can contact us at:
**[support@WorkoutCool.io](mailto:support@WorkoutCool.io)**
or by postal mail to the publishers address listed in ARTICLE 1.
## ARTICLE 14: Governing Law and Jurisdiction
These Terms are governed by French law.
In the event of a dispute, the courts of **Mulhouse, France** shall have exclusive jurisdiction, unless otherwise required by consumer protection regulations.
## ARTICLE 15: Force Majeure
WorkoutCool shall not be held liable for failure to fulfill its obligations in the event of force majeure, including but not limited to: natural disaster, fire, flood, riot, war, pandemic, strike, cyberattack, infrastructure failure, or any other unforeseeable event beyond its control.
+113
View File
@@ -0,0 +1,113 @@
# Conditions Générales dUtilisation WorkoutCool
Les présentes Conditions Générales dUtilisation (ci-après « CGU ») définissent les modalités daccès et dutilisation des services proposés par la plateforme WorkoutCool, ainsi que les droits et obligations entre WorkoutCool et ses utilisateurs.
## ARTICLE 1 : Mentions légales
Le site WorkoutCool.io est édité par **Mathias BRADICEANU**
Lhébergement du site est assuré par **Vercel Inc.**, 440 N Barranca Ave #4133, Covina, CA 91723, États-Unis.
## ARTICLE 2 : Accès à la plateforme
La plateforme WorkoutCool permet :
- La création et la gestion dune page de lien en bio personnalisée
- Lajout de liens, médias et modules à un profil public
- Le suivi des statistiques dengagement (clics, vues, etc.)
- La personnalisation de lapparence et lorganisation des éléments
Certaines fonctionnalités sont accessibles uniquement avec un abonnement payant ou pendant une période dessai ou une période de promotion.
Laccès à la plateforme est fourni « en l’état » et ne constitue en aucun cas une obligation de résultat.
## ARTICLE 3 : Collecte des données
WorkoutCool collecte et conserve les données saisies par les utilisateurs ou générées automatiquement lors de lutilisation du service.
Cela inclut les noms, adresses e-mail, liens, contenus de page, et statistiques dutilisation.
Lutilisateur peut à tout moment demander laccès, la rectification ou la suppression de ses données, sous réserve des obligations légales.
## ARTICLE 4 : Propriété intellectuelle
Lensemble des éléments de la plateforme WorkoutCool (textes, images, code, logo, interface, composants web, etc.) est protégé par le droit de la propriété intellectuelle et demeure la propriété exclusive de WorkoutCool ou de ses partenaires.
Toute reproduction, distribution ou utilisation commerciale sans autorisation écrite préalable est strictement interdite.
Le compte utilisateur et ses contenus hébergés sur WorkoutCool ne sont pas transférables et restent soumis à lapprobation de la plateforme.
## ARTICLE 5 : Responsabilités de lutilisateur
Lutilisateur sengage à :
- Fournir des informations exactes et licites
- Ne pas diffuser de contenus illégaux, offensants, diffamatoires ou trompeurs
- Protéger laccès à son compte et à ses identifiants
- Respecter les lois en vigueur et les règles de bonne conduite
- Ne pas utiliser la plateforme à des fins frauduleuses, commerciales non autorisées ou concurrentielles
En cas de non-respect, WorkoutCool se réserve le droit de suspendre ou de supprimer le compte concerné, sans préavis ni remboursement.
## ARTICLE 6 : Disponibilité et limites de garantie
WorkoutCool met tout en œuvre pour garantir un service stable et sécurisé.
Cependant, la plateforme est fournie **sans aucune garantie expresse ou implicite**, notamment en termes de disponibilité, de performance, de compatibilité ou dabsence derreur.
Aucune assistance technique nest due contractuellement, sauf mention contraire dans une offre spécifique.
Lutilisateur est seul responsable de la sauvegarde de ses contenus et données.
## ARTICLE 7 : Limitation de responsabilité
WorkoutCool ne pourra être tenu responsable des dommages directs ou indirects, matériels ou immatériels, résultant notamment :
- dune interruption de service
- dune perte de données ou de contenu
- dune erreur, dun retard ou dune défaillance technique
- dune utilisation non conforme ou illégale de la plateforme par un utilisateur ou un tiers
La responsabilité de WorkoutCool, si elle venait à être engagée, serait expressément limitée au montant de la dernière mensualité effectivement payée.
## ARTICLE 8 : Suspension ou suppression de compte
WorkoutCool se réserve le droit de suspendre ou clôturer tout compte :
- En cas de violation des présentes CGU
- En cas de comportement frauduleux ou suspect
- En cas de diffusion de contenus illicites ou contraires aux valeurs de la plateforme
- En cas dutilisation excessive ou abusive du service
Aucun remboursement ne pourra être exigé en cas de suspension ou de suppression liée à un manquement contractuel.
## ARTICLE 9 : Évolutions du service
WorkoutCool peut faire évoluer à tout moment ses services, fonctionnalités, tarifs ou conditions daccès.
Ces évolutions peuvent être mises en œuvre sans préavis, sous réserve den informer les utilisateurs dans un délai raisonnable.
La poursuite de lutilisation de la plateforme vaut acceptation des modifications.
## ARTICLE 10 : Liens externes
Les pages créées sur WorkoutCool peuvent contenir des liens vers des sites tiers.
WorkoutCool ne peut être tenu responsable du contenu, de la sécurité ou du bon fonctionnement de ces sites externes.
## ARTICLE 11 : Réversibilité
En cas de suppression du compte ou darrêt du service, lutilisateur est responsable de la récupération de ses données et contenus.
WorkoutCool ne garantit pas la portabilité automatique vers un service tiers, sauf disposition expresse dans une offre dédiée.
## ARTICLE 12 : Indemnisation
Lutilisateur sengage à garantir, défendre et indemniser WorkoutCool contre toute réclamation, responsabilité, perte, dommage ou frais (y compris les honoraires davocat) résultant :
- de sa violation des présentes CGU
- de tout contenu diffusé via la plateforme
- de toute activité effectuée avec son compte, même à son insu
## ARTICLE 13 : Contact
Pour toute question relative aux présentes CGU, vous pouvez nous contacter à :
**[support@WorkoutCool.io](mailto:support@WorkoutCool.io)**
ou par courrier recommandé à ladresse de l’éditeur mentionnée à lARTICLE 1.
## ARTICLE 15 : Force majeure
WorkoutCool ne pourra être tenu responsable dun manquement à ses obligations en cas de force majeure, tels que : catastrophe naturelle, incendie, inondation, émeute, guerre, pandémie, grève, cyberattaque, panne dinfrastructure, ou toute autre situation imprévisible échappant à son contrôle.
View File
+37
View File
@@ -0,0 +1,37 @@
import * as React from "react";
import { Body, Container, Head, Heading, Hr, Html, Preview, Section, Text, Tailwind } from "@react-email/components";
interface ContactSupportEmailProps {
email: string;
subject: string;
message: string;
}
const ContactSupportEmail = ({ email, subject, message }: ContactSupportEmailProps) => (
<Html>
<Head />
<Preview>New Contact Request - {subject}</Preview>
<Tailwind>
<Body className="mx-auto my-auto bg-white font-sans">
<Container className="mx-auto my-[40px] w-[465px] rounded border border-solid border-[#eaeaea] p-[20px]">
<Section className="mt-[32px]">
<Heading className="mx-0 my-[30px] p-0 text-center text-[24px] font-normal text-black">New Contact Request</Heading>
<Text className="text-[14px] leading-[24px] text-black">
You received a new message from: <strong>{email}</strong>
</Text>
<Text className="text-[14px] leading-[24px] text-black">
<strong>Subject:</strong> {subject}
</Text>
<Hr className="mx-0 my-[26px] w-full border border-solid border-[#eaeaea]" />
<Text className="text-[14px] leading-[24px] text-black">
<strong>Message:</strong>
</Text>
<Text className="whitespace-pre-wrap text-[14px] leading-[24px] text-black">{message}</Text>
</Section>
</Container>
</Body>
</Tailwind>
</Html>
);
export default ContactSupportEmail;
+28
View File
@@ -0,0 +1,28 @@
import { Link, Section, Text } from "@react-email/components";
import { SiteConfig } from "@/shared/config/site-config";
import { BaseEmailLayout } from "./utils/BaseEmailLayout";
export default function DeleteAccountEmail({ email }: { email: string }) {
return (
<BaseEmailLayout previewText={"Your account has been deleted"}>
<Section className="my-6">
<Text className="text-lg leading-6">Hello,</Text>
<Text className="text-lg leading-6">
You account with email{" "}
<Link className="text-sky-500 hover:underline" href={`mailto:${email}`}>
{email}
</Link>{" "}
has been deleted.
</Text>
<Text className="text-lg leading-6">This action is irreversible.</Text>
<Text className="text-lg leading-6">If you have any questions, please contact us at {SiteConfig.email.contact}.</Text>
</Section>
<Text className="text-lg leading-6">
Best,
<br />- {SiteConfig.maker.name} from {SiteConfig.title}
</Text>
</BaseEmailLayout>
);
}
+56
View File
@@ -0,0 +1,56 @@
import * as React from "react";
import { Button, Heading, Hr, Link, Section, Text } from "@react-email/components";
import { SiteConfig } from "@/shared/config/site-config";
import { BaseEmailLayout } from "./utils/BaseEmailLayout"; // Import the layout
interface ResetPasswordEmailProps {
url: string;
}
const primaryColor = "#2563EB"; // Blue-600
export const ResetPasswordEmail = ({ url }: ResetPasswordEmailProps) => (
<BaseEmailLayout previewText={`Reset your password for ${SiteConfig.title}`}>
<Heading className="mb-6 text-center text-2xl font-semibold text-gray-900">🔒 Reset Your Password</Heading>
<Section>
<Text className="text-text text-base leading-relaxed">Hello,</Text>
<Text className="text-text text-base leading-relaxed">
We received a request to reset the password for your {SiteConfig.title} account. If this was you, click the button below to set a
new password:
</Text>
</Section>
<Section className="my-8 text-center">
<Button
className="inline-block rounded-md bg-primary px-6 py-3 text-center text-sm font-medium text-white no-underline transition hover:opacity-90"
href={url}
style={{ backgroundColor: primaryColor }} // Inline style for better email client compatibility
>
Set New Password
</Button>
</Section>
<Section>
<Text className="text-text text-base leading-relaxed">
If you didn&apos;t request a password reset, please ignore this email. Your password will remain unchanged.
</Text>
</Section>
<Hr className="my-6 border-t" />
<Section>
<Text className="text-lightText text-sm leading-normal">
If the button above doesn&apos;t work, you can copy and paste this link into your browser:
</Text>
<Link className="block break-all text-sm text-primary hover:underline" href={url}>
{url}
</Link>
</Section>
{/* Footer is now handled by BaseEmailLayout */}
</BaseEmailLayout>
);
export default ResetPasswordEmail; // Keep export consistent
+38
View File
@@ -0,0 +1,38 @@
import Link from "next/link";
import { Section, Text } from "@react-email/components";
import { getServerUrl } from "@/shared/lib/server-url";
import { SiteConfig } from "@/shared/config/site-config";
import { BaseEmailLayout } from "./utils/BaseEmailLayout";
export default function SubscribtionDowngradeEmail() {
return (
<BaseEmailLayout previewText={"Your Premium Access Has Been Paused"}>
<Section className="my-6">
<Text className="text-lg leading-6">Hello,</Text>
<Text className="text-lg leading-6">
{
"We're reaching out to inform you that your account has reverted to our basic access level. This change is due to the recent issues with your premium subscription payment."
}
</Text>
<Text className="text-lg leading-6">
{
"While you'll still enjoy our core services, access to premium features is now limited. We'd love to have you back in our premium community!"
}
</Text>
<Text className="text-lg leading-6">To reactivate your premium status, simply update your payment information here:</Text>
<Text className="text-lg leading-6">
<Link className="text-sky-500 hover:underline" href={`${getServerUrl()}/account/billing`}>
Click to Update Payment and Keep Using ${SiteConfig.title}
</Link>
</Text>
<Text className="text-lg leading-6">If you have any questions or need assistance, our team is always here to help.</Text>
</Section>
<Text className="text-lg leading-6">
Best,
<br />- {SiteConfig.maker.name} from {SiteConfig.title}
</Text>
</BaseEmailLayout>
);
}
+39
View File
@@ -0,0 +1,39 @@
import Link from "next/link";
import { Section, Text } from "@react-email/components";
import { getServerUrl } from "@/shared/lib/server-url";
import { SiteConfig } from "@/shared/config/site-config";
import { BaseEmailLayout } from "./utils/BaseEmailLayout";
export default function SubscribtionFailedEmail() {
return (
<BaseEmailLayout previewText={"Important information about your ${SiteConfig.title} account"}>
<Section className="my-6">
<Text className="text-lg leading-6">Hello,</Text>
<Text className="text-lg leading-6">{"Your last payment didn't go through, so your extra features are on hold."}</Text>
<Text className="text-lg leading-6">
{"We've noticed an issue with your recent payment, which affects your access to our premium features."}
</Text>
<Text className="text-lg leading-6">
{
"To resolve this and continue enjoying all the benefits, simply update your payment details through the link below. It's quick and straightforward!"
}
straightforward!
</Text>
<Text className="text-lg leading-6">
<Link className="text-sky-500 hover:underline" href={`${getServerUrl()}/account/billing`}>
Click to Update Payment and Keep Using ${SiteConfig.title}
</Link>
</Text>
<Text className="text-lg leading-6">
{"Thank you for your prompt attention to this matter. We're here to help if you have any questions."}
</Text>
</Section>
<Text className="text-lg leading-6">
Best,
<br />- {SiteConfig.maker.name} from {SiteConfig.title}
</Text>
</BaseEmailLayout>
);
}
+28
View File
@@ -0,0 +1,28 @@
import { Section, Text } from "@react-email/components";
import { SiteConfig } from "@/shared/config/site-config";
import { BaseEmailLayout } from "./utils/BaseEmailLayout";
export default function SuccessUpgradeEmail() {
return (
<BaseEmailLayout previewText={"You have successfully upgraded your account"}>
<Section className="my-6">
<Text className="text-lg leading-6">Hello,</Text>
<Text className="text-lg leading-6">
Great news! Your payment was successful, and you now have full access to all our premium link in bio features. Get ready to create
your perfect link page!
</Text>
<Text className="text-lg leading-6">
If you have any questions about customizing your link page or need assistance with any features, feel free to reach out to us.
We&apos;re here to help you make the most of your link in bio experience.
</Text>
<Text className="text-lg leading-6">Happy linking,</Text>
</Section>
<Text className="text-lg leading-6">
Best,
<br />- {SiteConfig.maker.name} from {SiteConfig.title}
</Text>
</BaseEmailLayout>
);
}
+51
View File
@@ -0,0 +1,51 @@
import * as React from "react";
import { Button, Heading, Hr, Link, Section, Text } from "@react-email/components";
import { SiteConfig } from "@/shared/config/site-config";
import { BaseEmailLayout } from "./utils/BaseEmailLayout"; // Import the layout
interface VerifyEmailProps {
url: string;
}
const primaryColor = "#2563EB"; // Blue-600
export const VerifyEmail = ({ url }: VerifyEmailProps) => (
<BaseEmailLayout previewText={`Verify your email address for ${SiteConfig.title}`}>
<Heading className="mb-6 text-center text-2xl font-semibold text-gray-900"> Verify Your Email</Heading>
<Section>
<Text className="text-text text-base leading-relaxed">Welcome to {SiteConfig.title}!</Text>
<Text className="text-text text-base leading-relaxed">
Please click the button below to verify your email address and complete your signup or login:
</Text>
</Section>
<Section className="my-8 text-center">
<Button
className="inline-block rounded-md bg-primary px-6 py-3 text-center text-sm font-medium text-white no-underline transition hover:opacity-90"
href={url}
style={{ backgroundColor: primaryColor }} // Inline style for better email client compatibility
>
Verify Email Address
</Button>
</Section>
<Section>
<Text className="text-text text-base leading-relaxed">If you didn&apos;t request this email, you can safely ignore it.</Text>
</Section>
<Hr className="my-6 border-t" />
<Section>
<Text className="text-lightText text-sm leading-normal">
If the button above doesn&apos;t work, you can copy and paste this link into your browser:
</Text>
<Link className="block break-all text-sm text-primary hover:underline" href={url}>
{url}
</Link>
</Section>
{/* Footer is now handled by BaseEmailLayout */}
</BaseEmailLayout>
);
+82
View File
@@ -0,0 +1,82 @@
import * as React from "react";
import { Body, Container, Head, Hr, Html, Img, Preview, Section, Text, Tailwind } from "@react-email/components";
import { SiteConfig } from "@/shared/config/site-config";
interface BaseEmailLayoutProps {
previewText: string;
children: React.ReactNode;
}
// Consistent styling variables
const primaryColor = "#2563EB"; // Blue-600
// eslint-disable-next-line quotes
const fontFamily = 'Inter, "Helvetica Neue", Helvetica, Arial, sans-serif';
const containerPadding = "32px"; // p-8
const mainBgColor = "#f9fafb"; // bg-gray-50
const containerBgColor = "#ffffff"; // bg-white
const textColor = "#374151"; // text-gray-700
const lightTextColor = "#6b7280"; // text-gray-500
const borderColor = "#e5e7eb"; // border-gray-200
export const BaseEmailLayout = ({ previewText, children }: BaseEmailLayoutProps) => (
<Html>
<Head>
{/* Font import */}
<link href="https://fonts.googleapis.com" rel="preconnect" />
<link crossOrigin="" href="https://fonts.gstatic.com" rel="preconnect" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap" rel="stylesheet" />
</Head>
<Preview>{previewText}</Preview>
<Tailwind
config={{
theme: {
extend: {
colors: {
primary: primaryColor,
text: textColor,
lightText: lightTextColor,
},
fontFamily: {
sans: [fontFamily],
},
borderColor: {
DEFAULT: borderColor,
},
},
},
}}
>
<Body className="mx-auto my-auto bg-gray-50 font-sans" style={{ backgroundColor: mainBgColor }}>
<Container
className="mx-auto my-10 max-w-md rounded-lg border border-solid bg-white shadow-sm"
style={{
padding: containerPadding,
backgroundColor: containerBgColor,
borderColor: borderColor,
}}
>
{/* Logo Section */}
<Section className="mb-6 text-center">
<Img alt={`${SiteConfig.title} Logo`} className="mx-auto" height="36" src={SiteConfig.cdnIcon} width="auto" />
</Section>
{/* Email specific content */}
{children}
{/* Footer Section */}
<Hr className="my-6 border-t" style={{ borderColor: borderColor }} />
<Section>
<Text className="text-lightText text-sm" style={{ color: lightTextColor }}>
Best regards,
<br />
The {SiteConfig.title} Team
</Text>
{/* Optional: Add company address or other info here if needed */}
{/* <Text className="text-xs text-gray-400">{SiteConfig.company.address}</Text> */}
</Section>
</Container>
</Body>
</Tailwind>
</Html>
);
+171
View File
@@ -0,0 +1,171 @@
import { fixupConfigRules, fixupPluginRules } from "@eslint/compat";
import js from "@eslint/js";
import { FlatCompat } from "@eslint/eslintrc";
import { configs as tsConfigs } from "typescript-eslint";
import path from "node:path";
import { fileURLToPath } from "node:url";
import globals from "globals";
import nextPlugin from "@next/eslint-plugin-next";
import reactHooks from "eslint-plugin-react-hooks";
import reactPlugin from "eslint-plugin-react";
import importPlugin from "eslint-plugin-import";
import unusedImportsPlugin from "eslint-plugin-unused-imports";
import tsParser from "@typescript-eslint/parser";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
});
const config = [
js.configs.recommended,
...tsConfigs.recommended,
...fixupConfigRules(
compat.extends(
"plugin:react/recommended",
"plugin:react/jsx-runtime",
"plugin:react-hooks/recommended",
"plugin:prettier/recommended",
"plugin:import/recommended",
"plugin:import/typescript",
"next/core-web-vitals",
),
),
{
files: ["**/*.{js,jsx,ts,tsx}"],
ignores: [
"**/node_modules/**",
"**/.next/**",
"**/out/**",
"**/coverage/**",
"**/build/**",
"**/dist/**",
"**/package.json",
"**/package-lock.json",
"**/eslint.config.mjs",
"**/next.config.js",
"src/utils/attempt2.js",
"src/utils/inapp.js",
"src/utils/externalLinkOpener.js",
"src/utils/browserEscape.js",
],
plugins: {
"react-hooks": fixupPluginRules(reactHooks),
react: fixupPluginRules(reactPlugin),
import: fixupPluginRules(importPlugin),
"unused-imports": fixupPluginRules(unusedImportsPlugin),
next: nextPlugin,
},
languageOptions: {
globals: {
...globals.browser,
...globals.node,
},
ecmaVersion: 2018,
sourceType: "module",
parser: tsParser,
parserOptions: {
project: "./tsconfig.json",
ecmaFeatures: {
jsx: true,
},
},
},
settings: {
"import/resolver": {
node: {
paths: ["src"],
extensions: [".js", ".jsx", ".ts", ".tsx"],
},
},
react: {
version: "detect",
},
},
rules: {
"prettier/prettier": ["off", { singleQuote: true }],
"no-use-before-define": ["off", { functions: false, classes: false }],
"@typescript-eslint/naming-convention": [
"error",
{
selector: "parameter",
format: ["camelCase", "PascalCase"],
leadingUnderscore: "allow",
},
{
selector: "variable",
format: ["camelCase", "UPPER_CASE", "PascalCase"],
leadingUnderscore: "allow",
},
],
"import/no-extraneous-dependencies": [
"error",
{
devDependencies: true,
optionalDependencies: false,
peerDependencies: false,
},
],
"@typescript-eslint/default-param-last": "off",
"@typescript-eslint/no-use-before-define": "off",
"comma-dangle": "off",
"@typescript-eslint/comma-dangle": "off",
"import/prefer-default-export": "off",
"unused-imports/no-unused-imports": "warn",
"max-len": ["warn", { code: 140, ignorePattern: "^import .*", ignoreStrings: true }],
"import/order": [
"error",
{
groups: ["builtin", "external", "internal", ["sibling", "parent"], "index", "type"],
alphabetize: { order: "desc", caseInsensitive: true },
pathGroups: [
{ pattern: "components", group: "internal" },
{ pattern: "components/**", group: "internal" },
{ pattern: "constants/**", group: "internal" },
{ pattern: "common", group: "internal" },
{ pattern: "error/**", group: "internal" },
{ pattern: "hooks/**", group: "internal" },
{ pattern: "locale/**", group: "internal" },
{ pattern: "routes/**", group: "internal" },
{ pattern: "selectors", group: "internal" },
{ pattern: "store", group: "internal" },
],
"newlines-between": "always",
},
],
"@typescript-eslint/no-explicit-any": "off",
"react/prop-types": "off",
"react/require-default-props": "off",
"import/no-unresolved": "off",
"import/no-cycle": ["off", { maxDepth: "∞" }],
"@typescript-eslint/no-shadow": "off",
"no-shadow": "off",
"no-console": "off",
"no-plusplus": "off",
"react-hooks/exhaustive-deps": "off",
"react/jsx-filename-extension": "off",
"react/jsx-props-no-spreading": "off",
"class-methods-use-this": "off",
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }],
"@typescript-eslint/no-empty-object-type": "off",
"react/jsx-sort-props": [
"error",
{
callbacksLast: false,
shorthandFirst: false,
shorthandLast: false,
ignoreCase: true,
noSortAlphabetically: false,
reservedFirst: false,
},
],
quotes: ["error", "double", { avoidEscape: false, allowTemplateLiterals: false }],
},
},
];
export default config;
+29
View File
@@ -0,0 +1,29 @@
"use client";
import { createI18nClient } from "next-international/client";
// NOTE: Also update middleware.ts to support locale
export const languages = ["en", "fr"];
export const { useI18n, useScopedI18n, I18nProviderClient, useChangeLocale, defineLocale, useCurrentLocale } = createI18nClient(
{
en: async () => {
await new Promise((resolve) => setTimeout(resolve, 100));
return import("./en");
},
fr: async () => {
await new Promise((resolve) => setTimeout(resolve, 100));
return import("./fr");
},
},
{
// Uncomment to set base path
// basePath: '/base',
// Uncomment to use custom segment name
// segmentName: 'locale',
// Uncomment to set fallback locale
// fallbackLocale: en,
},
);
export type TFunction = Awaited<ReturnType<typeof useI18n>>;
+198
View File
@@ -0,0 +1,198 @@
export default {
signin_error_subtitle: "Please check your credentials and try again.",
register_title: "Create an account",
register_description: "Enter your information below to create your account",
register_terms: "By signing up, you agree to our",
register_privacy: "Terms of Service",
register_privacy_link: "and our",
register_privacy_link_2: "Privacy Policy",
error: {
invalid_credentials: "Invalid credentials or account does not exist",
},
// Contact Support
contact_support: "Contact Support",
contact_support_subtitle: "Describe your issue and we'll help you as soon as possible. You can also write to us directly at",
// Social Platforms
social_platforms: {
x: "X (Twitter)",
facebook: "Facebook",
email: "Email",
whatsapp: "WhatsApp",
website: "Website",
phone: "Phone",
youtube: "YouTube",
linkedin: "LinkedIn",
snapchat: "Snapchat",
instagram: "Instagram",
tiktok: "TikTok",
threads: "Threads",
},
// Workout Builder
workout_builder: {
steps: {
equipment: {
title: "Equipment",
description: "Select your equipment",
},
muscles: {
title: "Muscles",
description: "Choose your training",
},
exercises: {
title: "Exercises",
description: "Customize your workout",
},
},
equipment: {
bodyweight: {
label: "Bodyweight",
description: "Exercises using only your body weight",
},
dumbbell: {
label: "Dumbbell",
description: "Free weight exercises with dumbbells",
},
barbell: {
label: "Barbell",
description: "Compound movements with a barbell",
},
kettlebell: {
label: "Kettlebell",
description: "Dynamic exercises with kettlebells",
},
band: {
label: "Band",
description: "Resistance band exercises",
},
plate: {
label: "Plate",
description: "Exercises using weight plates",
},
pullup_bar: {
label: "Pull-up bar",
description: "Upper body exercises with a pull-up bar",
},
bench: {
label: "Bench",
description: "Bench exercises and support",
},
},
navigation: {
previous: "Previous",
continue: "Continue",
complete: "Complete",
complete_workout: "Complete Workout",
},
stats: {
equipment_selected: "equipment selected",
equipment_selected_plural: "equipment selected",
selected: "Selected",
total: "Total",
equipment_ready: "equipment ready",
equipment_ready_plural: "equipment ready",
},
selection: {
choose_your_arsenal: "Choose Your Arsenal",
select_equipment_description: "Select equipment to unlock personalized workouts",
clear_all: "Clear all",
muscle_selection_coming_soon: "Muscle Selection (Coming Soon)",
muscle_selection_description: "This step will allow you to select target muscles for your workout.",
exercise_selection_coming_soon: "Exercise Selection (Coming Soon)",
exercise_selection_description: "This step will show you personalized exercise recommendations.",
},
},
commons: {
signup_with: "Sign up with {provider}",
signin_with: "Sign in with {provider}",
signup: "Sign up",
login: "Login",
connecting: "Connecting...",
login_to_your_account_title: "Login to your account",
login_to_your_account_subtitle: "Enter your credentials below to login",
password_forgot: "Forgot password?",
password_reset_success: "Password reset successfully",
dont_have_account: "Don't have an account?",
already_have_account: "Already have an account?",
or: "Or",
add: "Add",
your_feminine: "your",
password: "Password",
email: "Email",
logout: "Logout",
first_name: "First name",
last_name: "Last name",
verify_password: "Verify password",
submit: "Submit",
upload: "Upload",
cancel: "Cancel",
save_changes: "Save changes",
change: "Change",
subject: "Subject",
message: "Message",
saving: "Saving...",
edit: "Edit",
more_options: "More options",
open_link: "Open link",
hide: "Hide",
make_visible: "Make visible",
delete: "Delete",
share: "Share",
title: "Title",
subtitle: "Subtitle",
content: "Content",
save: "Save",
button: "Button",
card: "Card",
go_back: "Go back",
next: "Next",
choose_image: "Choose image",
soon: "Soon",
coming_soon_with_emoji: "Coming soon 🤫",
no_image: "No image",
description: "Description",
price: "Price",
duration: "Duration",
location: "Location",
schedule: "Schedule",
participants_info: "Participants info",
description_placeholder: "Enter the description",
title_placeholder: "Enter the title",
changes_saved: "Changes saved",
replace: "Replace",
loading: "Loading...",
image_deleted: "The image has been deleted",
discover_workoutcool: "Discover Workout Cool",
received_just_now: "Received just now",
copied: "Copied",
url_copied: "The URL has been copied",
copy_failed: "Copy failed",
accordion: "Accordion",
image: "Image",
other: "Other",
register: "Register",
instantly: "instantly",
immediately: "immediately",
link: "Link",
accept: "Accept",
deny: "Deny",
invalid_input: "Invalid input. Please check the errors.",
copy_url: "Copy URL",
page_url: "Page URL",
saving_short: "Saving...",
saved_short: "OK",
looks_like_you_are_lost: "Looks like you are lost",
the_page_you_are_looking_for_is_not_available: "The page you are looking for is not available",
go_to_home: "Go to home",
terms: "Terms of Service",
privacy: "Privacy Policy",
sales_terms: "Sales Terms",
consent_banner: "We use cookies to improve your experience. By clicking Accept, you agree to our use of cookies.",
about: "About us",
profile: "Profile",
donate: "Donate",
my_account: "My account",
dashboard: "Dashboard",
},
} as const;
+198
View File
@@ -0,0 +1,198 @@
export default {
signin_error_subtitle: "Veuillez vérifier vos identifiants et réessayer.",
register_title: "Créer un compte",
register_description: "Entrez vos informations ci-dessous pour créer votre compte",
register_terms: "En vous inscrivant, vous acceptez nos",
register_privacy: "Conditions d'utilisation",
register_privacy_link: "et notre",
register_privacy_link_2: "Politique de confidentialité",
error: {
invalid_credentials: "Identifiants invalides ou compte inexistant",
},
// Contact Support
contact_support: "Contacter le support",
contact_support_subtitle: "Décrivez votre problème et nous vous aiderons dès que possible. Vous pouvez aussi nous écrire directement à",
// Social Platforms
social_platforms: {
x: "X (Twitter)",
facebook: "Facebook",
email: "Email",
whatsapp: "WhatsApp",
website: "Site web",
phone: "Téléphone",
youtube: "YouTube",
linkedin: "LinkedIn",
snapchat: "Snapchat",
instagram: "Instagram",
tiktok: "TikTok",
threads: "Threads",
},
// Workout Builder
workout_builder: {
steps: {
equipment: {
title: "Équipement",
description: "Sélectionnez votre équipement",
},
muscles: {
title: "Muscles",
description: "Choisissez votre entraînement",
},
exercises: {
title: "Exercices",
description: "Personnalisez votre séance",
},
},
equipment: {
bodyweight: {
label: "Poids du corps",
description: "Exercices utilisant uniquement le poids de votre corps",
},
dumbbell: {
label: "Haltères",
description: "Exercices de poids libres avec haltères",
},
barbell: {
label: "Barre",
description: "Mouvements composés avec une barre",
},
kettlebell: {
label: "Kettlebell",
description: "Exercices dynamiques avec kettlebells",
},
band: {
label: "Élastique",
description: "Exercices avec bandes de résistance",
},
plate: {
label: "Disques",
description: "Exercices utilisant des disques de poids",
},
pullup_bar: {
label: "Barre de traction",
description: "Exercices du haut du corps avec barre de traction",
},
bench: {
label: "Banc",
description: "Exercices sur banc et support",
},
},
navigation: {
previous: "Précédent",
continue: "Continuer",
complete: "Terminer",
complete_workout: "Terminer la séance",
},
stats: {
equipment_selected: "équipement sélectionné",
equipment_selected_plural: "équipements sélectionnés",
selected: "Sélectionné",
total: "Total",
equipment_ready: "équipement prêt",
equipment_ready_plural: "équipements prêts",
},
selection: {
choose_your_arsenal: "Choisissez votre arsenal",
select_equipment_description: "Sélectionnez l'équipement pour débloquer des entraînements personnalisés",
clear_all: "Tout effacer",
muscle_selection_coming_soon: "Sélection des muscles (Bientôt disponible)",
muscle_selection_description: "Cette étape vous permettra de sélectionner les muscles cibles pour votre entraînement.",
exercise_selection_coming_soon: "Sélection des exercices (Bientôt disponible)",
exercise_selection_description: "Cette étape vous montrera des recommandations d'exercices personnalisées.",
},
},
commons: {
signup_with: "S'inscrire avec {provider}",
signin_with: "Se connecter avec {provider}",
signup: "S'inscrire",
login: "Se connecter",
connecting: "Connexion...",
password_reset_success: "Le mot de passe a été réinitialisé avec succès",
login_to_your_account_title: "Connectez-vous à votre compte",
login_to_your_account_subtitle: "Entrez vos identifiants ci-dessous pour vous connecter",
password_forgot: "Mot de passe oublié ?",
dont_have_account: "Vous n'avez pas de compte ?",
already_have_account: "Vous avez déjà un compte ?",
or: "Ou",
add: "Ajouter",
your_feminine: "ta",
password: "Mot de passe",
email: "Email",
logout: "Déconnexion",
first_name: "Prénom",
last_name: "Nom",
verify_password: "Vérifier le mot de passe",
submit: "Envoyer",
upload: "Télécharger",
cancel: "Annuler",
save_changes: "Enregistrer les modifications",
change: "Changer",
subject: "Sujet",
message: "Message",
saving: "Enregistrement...",
edit: "Modifier",
more_options: "Plus d'options",
open_link: "Ouvrir le lien",
hide: "Masquer",
make_visible: "Rendre visible",
delete: "Supprimer",
share: "Partager",
title: "Titre",
subtitle: "Sous-titre",
content: "Contenu",
save: "Enregistrer",
button: "Bouton",
card: "Carte",
go_back: "Retour",
next: "Suivant",
choose_image: "Choisir une image",
soon: "Bientôt",
coming_soon_with_emoji: "Bientôt disponible 🤫",
no_image: "Aucune image",
description: "Description",
price: "Prix",
duration: "Durée",
location: "Lieu",
schedule: "Horaire",
participants_info: "Informations sur les participants",
title_placeholder: "Entrez le titre",
description_placeholder: "Entrez la description",
changes_saved: "Les modifications ont été sauvegardées",
replace: "Remplacer",
loading: "Chargement...",
image_deleted: "L'image a été supprimée",
discover_workoutcool: "Découvrir gratuitement",
received_just_now: "Reçu à l'instant",
copied: "Copié",
url_copied: "L'URL a été copiée",
copy_failed: "Erreur lors de la copie de l'URL",
accordion: "Accordéon",
image: "Image",
other: "Autre",
register: "S'inscrire",
instantly: "instantanément",
immediately: "immédiatement",
link: "Lien",
accept: "Accepter",
deny: "Refuser",
invalid_input: "Saisie invalide. Veuillez vérifier les erreurs.",
copy_url: "Copier l'URL",
page_url: "URL de la page",
saving_short: "Enregistrement...",
saved_short: "Sauvegardé",
looks_like_you_are_lost: "Il semble que vous soyez perdu",
the_page_you_are_looking_for_is_not_available: "La page que vous cherchez n'est pas disponible",
go_to_home: "Retour à l'accueil",
terms: "Conditions d'utilisation",
privacy: "Politique de confidentialité",
sales_terms: "Conditions de vente",
consent_banner: "Nous utilisons des cookies pour améliorer votre expérience. En cliquant sur Accepter, vous acceptez nos cookies.",
about: "À propos",
profile: "Profil",
donate: "Faire un don",
my_account: "Mon compte",
dashboard: "Tableau de bord",
},
} as const;
+6
View File
@@ -0,0 +1,6 @@
import { createI18nServer } from "next-international/server";
export const { getI18n, getScopedI18n, getStaticParams } = createI18nServer({
en: () => import("./en"),
fr: () => import("./fr"),
});
+31
View File
@@ -0,0 +1,31 @@
// middleware.ts
import { createI18nMiddleware } from "next-international/middleware";
import { NextRequest, NextResponse } from "next/server";
import { getSessionCookie } from "better-auth/cookies";
const I18nMiddleware = createI18nMiddleware({
locales: ["en", "fr"],
defaultLocale: "en",
urlMappingStrategy: "rewrite",
});
export async function middleware(request: NextRequest) {
const response = I18nMiddleware(request);
const searchParams = request.nextUrl.searchParams.toString();
response.headers.set("searchParams", searchParams);
if (request.nextUrl.pathname.includes("/dashboard")) {
const session = getSessionCookie(request);
if (!session) {
return NextResponse.redirect(new URL("/", request.url));
}
}
return response;
}
export const config = {
matcher: ["/((?!api|static|_next|manifest.json|scripts/pixel.js|favicon.ico|robots.txt|service-worker\\.js|images|icons|sitemap.xml).*)"],
};
+22
View File
@@ -0,0 +1,22 @@
import { withPlausibleProxy } from "next-plausible";
import { env } from "@/env";
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
reactStrictMode: true,
images: {
unoptimized: true,
domains: ["lh3.googleusercontent.com", "192.168.1.12", "localhost", "www.facebook.com"],
remotePatterns: [
{
protocol: "https",
hostname: "**.vercel.app",
},
],
},
};
export default withPlausibleProxy()(nextConfig);
+13
View File
@@ -0,0 +1,13 @@
/* eslint-disable @typescript-eslint/consistent-type-definitions */
import type { DefaultSession } from "next-auth";
declare module "next-auth" {
interface Session {
user: DefaultSession["user"] & {
id: string;
email: string;
name?: string;
image?: string;
};
}
}
+152
View File
@@ -0,0 +1,152 @@
{
"name": "workoutcool",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"email": "email dev",
"start": "next start",
"stripe-webhooks": "stripe listen --forward-to localhost:3000/api/webhooks/stripe",
"vercel-build": "next build",
"old-vercel-build": "prisma generate && prisma migrate deploy && next build",
"postinstall": "prisma generate",
"lint": "next lint",
"import:exercises-full": "tsx scripts/import-exercises-with-attributes.ts"
},
"resolutions": {
"prettier": "^3.4.2"
},
"dependencies": {
"@auth/prisma-adapter": "^2.8.0",
"@aws-sdk/client-s3": "^3.787.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.0.1",
"@openpanel/nextjs": "^1.0.8",
"@prisma/client": "^6.5.0",
"@radix-ui/react-accordion": "^1.2.3",
"@radix-ui/react-alert-dialog": "^1.1.6",
"@radix-ui/react-aspect-ratio": "^1.1.2",
"@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-hover-card": "^1.1.7",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-navigation-menu": "^1.2.6",
"@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-portal": "^1.1.5",
"@radix-ui/react-radio-group": "^1.3.3",
"@radix-ui/react-scroll-area": "^1.2.6",
"@radix-ui/react-select": "^2.1.7",
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slider": "^1.3.2",
"@radix-ui/react-slot": "^1.2.0",
"@radix-ui/react-switch": "^1.2.2",
"@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-toast": "^1.2.7",
"@radix-ui/react-tooltip": "^1.1.8",
"@react-email/components": "^0.0.35",
"@react-email/html": "^0.0.11",
"@react-email/tailwind": "^1.0.4",
"@t3-oss/env-nextjs": "^0.12.0",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/react-query": "^5.74.3",
"@tanstack/react-query-devtools": "^5.74.4",
"@tiptap/extension-bold": "^2.11.7",
"@tiptap/extension-bullet-list": "^2.11.7",
"@tiptap/extension-italic": "^2.11.7",
"@tiptap/extension-list-item": "^2.11.7",
"@tiptap/extension-ordered-list": "^2.11.7",
"@tiptap/extension-placeholder": "^2.11.7",
"@tiptap/extension-strike": "^2.11.7",
"@tiptap/react": "^2.11.7",
"@tiptap/starter-kit": "^2.11.7",
"@vercel/functions": "^2.0.3",
"better-auth": "^1.2.7",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"csv-parser": "^3.2.0",
"embla-carousel-auto-scroll": "^8.6.0",
"embla-carousel-react": "^8.6.0",
"eslint-config-prettier": "^10.1.1",
"framer-motion": "^12.7.2",
"geist": "^1.3.1",
"i": "^0.3.7",
"is-ua-webview": "^1.1.2",
"isomorphic-dompurify": "^2.24.0",
"lodash.debounce": "^4.0.8",
"lodash.findkey": "^4.6.0",
"lodash.set": "^4.3.2",
"lottie-react": "^2.4.1",
"lucide-react": "^0.487.0",
"mime": "^4.0.7",
"nanoid": "^5.1.5",
"next": "15.2.3",
"next-international": "^1.3.1",
"next-mdx-remote": "^5.0.0",
"next-plausible": "^3.12.4",
"next-safe-action": "^7.10.4",
"next-themes": "^0.4.6",
"nodemailer": "^6.10.0",
"npm": "^11.3.0",
"nprogress": "^0.2.0",
"nuqs": "^2.4.3",
"pg": "^8.14.1",
"prisma": "^6.5.0",
"react": "^19.0.0",
"react-calendly": "^4.3.1",
"react-colorful": "^5.6.1",
"react-dom": "^19.0.0",
"react-facebook-pixel": "^1.0.4",
"react-hook-form": "^7.55.0",
"react-icons": "^5.5.0",
"react-qrcode-logo": "^3.0.0",
"resend": "^4.2.0",
"sharp": "^0.34.1",
"sonner": "^2.0.3",
"stripe": "^18.0.0",
"tw-animate-css": "^1.2.5",
"usehooks-ts": "^3.1.1",
"vaul": "^1.1.2",
"zod": "^3.24.2",
"zustand": "^5.0.3"
},
"devDependencies": {
"@eslint/compat": "^1.2.7",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.28.0",
"@next/eslint-plugin-next": "^15.2.4",
"@types/lodash.debounce": "^4.0.9",
"@types/lodash.set": "^4.3.9",
"@types/node": "^20",
"@types/nprogress": "^0.2.3",
"@types/react": "^19",
"@types/react-dom": "^19",
"@typescript-eslint/eslint-plugin": "^8.29.0",
"@typescript-eslint/parser": "^8.29.0",
"autoprefixer": "^10.4.21",
"daisyui": "^5.0.43",
"eslint": "^9.23.0",
"eslint-config-next": "15.2.3",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-prettier": "^5.2.5",
"eslint-plugin-react": "^7.37.4",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-unused-imports": "^4.1.4",
"globals": "^16.0.0",
"postcss": "^8.5.3",
"prettier": "^3.4.2",
"prettier-plugin-sort-json": "^4.1.1",
"tailwind-merge": "^2.3.0",
"tailwindcss": "^3.4.13",
"tailwindcss-animate": "^1.0.7",
"tslog": "^4.9.3",
"tsx": "^4.19.4",
"typescript": "^5",
"typescript-eslint": "^8.29.0"
}
}
+10947
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;
@@ -0,0 +1,69 @@
-- CreateTable
CREATE TABLE "user" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"email" TEXT NOT NULL,
"emailVerified" BOOLEAN NOT NULL,
"image" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "user_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "session" (
"id" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"token" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
"ipAddress" TEXT,
"userAgent" TEXT,
"userId" TEXT NOT NULL,
CONSTRAINT "session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "account" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"providerId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"accessToken" TEXT,
"refreshToken" TEXT,
"idToken" TEXT,
"accessTokenExpiresAt" TIMESTAMP(3),
"refreshTokenExpiresAt" TIMESTAMP(3),
"scope" TEXT,
"password" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "account_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "verification" (
"id" TEXT NOT NULL,
"identifier" TEXT NOT NULL,
"value" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3),
"updatedAt" TIMESTAMP(3),
CONSTRAINT "verification_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "user_email_key" ON "user"("email");
-- CreateIndex
CREATE UNIQUE INDEX "session_token_key" ON "session"("token");
-- AddForeignKey
ALTER TABLE "session" ADD CONSTRAINT "session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "account" ADD CONSTRAINT "account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,15 @@
-- CreateTable
CREATE TABLE "Feedback" (
"id" TEXT NOT NULL,
"review" INTEGER NOT NULL,
"message" TEXT NOT NULL,
"email" TEXT,
"userId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Feedback_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "Feedback" ADD CONSTRAINT "Feedback_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,27 @@
/*
Warnings:
- You are about to drop the `Feedback` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropForeignKey
ALTER TABLE "Feedback" DROP CONSTRAINT "Feedback_userId_fkey";
-- DropTable
DROP TABLE "Feedback";
-- CreateTable
CREATE TABLE "feedbacks" (
"id" TEXT NOT NULL,
"review" INTEGER NOT NULL,
"message" TEXT NOT NULL,
"email" TEXT,
"userId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "feedbacks_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "feedbacks" ADD CONSTRAINT "feedbacks_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "user" ADD COLUMN "firstName" TEXT NOT NULL DEFAULT '',
ADD COLUMN "lastName" TEXT NOT NULL DEFAULT '';
@@ -0,0 +1,49 @@
-- CreateTable
CREATE TABLE "Plan" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Plan_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PlanVariant" (
"id" TEXT NOT NULL,
"label" TEXT NOT NULL,
"planId" TEXT NOT NULL,
"stripePriceId" TEXT NOT NULL,
"description" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PlanVariant_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Subscription" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"planVariantId" TEXT NOT NULL,
"stripeCustomerId" TEXT NOT NULL,
"stripeSubId" TEXT NOT NULL,
"status" TEXT NOT NULL,
"currentPeriodEnd" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Subscription_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Subscription_userId_key" ON "Subscription"("userId");
-- AddForeignKey
ALTER TABLE "PlanVariant" ADD CONSTRAINT "PlanVariant_planId_fkey" FOREIGN KEY ("planId") REFERENCES "Plan"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_planVariantId_fkey" FOREIGN KEY ("planVariantId") REFERENCES "PlanVariant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,75 @@
/*
Warnings:
- You are about to drop the `Plan` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `PlanVariant` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `Subscription` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropForeignKey
ALTER TABLE "PlanVariant" DROP CONSTRAINT "PlanVariant_planId_fkey";
-- DropForeignKey
ALTER TABLE "Subscription" DROP CONSTRAINT "Subscription_planVariantId_fkey";
-- DropForeignKey
ALTER TABLE "Subscription" DROP CONSTRAINT "Subscription_userId_fkey";
-- DropTable
DROP TABLE "Plan";
-- DropTable
DROP TABLE "PlanVariant";
-- DropTable
DROP TABLE "Subscription";
-- CreateTable
CREATE TABLE "plan" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "plan_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "plan_variant" (
"id" TEXT NOT NULL,
"label" TEXT NOT NULL,
"planId" TEXT NOT NULL,
"stripePriceId" TEXT NOT NULL,
"description" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "plan_variant_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "subscription" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"planVariantId" TEXT NOT NULL,
"stripeCustomerId" TEXT NOT NULL,
"stripeSubId" TEXT NOT NULL,
"status" TEXT NOT NULL,
"currentPeriodEnd" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "subscription_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "subscription_userId_key" ON "subscription"("userId");
-- AddForeignKey
ALTER TABLE "plan_variant" ADD CONSTRAINT "plan_variant_planId_fkey" FOREIGN KEY ("planId") REFERENCES "plan"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "subscription" ADD CONSTRAINT "subscription_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "subscription" ADD CONSTRAINT "subscription_planVariantId_fkey" FOREIGN KEY ("planVariantId") REFERENCES "plan_variant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,11 @@
-- CreateEnum
CREATE TYPE "UserRole" AS ENUM ('ADMIN', 'USER');
-- AlterTable
ALTER TABLE "session" ADD COLUMN "impersonatedBy" TEXT;
-- AlterTable
ALTER TABLE "user" ADD COLUMN "banExpires" TIMESTAMP(3),
ADD COLUMN "banReason" TEXT,
ADD COLUMN "banned" BOOLEAN DEFAULT false,
ADD COLUMN "role" "UserRole" DEFAULT 'USER';
@@ -0,0 +1,19 @@
/*
Warnings:
- The values [ADMIN,USER] on the enum `UserRole` will be removed. If these variants are still used in the database, this will fail.
*/
-- AlterEnum
BEGIN;
CREATE TYPE "UserRole_new" AS ENUM ('admin', 'user');
ALTER TABLE "user" ALTER COLUMN "role" DROP DEFAULT;
ALTER TABLE "user" ALTER COLUMN "role" TYPE "UserRole_new" USING ("role"::text::"UserRole_new");
ALTER TYPE "UserRole" RENAME TO "UserRole_old";
ALTER TYPE "UserRole_new" RENAME TO "UserRole";
DROP TYPE "UserRole_old";
ALTER TABLE "user" ALTER COLUMN "role" SET DEFAULT 'user';
COMMIT;
-- AlterTable
ALTER TABLE "user" ALTER COLUMN "role" SET DEFAULT 'user';
@@ -0,0 +1,121 @@
/*
Warnings:
- You are about to drop the `plan` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `plan_variant` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `subscription` table. If the table is not empty, all the data it contains will be lost.
*/
-- CreateEnum
CREATE TYPE "ExercisePrivacy" AS ENUM ('PUBLIC', 'PRIVATE');
-- CreateEnum
CREATE TYPE "ExerciseAttributeNameEnum" AS ENUM ('MUSCLE_GROUP', 'EQUIPMENT', 'DIFFICULTY', 'MOVEMENT_TYPE');
-- CreateEnum
CREATE TYPE "ExerciseAttributeValueEnum" AS ENUM ('CHEST', 'BACK', 'SHOULDERS', 'ARMS', 'LEGS', 'CORE', 'BARBELL', 'DUMBBELL', 'BODYWEIGHT', 'MACHINE', 'BEGINNER', 'INTERMEDIATE', 'ADVANCED', 'PUSH', 'PULL', 'SQUAT', 'HINGE');
-- DropForeignKey
ALTER TABLE "plan_variant" DROP CONSTRAINT "plan_variant_planId_fkey";
-- DropForeignKey
ALTER TABLE "subscription" DROP CONSTRAINT "subscription_planVariantId_fkey";
-- DropForeignKey
ALTER TABLE "subscription" DROP CONSTRAINT "subscription_userId_fkey";
-- AlterTable
ALTER TABLE "user" ADD COLUMN "locale" TEXT DEFAULT 'fr';
-- DropTable
DROP TABLE "plan";
-- DropTable
DROP TABLE "plan_variant";
-- DropTable
DROP TABLE "subscription";
-- CreateTable
CREATE TABLE "exercises" (
"id" TEXT NOT NULL,
"coachId" TEXT,
"privacy" "ExercisePrivacy" NOT NULL DEFAULT 'PUBLIC',
"name" TEXT NOT NULL,
"nameEn" TEXT,
"introduction" TEXT,
"introductionEn" TEXT,
"description" TEXT,
"descriptionEn" TEXT,
"fullVideoUrl" TEXT,
"fullVideoImageUrl" TEXT,
"isArchived" BOOLEAN NOT NULL DEFAULT false,
"slug" TEXT,
"slugEn" TEXT,
"metaTitle" TEXT,
"metaTitleEn" TEXT,
"metaDescription" TEXT,
"metaDescriptionEn" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"deletedAt" TIMESTAMP(3),
CONSTRAINT "exercises_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "exercise_attribute_names" (
"id" TEXT NOT NULL,
"name" "ExerciseAttributeNameEnum" NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"deletedAt" TIMESTAMP(3),
CONSTRAINT "exercise_attribute_names_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "exercise_attribute_values" (
"id" TEXT NOT NULL,
"attributeNameId" TEXT NOT NULL,
"value" "ExerciseAttributeValueEnum" NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"deletedAt" TIMESTAMP(3),
CONSTRAINT "exercise_attribute_values_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "exercise_attributes" (
"id" TEXT NOT NULL,
"exerciseId" TEXT NOT NULL,
"attributeNameId" TEXT NOT NULL,
"attributeValueId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"deletedAt" TIMESTAMP(3),
CONSTRAINT "exercise_attributes_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "exercises_slug_key" ON "exercises"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "exercises_slugEn_key" ON "exercises"("slugEn");
-- CreateIndex
CREATE UNIQUE INDEX "exercise_attributes_exerciseId_attributeNameId_attributeVal_key" ON "exercise_attributes"("exerciseId", "attributeNameId", "attributeValueId");
-- AddForeignKey
ALTER TABLE "exercise_attribute_values" ADD CONSTRAINT "exercise_attribute_values_attributeNameId_fkey" FOREIGN KEY ("attributeNameId") REFERENCES "exercise_attribute_names"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "exercise_attributes" ADD CONSTRAINT "exercise_attributes_exerciseId_fkey" FOREIGN KEY ("exerciseId") REFERENCES "exercises"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "exercise_attributes" ADD CONSTRAINT "exercise_attributes_attributeNameId_fkey" FOREIGN KEY ("attributeNameId") REFERENCES "exercise_attribute_names"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "exercise_attributes" ADD CONSTRAINT "exercise_attributes_attributeValueId_fkey" FOREIGN KEY ("attributeValueId") REFERENCES "exercise_attribute_values"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,28 @@
/*
Warnings:
- The values [MUSCLE_GROUP,DIFFICULTY,MOVEMENT_TYPE] on the enum `ExerciseAttributeNameEnum` will be removed. If these variants are still used in the database, this will fail.
- The values [CHEST,BACK,ARMS,LEGS,CORE,DUMBBELL,BODYWEIGHT,MACHINE,BEGINNER,INTERMEDIATE,ADVANCED,PUSH,PULL,SQUAT,HINGE] on the enum `ExerciseAttributeValueEnum` will be removed. If these variants are still used in the database, this will fail.
- A unique constraint covering the columns `[attributeNameId,value]` on the table `exercise_attribute_values` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterEnum
BEGIN;
CREATE TYPE "ExerciseAttributeNameEnum_new" AS ENUM ('TYPE', 'PRIMARY_MUSCLE', 'SECONDARY_MUSCLE', 'EQUIPMENT', 'MECHANICS_TYPE');
ALTER TABLE "exercise_attribute_names" ALTER COLUMN "name" TYPE "ExerciseAttributeNameEnum_new" USING ("name"::text::"ExerciseAttributeNameEnum_new");
ALTER TYPE "ExerciseAttributeNameEnum" RENAME TO "ExerciseAttributeNameEnum_old";
ALTER TYPE "ExerciseAttributeNameEnum_new" RENAME TO "ExerciseAttributeNameEnum";
DROP TYPE "ExerciseAttributeNameEnum_old";
COMMIT;
-- AlterEnum
BEGIN;
CREATE TYPE "ExerciseAttributeValueEnum_new" AS ENUM ('STRENGTH', 'PLYOMETRICS', 'CROSSFIT', 'CARDIO', 'QUADRICEPS', 'SHOULDERS', 'FULL_BODY', 'GLUTES', 'HAMSTRINGS', 'FOREARMS', 'BARBELL', 'BAR', 'CABLE', 'ROPE', 'BENCH', 'COMPOUND', 'ISOLATION');
ALTER TABLE "exercise_attribute_values" ALTER COLUMN "value" TYPE "ExerciseAttributeValueEnum_new" USING ("value"::text::"ExerciseAttributeValueEnum_new");
ALTER TYPE "ExerciseAttributeValueEnum" RENAME TO "ExerciseAttributeValueEnum_old";
ALTER TYPE "ExerciseAttributeValueEnum_new" RENAME TO "ExerciseAttributeValueEnum";
DROP TYPE "ExerciseAttributeValueEnum_old";
COMMIT;
-- CreateIndex
CREATE UNIQUE INDEX "exercise_attribute_values_attributeNameId_value_key" ON "exercise_attribute_values"("attributeNameId", "value");
@@ -0,0 +1,47 @@
/*
Warnings:
- You are about to drop the column `deletedAt` on the `exercise_attribute_names` table. All the data in the column will be lost.
- You are about to drop the column `deletedAt` on the `exercise_attribute_values` table. All the data in the column will be lost.
- You are about to drop the column `deletedAt` on the `exercise_attributes` table. All the data in the column will be lost.
- You are about to drop the column `coachId` on the `exercises` table. All the data in the column will be lost.
- You are about to drop the column `deletedAt` on the `exercises` table. All the data in the column will be lost.
- You are about to drop the column `isArchived` on the `exercises` table. All the data in the column will be lost.
- You are about to drop the column `metaDescription` on the `exercises` table. All the data in the column will be lost.
- You are about to drop the column `metaDescriptionEn` on the `exercises` table. All the data in the column will be lost.
- You are about to drop the column `metaTitle` on the `exercises` table. All the data in the column will be lost.
- You are about to drop the column `metaTitleEn` on the `exercises` table. All the data in the column will be lost.
- You are about to drop the column `privacy` on the `exercises` table. All the data in the column will be lost.
- A unique constraint covering the columns `[name]` on the table `exercise_attribute_names` will be added. If there are existing duplicate values, this will fail.
- Changed the type of `name` on the `exercise_attribute_names` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required.
- Changed the type of `value` on the `exercise_attribute_values` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required.
*/
-- AlterTable
ALTER TABLE "exercise_attribute_names" DROP COLUMN "deletedAt",
DROP COLUMN "name",
ADD COLUMN "name" TEXT NOT NULL;
-- AlterTable
ALTER TABLE "exercise_attribute_values" DROP COLUMN "deletedAt",
DROP COLUMN "value",
ADD COLUMN "value" TEXT NOT NULL;
-- AlterTable
ALTER TABLE "exercise_attributes" DROP COLUMN "deletedAt";
-- AlterTable
ALTER TABLE "exercises" DROP COLUMN "coachId",
DROP COLUMN "deletedAt",
DROP COLUMN "isArchived",
DROP COLUMN "metaDescription",
DROP COLUMN "metaDescriptionEn",
DROP COLUMN "metaTitle",
DROP COLUMN "metaTitleEn",
DROP COLUMN "privacy";
-- CreateIndex
CREATE UNIQUE INDEX "exercise_attribute_names_name_key" ON "exercise_attribute_names"("name");
-- CreateIndex
CREATE UNIQUE INDEX "exercise_attribute_values_attributeNameId_value_key" ON "exercise_attribute_values"("attributeNameId", "value");
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
+265
View File
@@ -0,0 +1,265 @@
// This is your Prisma schema file
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum UserRole {
admin
user
}
model User {
id String @id
firstName String @default("")
lastName String @default("")
name String
email String @unique
emailVerified Boolean
image String?
locale String? @default("fr")
createdAt DateTime
updatedAt DateTime
sessions Session[]
accounts Account[]
feedbacks Feedbacks[]
role UserRole? @default(user)
banned Boolean? @default(false)
banReason String?
banExpires DateTime?
@@map("user")
}
model Session {
id String @id
expiresAt DateTime
token String @unique
createdAt DateTime
updatedAt DateTime
ipAddress String?
userAgent String?
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
impersonatedBy String?
@@map("session")
}
model Account {
id String @id
accountId String
providerId String
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
accessToken String?
refreshToken String?
idToken String?
accessTokenExpiresAt DateTime?
refreshTokenExpiresAt DateTime?
scope String?
password String?
createdAt DateTime
updatedAt DateTime
@@map("account")
}
model Verification {
id String @id
identifier String
value String
expiresAt DateTime
createdAt DateTime?
updatedAt DateTime?
@@map("verification")
}
model Feedbacks {
id String @id @default(cuid())
review Int
message String
email String?
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("feedbacks")
}
model Exercise {
id String @id @default(cuid())
name String
nameEn String?
description String? @db.Text
descriptionEn String? @db.Text
fullVideoUrl String? @db.Text
fullVideoImageUrl String? @db.Text
introduction String? @db.Text
introductionEn String? @db.Text
slug String? @unique
slugEn String? @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
attributes ExerciseAttribute[]
@@map("exercises")
}
model ExerciseAttributeName {
id String @id @default(cuid())
name ExerciseAttributeNameEnum @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
values ExerciseAttributeValue[]
attributes ExerciseAttribute[]
@@map("exercise_attribute_names")
}
model ExerciseAttributeValue {
id String @id @default(cuid())
attributeNameId String
value ExerciseAttributeValueEnum
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
attributeName ExerciseAttributeName @relation(fields: [attributeNameId], references: [id])
attributes ExerciseAttribute[]
@@unique([attributeNameId, value])
@@map("exercise_attribute_values")
}
model ExerciseAttribute {
id String @id @default(cuid())
exerciseId String
attributeNameId String
attributeValueId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
exercise Exercise @relation(fields: [exerciseId], references: [id], onDelete: Cascade)
attributeName ExerciseAttributeName @relation(fields: [attributeNameId], references: [id])
attributeValue ExerciseAttributeValue @relation(fields: [attributeValueId], references: [id])
@@unique([exerciseId, attributeNameId, attributeValueId])
@@map("exercise_attributes")
}
// Enums
enum ExercisePrivacy {
PUBLIC
PRIVATE
}
// Noms d'attributs
enum ExerciseAttributeNameEnum {
TYPE
PRIMARY_MUSCLE
SECONDARY_MUSCLE
EQUIPMENT
MECHANICS_TYPE
}
// Toutes les valeurs possibles
enum ExerciseAttributeValueEnum {
// Types d'exercices
BODYWEIGHT
STRENGTH
POWERLIFTING
CALISTHENIC
PLYOMETRICS
STRETCHING
STRONGMAN
CARDIO
STABILIZATION
POWER
RESISTANCE
CROSSFIT
WEIGHTLIFTING
// Groupes musculaires
BICEPS
SHOULDERS
CHEST
BACK
GLUTES
TRICEPS
HAMSTRINGS
QUADRICEPS
FOREARMS
CALVES
TRAPS
ABDOMINALS
NECK
LATS
ADDUCTORS
ABDUCTORS
OBLIQUES
GROIN
FULL_BODY
ROTATOR_CUFF
HIP_FLEXOR
ACHILLES_TENDON
FINGERS
// Équipements
DUMBBELL
KETTLEBELLS
BARBELL
SMITH_MACHINE
BODY_ONLY
OTHER
BANDS
EZ_BAR
MACHINE
DESK
PULLUP_BAR
NONE
CABLE
MEDICINE_BALL
SWISS_BALL
FOAM_ROLL
WEIGHT_PLATE
TRX
BOX
ROPES
SPIN_BIKE
STEP
BOSU
TYRE
SANDBAG
POLE
BENCH
WALL
BAR
RACK
CAR
SLED
CHAIN
SKIERG
ROPE
NA
// Types de mécanique
ISOLATION
COMPOUND
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 760 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Some files were not shown because too many files have changed in this diff Show More