├── .env.example
├── .github
└── workflows
│ ├── build.yml
│ └── ci.yml
├── .gitignore
├── .prettierrc
├── Dockerfile
├── README.md
├── backend
├── .dockerignore
├── .eslintrc.js
├── .gitignore
├── .prettierrc
├── README.md
├── nest-cli.json
├── package.json
├── prisma
│ ├── migrations
│ │ ├── 20240414130439_init_prisma
│ │ │ └── migration.sql
│ │ ├── 20240512104520_add_offers_and_transactions
│ │ │ └── migration.sql
│ │ ├── 20240514173402_unique_user_offer
│ │ │ └── migration.sql
│ │ ├── 20240528171404_messages
│ │ │ └── migration.sql
│ │ ├── 20240601114100_add_offer_in_message
│ │ │ └── migration.sql
│ │ ├── 20250624071110_add_images
│ │ │ └── migration.sql
│ │ ├── 20250916115632_add_stripe_connect
│ │ │ └── migration.sql
│ │ └── migration_lock.toml
│ └── schema.prisma
├── src
│ ├── app.module.ts
│ ├── auth
│ │ ├── auth.controller.ts
│ │ ├── auth.module.ts
│ │ ├── auth.service.ts
│ │ ├── cookie-serializer.ts
│ │ ├── exception.filter.ts
│ │ ├── local-auth.guard.ts
│ │ ├── local.strategy.ts
│ │ └── redirected-error.exception.ts
│ ├── aws
│ │ └── aws-s3.service.ts
│ ├── main.ts
│ ├── prisma
│ │ └── prisma.service.ts
│ ├── remix
│ │ ├── remix.controller.ts
│ │ └── remix.service.ts
│ ├── scripts
│ │ └── seed-offers.ts
│ └── stripe
│ │ ├── stripe.controller.ts
│ │ └── stripe.service.ts
├── start.sh
├── tsconfig.build.json
└── tsconfig.json
├── biome.json
├── cache
└── dump.rdb
├── docker-compose.dev.yml
├── docker-compose.prod.yml
├── docker-compose.redis.yml
├── frontend
├── .dockerignore
├── .eslintignore
├── .eslintrc.cjs
├── .gitignore
├── README.md
├── app
│ ├── components
│ │ ├── Chatbox.tsx
│ │ ├── Footer.tsx
│ │ ├── Navbar.tsx
│ │ ├── forms.tsx
│ │ └── ui
│ │ │ ├── button.tsx
│ │ │ ├── checkbox.tsx
│ │ │ ├── input.tsx
│ │ │ ├── label.tsx
│ │ │ ├── pagination.tsx
│ │ │ ├── select.tsx
│ │ │ └── textarea.tsx
│ ├── entry.client.tsx
│ ├── entry.server.tsx
│ ├── global.css
│ ├── lib
│ │ └── utils.ts
│ ├── root.tsx
│ ├── routes
│ │ ├── _assets
│ │ │ └── logo-coup-de-pouce-dark.png
│ │ ├── _index.tsx
│ │ ├── _public+
│ │ │ ├── _layout.tsx
│ │ │ ├── login.tsx
│ │ │ ├── my-services.$offerId.tsx
│ │ │ ├── my-services.index.tsx
│ │ │ ├── prestataires.$prestataireId.tsx
│ │ │ ├── prestataires.index.tsx
│ │ │ ├── profile.tsx
│ │ │ ├── register.tsx
│ │ │ ├── stripe.dashboard.tsx
│ │ │ ├── stripe.onboarding.tsx
│ │ │ ├── stripe.refresh.tsx
│ │ │ ├── stripe.return.tsx
│ │ │ ├── transactions.$transactionId.tsx
│ │ │ └── transactions.index.tsx
│ │ └── offers.$offerId.tsx
│ └── server
│ │ ├── auth.server.ts
│ │ ├── offers.server.ts
│ │ ├── profile.server.ts
│ │ ├── providers.server.ts
│ │ ├── stripe.server.ts
│ │ └── transactions.server.ts
├── components.json
├── env.d.ts
├── index.cjs
├── index.d.cts
├── package.json
├── postcss.config.cjs
├── public
│ └── favicon.ico
├── tailwind.config.cjs
├── tsconfig.json
├── vite.config.ts
├── vite.config.ts.timestamp-1751794841752-fdfb69122621c.mjs
└── vite.config.ts.timestamp-1758024100248-611b4875cc2f78.mjs
├── package-lock.json
├── package.json
├── packages
├── eslint-config
│ ├── base.js
│ └── package.json
└── typescript-config
│ ├── base.json
│ └── package.json
└── turbo.json
/.env.example:
--------------------------------------------------------------------------------
1 | DOCKERHUB_TOKEN=
2 | DOCKERHUB_USERNAME=
3 |
4 | REDIS_URL=""
5 | SESSION_SECRET=""
6 | DATABASE_URL=""
7 |
8 |
9 | PORT=3000
10 | AWS_ACCESS_KEY=""
11 | AWS_SECRET=""
12 | AWS_REGION=""
13 | AWS_BUCKET_NAME=""
14 |
15 | PRISMA_OPTIMIZE_API_KEY=""
16 |
17 | STRIPE_SECRET_KEY=""
18 | STRIPE_WEBHOOK_SECRET=""
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: 🐳 Build And Push Docker Image
2 | on:
3 | workflow_call:
4 | inputs:
5 | tag:
6 | type: string
7 | description: The tag to push to the Docker registry.
8 | # required: true
9 | # default: latest
10 |
11 | jobs:
12 | build:
13 | name: 🐳 Build
14 | # only build/deploy main branch on pushes
15 | # if: ${{ (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && github.event_name == 'push' }}
16 | if: ${{ (github.ref == 'refs/heads/main') && github.event_name == 'push' }}
17 | runs-on: ubuntu-latest
18 | steps:
19 | - name: ⬇️ Checkout repo
20 | uses: actions/checkout@v4.1.1
21 |
22 | - name: 🧑💻 Login to Docker Hub
23 | uses: docker/login-action@v3.0.0
24 | with:
25 | username: ${{ secrets.DOCKERHUB_USERNAME }}
26 | password: ${{ secrets.DOCKERHUB_TOKEN }}
27 | logout: true
28 |
29 | - name: 🐳 Set up Docker Buildx
30 | uses: docker/setup-buildx-action@v3.0.0
31 |
32 | # Setup cache
33 | - name: ⚡️ Cache Docker layers
34 | uses: actions/cache@v4.2.3
35 | with:
36 | path: /tmp/.buildx-cache
37 | key: ${{ runner.os }}-buildx-${{ github.sha }}-${{ github.ref_name }}
38 | restore-keys: |
39 | ${{ runner.os }}-buildx-
40 | - name: 🐳 Build Production Image
41 | if: ${{ github.ref == 'refs/heads/main' }}
42 | uses: docker/build-push-action@v5.1.0
43 | with:
44 | context: .
45 | push: true
46 | tags: varkoff/nestjs-remix-monorepo:production
47 | cache-from: type=local,src=/tmp/.buildx-cache
48 | cache-to: type=local,mode=max,dest=/tmp/.buildx-cache-new
49 |
50 | # - name: 🐳 Build Staging Image
51 | # if: ${{ github.ref == 'refs/heads/dev' }}
52 | # uses: docker/build-push-action@v5.1.0
53 | # with:
54 | # context: .
55 | # push: true
56 | # tags: varkoff/nestjs-remix-monorepo:latest
57 |
58 | # cache-from: type=local,src=/tmp/.buildx-cache
59 | # cache-to: type=local,mode=max,dest=/tmp/.buildx-cache-new
60 |
61 | # This ugly bit is necessary if you don't want your cache to grow forever
62 | # till it hits GitHub's limit of 5GB.
63 | # Temp fix
64 | # https://github.com/docker/build-push-action/issues/252
65 | # https://github.com/moby/buildkit/issues/1896
66 | - name: 🚚 Move cache
67 | run: |
68 | rm -rf /tmp/.buildx-cache
69 | mv /tmp/.buildx-cache-new /tmp/.buildx-cache
70 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: 🚀 Deploy
2 | on:
3 | push:
4 | branches:
5 | - main
6 | - dev
7 | pull_request: {}
8 |
9 | permissions:
10 | actions: write
11 | contents: read
12 |
13 | jobs:
14 | lint:
15 | name: ⬣ ESLint
16 | runs-on: ubuntu-latest
17 | env:
18 | TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
19 | TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
20 | TURBO_REMOTE_ONLY: true
21 |
22 | steps:
23 | - name: Check out code
24 | uses: actions/checkout@v4
25 | with:
26 | fetch-depth: 2
27 |
28 | - name: Setup Node.js environment
29 | uses: actions/setup-node@v4
30 | with:
31 | node-version: 20
32 | cache: 'npm'
33 |
34 | - name: Install dependencies
35 | run: npm install
36 |
37 | - name: Build Application
38 | run: npm run build
39 |
40 | - name: 🔬 Lint
41 | run: npm run lint
42 |
43 | typecheck:
44 | name: ʦ TypeScript
45 | runs-on: ubuntu-latest
46 | env:
47 | TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
48 | TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
49 | TURBO_REMOTE_ONLY: true
50 |
51 | steps:
52 | - name: Check out code
53 | uses: actions/checkout@v4
54 | with:
55 | fetch-depth: 2
56 |
57 | - name: Setup Node.js environment
58 | uses: actions/setup-node@v4
59 | with:
60 | node-version: 20
61 | cache: 'npm'
62 |
63 | - name: Install dependencies
64 | run: npm install
65 |
66 | - name: Build Application
67 | run: npm run build
68 |
69 | - name: 🔎 Type check
70 | run: npm run typecheck --if-present
71 |
72 | build:
73 | name: 🐳 build
74 | uses: ./.github/workflows/build.yml
75 | secrets: inherit
76 |
77 | deploy:
78 | name: 🚀 Deploy
79 | runs-on: [self-hosted]
80 | needs: [build, lint, typecheck]
81 | # needs: [build]
82 | # only build/deploy main branch on pushes
83 | # if: ${{ (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && github.event_name == 'push' }}
84 | if: ${{ (github.ref == 'refs/heads/main') && github.event_name == 'push' }}
85 | env:
86 | TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
87 | TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
88 | TURBO_REMOTE_ONLY: true
89 |
90 | steps:
91 | - name: Cache node modules
92 | uses: actions/cache@v4.2.3
93 | with:
94 | path: ~/.npm
95 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
96 | restore-keys: |
97 | ${{ runner.os }}-node-
98 |
99 | - name: ⬇️ Checkout repo
100 | uses: actions/checkout@v4.1.1
101 |
102 | - name: Login to Docker Hub
103 | uses: docker/login-action@v2
104 | with:
105 | username: ${{ secrets.DOCKERHUB_USERNAME }}
106 | password: ${{ secrets.DOCKERHUB_TOKEN }}
107 | # - name: 🚀 Run Docker Compose on Staging
108 | # if: ${{ github.ref == 'refs/heads/dev' }}
109 | # env:
110 | # NODE_ENV: staging
111 | # run: |
112 | # docker pull varkoff/nestjs-remix-monorepo:latest
113 | # docker compose -f docker-compose.staging.yaml up -d
114 | # docker system prune --all --volumes --force
115 |
116 | - name: 🚀 Run Docker Compose on Production
117 | if: ${{ github.ref == 'refs/heads/main' }}
118 | env:
119 | NODE_ENV: production
120 | DATABASE_URL: ${{ secrets.DATABASE_URL }}
121 | run: |
122 | docker pull varkoff/nestjs-remix-monorepo:production
123 | docker compose -f docker-compose.prod.yml up -d
124 | docker system prune --all --volumes --force
125 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .turbo
2 | node_modules
3 |
4 | # compiled output
5 | /dist
6 | /node_modules
7 | /build
8 |
9 | # Logs
10 | logs
11 | *.log
12 | npm-debug.log*
13 | pnpm-debug.log*
14 | yarn-debug.log*
15 | yarn-error.log*
16 | lerna-debug.log*
17 |
18 | # OS
19 | .DS_Store
20 |
21 | # Tests
22 | /coverage
23 | /.nyc_output
24 |
25 | # IDEs and editors
26 | /.idea
27 | .project
28 | .classpath
29 | .c9/
30 | *.launch
31 | .settings/
32 | *.sublime-workspace
33 |
34 | # IDE - VSCode
35 | .vscode/*
36 | !.vscode/settings.json
37 | !.vscode/tasks.json
38 | !.vscode/launch.json
39 | !.vscode/extensions.json
40 |
41 | # dotenv environment variable files
42 | .env
43 | .env.development.local
44 | .env.test.local
45 | .env.production.local
46 | .env.local
47 |
48 | # temp directory
49 | .temp
50 | .tmp
51 |
52 | # Runtime data
53 | pids
54 | *.pid
55 | *.seed
56 | *.pid.lock
57 |
58 | # Diagnostic reports (https://nodejs.org/api/report.html)
59 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
60 | out/
61 | cache/
62 | course.md
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "tabWidth": 2,
3 | "useTabs": false
4 | }
5 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM --platform=amd64 node:18-alpine As base
2 |
3 | FROM base AS builder
4 |
5 | # Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
6 | RUN apk add --no-cache libc6-compat
7 | RUN apk update
8 | # Set working directory
9 | WORKDIR /app
10 | RUN npm install --global turbo
11 | COPY --chown=node:node . .
12 | RUN turbo prune @virgile/backend --docker
13 |
14 | # Add lockfile and package.json's of isolated subworkspace
15 | FROM base AS installer
16 | RUN apk add --no-cache libc6-compat
17 | RUN apk update
18 | WORKDIR /app
19 |
20 | # First install the dependencies (as they change less often)
21 | COPY .gitignore .gitignore
22 | COPY --chown=node:node --from=builder /app/out/json/ .
23 | COPY --chown=node:node --from=builder /app/out/package-lock.json ./package-lock.json
24 | RUN npm install
25 |
26 | # Build the project
27 | COPY --from=builder /app/out/full/ .
28 | COPY turbo.json turbo.json
29 |
30 | # Uncomment and use build args to enable remote caching
31 | ARG TURBO_TEAM
32 | ENV TURBO_TEAM=$TURBO_TEAM
33 |
34 | ARG TURBO_TOKEN
35 | ENV TURBO_TOKEN=$TURBO_TOKEN
36 | ENV TZ=Europe/Paris
37 | ENV NODE_ENV="production"
38 |
39 | ADD backend/prisma backend/prisma
40 | RUN cd backend && npx prisma generate
41 |
42 | RUN npm run build
43 |
44 | FROM base AS runner
45 | WORKDIR /app
46 |
47 | # Don't run production as root
48 | RUN addgroup --system --gid 1001 nodejs
49 | RUN adduser --system --uid 1001 remix-api
50 | USER remix-api
51 |
52 | # ENV TZ=Europe/Paris
53 | # ENV NODE_ENV="production"
54 |
55 | COPY --chown=remix-api:nodejs --from=installer /app/backend/package.json ./backend/package.json
56 | COPY --chown=remix-api:nodejs --from=installer /app/backend/dist ./backend/dist
57 | COPY --chown=remix-api:nodejs --from=installer /app/node_modules ./node_modules
58 | COPY --chown=remix-api:nodejs --from=installer /app/node_modules/@virgile/frontend ./node_modules/@virgile/frontend
59 | COPY --chown=remix-api:nodejs --from=installer /app/node_modules/@virgile/typescript-config ./node_modules/@virgile/typescript-config
60 | COPY --chown=remix-api:nodejs --from=installer /app/node_modules/@virgile/eslint-config ./node_modules/@virgile/eslint-config
61 | COPY --chown=remix-api:nodejs --from=installer /app/backend/prisma ./backend/prisma
62 |
63 | COPY --chown=remix-api:nodejs --from=builder /app/backend/start.sh ./backend/start.sh
64 |
65 | ENTRYPOINT [ "backend/start.sh" ]
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ---
2 |
3 | ## 🚀 Tu as aimé cette formation ? Passe à React Router 7 !
4 |
5 | [](https://algomax.fr/formation-react-router-7)
6 |
7 | **React Router 7**, c'est Remix réinventé : **plus simple, plus rapide, avec Vite 6**. Toute la puissance du SSR, des loaders et actions, mais sans la complexité.
8 |
9 | ✨ **Formation complète** : 76 leçons, 19h56 de contenu
10 | 💼 **Projets réels** : E-commerce avec Stripe, Better Auth, Prisma, AWS S3
11 | 🎯 **Stack moderne** : TypeScript, Tailwind v4, shadcn-ui, déploiement prod
12 |
13 | ### 🎁 **-65% avec le code REMIXJAM** → [Accéder à la formation](https://algomax.fr/formation-react-router-7)
14 |
15 | ---
16 |
17 | ## Stack Remix with NestJS, Turborepo
18 |
19 | ### 📋 Pré-requis
20 |
21 | - [Node.js via nvm](https://github.com/nvm-sh/nvm#installing-and-updating) (recommandé)
22 | - [Docker & Docker Compose](https://docs.docker.com/get-docker/)
23 |
24 | ### 🏗️ Architecture
25 |
26 | Monorepo Turborepo : NestJS sert le frontend Remix directement. Pas d'API REST séparée, juste le serveur NestJS qui monte Remix.
27 |
28 | ```
29 | ├── backend/ # NestJS + Prisma + Remix server
30 | ├── frontend/ # Remix app (UI/routes)
31 | └── packages/ # Configs partagées (ESLint, TS)
32 | ```
33 |
34 | ### 🚀 Setup local
35 |
36 | ```bash
37 | # 1. Redis (sessions)
38 | docker compose -f docker-compose.redis.yml up -d
39 |
40 | # 2. Dépendances
41 | npm install
42 |
43 | # 3. Créer .env à la racine (voir section Variables ci-dessous)
44 |
45 | # 4. Copier .env dans backend/ à chaque modif
46 | cp .env backend/.env
47 |
48 | # 5. DB migrations
49 | cd backend
50 | npx prisma migrate dev
51 | npx prisma generate
52 |
53 | # 6. Lancer dev (depuis racine)
54 | cd ..
55 | npm run dev
56 | ```
57 |
58 | App dispo sur `http://localhost:3000`
59 |
60 | ### 🔑 Variables d'environnement
61 |
62 | Créer `.env` **à la racine** et dans `backend/` :
63 |
64 | ```bash
65 | # Base de données PostgreSQL
66 | DATABASE_URL="postgresql://user:password@localhost:5432/dbname"
67 |
68 | # Redis (sessions utilisateur)
69 | REDIS_URL="redis://localhost:6379"
70 |
71 | # Secret session (générer string aléatoire sécurisé)
72 | SESSION_SECRET="your-super-secret-key-change-me"
73 |
74 | # Environnement
75 | NODE_ENV="development"
76 | PORT="3000"
77 |
78 | # Stripe (paiements)
79 | STRIPE_SECRET_KEY="sk_test_..." # Clé secrète Stripe
80 | STRIPE_WEBHOOK_SECRET="whsec_..." # Secret webhook Stripe
81 |
82 | # AWS S3 (upload fichiers)
83 | AWS_ACCESS_KEY="AKIA..."
84 | AWS_SECRET="your-secret"
85 | AWS_REGION="eu-west-3"
86 | AWS_BUCKET_NAME="your-bucket"
87 |
88 | # Optionnel : seed BD avec X offres fictives
89 | SEED_OFFERS_COUNT="1000"
90 | ```
91 |
92 | > ⚠️ **Important** : Copier `.env` dans `backend/` après chaque modif : `cp .env backend/.env`
93 |
94 | ### 📹 Tutoriels vidéo
95 |
96 | Retrouvez le guide vidéo pour configurer ce projet sur YouTube (en français)
97 |
98 | - [Partie 1 : Configurer Remix, NestJS et Turborepo](https://www.youtube.com/watch?v=yv96ar6XNnU&list=PL2TfCPpDwZVTQr3Ox9KT0Ex2D-QajUyhM&index=1)
99 | - [Partie 2 : CI/CD, Déploiement avec Github Actions et Docker](https://www.youtube.com/watch?v=KCMFcHTYf9o&list=PL2TfCPpDwZVTQr3Ox9KT0Ex2D-QajUyhM&index=2)
100 | - [Partie 3: Intégration Design System | Figma, Tailwind CSS & Shadcn UI](https://www.youtube.com/watch?v=GWfZewdFx4o&list=PL2TfCPpDwZVTQr3Ox9KT0Ex2D-QajUyhM&index=3)
101 | - [Partie 4: Authentification avec Redis, express-session, Passport.js](https://youtu.be/SyuXRIbECEY?list=PL2TfCPpDwZVTQr3Ox9KT0Ex2D-QajUyhM)
102 | - [Partie 5: Authentification par token, inscription avec Redis, express-session, Passport.js](https://youtu.be/k6KrmuVgvec)
103 | - [Partie 6: Développement des fonctionnalités principales d'échange de service, faire une offre, éditer le profil ...](https://youtu.be/0C4Xh1x7flY)
104 | - [Partie 7: Intégrer Amazon S3 pour héberger les fichiers des utilisateurs](https://youtu.be/4_Q8dsj-X9k)
105 | - [Partie 8: J'ai redesigné ce SaaS avec l'IA | Formation Remix, NestJS, Shadcn UI 2024](https://www.youtube.com/watch?v=ZxYvbvF1dDA)
106 | - [Partie 9: Implémentation de Nuqs: filtres et pagination côté serveur | Formation Remix, NestJS, Shadcn UI 2024](https://youtu.be/4nF_dgbkorw?list=PL2TfCPpDwZVTQr3Ox9KT0Ex2D-QajUyhM)
107 | - [Partie 10: Implémenter Stripe Connect en multi-tenant | Formation Remix, NestJS, Shadcn UI 2024](https://youtu.be/uRzK8kVGJeY?list=PL2TfCPpDwZVTQr3Ox9KT0Ex2D-QajUyhM)
108 |
109 | ### Motivation
110 |
111 | En 4 ans de développement, je n'ai pas encore trouvé une stack qui me plaît. Il y a toujours un élément qui manque (une fonctionnalité, ou une limitation technique).
112 |
113 | En tant que développeur fullstack, je souhaite bénéficier du meilleur des deux mondes.
114 |
115 | Je souhaite utiliser une technologie :
116 |
117 | - simple à utiliser
118 | - qui me permet d'implémenter une fonctionnalité rapidement
119 | - qui me permet d'avoir un contrôle total sur la logique, front comme back
120 |
121 | [Remix](https://remix.run) répond à mes attentes. C'est un framework frontend qui me permet d'utiliser Javascript et React pour créer des sites web performants et ergonomiques.
122 |
123 | Ce framework est full-stack, signifiant que tu n'as pas besoin de configurer un serveur pour ajouter une logique backend. Tu peux appeler une base de donnée, intégrer l'authentification, et plein d'autres fonctionnalités.
124 |
125 | Cependant, il n'a pas suffisamment de maturité. Il manque plein de features, comme les middleware (qui sont très utiles pour ne pas recopier la même logique de protection des routes)
126 |
127 | J'utilisais donc [NestJS](https://nestjs.com/) comme serveur séparé jusqu'à présent. Ce framework Node.JS m'a permi d'utiliser Javascript pour configurer une base de donnée, des routes et toute ma logique métier.
128 |
129 | Ensuite, j'appelle chaque route dans Remix. Mais c'est sujet à beaucoup d'erreurs d'inattention, ou de perte de synchronisation. J'informe Remix des réponses API de NestJS en déclarant un schéma Zod, qui peut être erroné, et générer des erreurs.
130 |
131 | Je perd donc pas mal de temps à :
132 |
133 | - déclarer des schémas Zod
134 | - réparer des bugs, erreurs d'inattention
135 | - déclarer des méthodes pour appeler mes routes
136 |
137 | MAIS c'est terminé ! J'ai découvert une stack qui me permet d'intégrer ce serveur NestJS avec Remix. Cela remplace le serveur de Remix (celui qui faisait les appels à NestJS) par le serveur NestJS, directement.
138 |
139 | Voici les avantages :
140 |
141 | - aucune duplication de code
142 | - aucun schéma zod
143 | - aucun bug de ce style à régler
144 |
145 | C'est un gain de temps énorme.
146 |
147 | Et dans cette formation, je te montre comment j'ai configuré cette stack pour que tu puisses l'utiliser dans tes projets.
148 |
149 | ---
150 |
151 | ## ⚠️ Note importante
152 |
153 | **Cette stack n'est plus recommandée pour de nouveaux projets.**
154 |
155 | L'écosystème a évolué : Remix a fusionné avec React Router 7, qui simplifie énormément l'architecture. Plus besoin de NestJS, Redis, ni Docker pour du dev local. Ma nouvelle stack (enseignée dans [la formation React Router 7](https://algomax.fr/formation-react-router-7)) est **plus simple, plus rapide, plus maintenable**.
156 |
157 | Ce repo reste utile pour apprendre, mais pour du prod, regarde React Router 7.
158 |
159 | 💬 **Des questions ?** Rejoins le [Discord Algomax](https://algomax.fr/discord)
160 |
161 | ---
162 |
163 | ## 🚀 Next Steps (améliorations possibles)
164 |
165 | Si tu veux moderniser ce projet :
166 |
167 | ### 1. **Retirer Redis** (optionnel)
168 |
169 | Remplacer express-session + Redis par auth en DB avec Prisma. Stocker sessions directement en PostgreSQL. Ça supprime la dépendance Docker/Redis en local.
170 |
171 | ### 2. **Migrer vers React Router 7**
172 |
173 | - Activer tous les Remix future flags
174 | - Updater vers dernière version Remix
175 | - Migrer vers React Router 7 (guide officiel : [reactrouter.com](https://reactrouter.com))
176 |
177 | ### 3. **Retirer NestJS** (optionnel)
178 |
179 | React Router 7 suffit pour la plupart des apps. Garde NestJS seulement si tu as besoin de microservices, websockets complexes, ou logique backend lourde.
180 |
181 | **Résultat** : Stack ultra-simple avec juste React Router 7 + Prisma + PostgreSQL.
182 |
--------------------------------------------------------------------------------
/backend/.dockerignore:
--------------------------------------------------------------------------------
1 | Dockerfile
2 | .dockerignore
3 | node_modules
4 | npm-debug.log
5 | dist
6 |
--------------------------------------------------------------------------------
/backend/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | parser: '@typescript-eslint/parser',
3 | parserOptions: {
4 | project: 'tsconfig.json',
5 | tsconfigRootDir: __dirname,
6 | sourceType: 'module',
7 | },
8 | // plugins: ['@typescript-eslint/eslint-plugin'],
9 | extends: [
10 | 'plugin:@typescript-eslint/recommended',
11 | // 'plugin:prettier/recommended',
12 | ],
13 | root: true,
14 | env: {
15 | node: true,
16 | jest: true,
17 | },
18 | ignorePatterns: ['.eslintrc.js', 'dist/', 'node_modules/', '**.d.ts'],
19 | rules: {
20 | '@typescript-eslint/interface-name-prefix': 'off',
21 | '@typescript-eslint/explicit-function-return-type': 'off',
22 | '@typescript-eslint/explicit-module-boundary-types': 'off',
23 | '@typescript-eslint/no-explicit-any': 'off',
24 | },
25 | };
26 |
--------------------------------------------------------------------------------
/backend/.gitignore:
--------------------------------------------------------------------------------
1 | # compiled output
2 | /dist
3 | /node_modules
4 | /build
5 |
6 | # Logs
7 | logs
8 | *.log
9 | npm-debug.log*
10 | pnpm-debug.log*
11 | yarn-debug.log*
12 | yarn-error.log*
13 | lerna-debug.log*
14 |
15 | # OS
16 | .DS_Store
17 |
18 | # Tests
19 | /coverage
20 | /.nyc_output
21 |
22 | # IDEs and editors
23 | /.idea
24 | .project
25 | .classpath
26 | .c9/
27 | *.launch
28 | .settings/
29 | *.sublime-workspace
30 |
31 | # IDE - VSCode
32 | .vscode/*
33 | !.vscode/settings.json
34 | !.vscode/tasks.json
35 | !.vscode/launch.json
36 | !.vscode/extensions.json
37 |
38 | # dotenv environment variable files
39 | .env
40 | .env.development.local
41 | .env.test.local
42 | .env.production.local
43 | .env.local
44 |
45 | # temp directory
46 | .temp
47 | .tmp
48 |
49 | # Runtime data
50 | pids
51 | *.pid
52 | *.seed
53 | *.pid.lock
54 |
55 | # Diagnostic reports (https://nodejs.org/api/report.html)
56 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
57 |
--------------------------------------------------------------------------------
/backend/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": true,
3 | "trailingComma": "all"
4 | }
--------------------------------------------------------------------------------
/backend/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | [circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
6 | [circleci-url]: https://circleci.com/gh/nestjs/nest
7 |
8 | A progressive Node.js framework for building efficient and scalable server-side applications.
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
24 |
25 | ## Description
26 |
27 | [Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
28 |
29 | ## Installation
30 |
31 | ```bash
32 | $ npm install
33 | ```
34 |
35 | ## Running the app
36 |
37 | ```bash
38 | # development
39 | $ npm run start
40 |
41 | # watch mode
42 | $ npm run start:dev
43 |
44 | # production mode
45 | $ npm run start:prod
46 | ```
47 |
48 | ## Test
49 |
50 | ```bash
51 | # unit tests
52 | $ npm run test
53 |
54 | # e2e tests
55 | $ npm run test:e2e
56 |
57 | # test coverage
58 | $ npm run test:cov
59 | ```
60 |
61 | ## Support
62 |
63 | Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
64 |
65 | ## Stay in touch
66 |
67 | - Author - [Kamil Myśliwiec](https://kamilmysliwiec.com)
68 | - Website - [https://nestjs.com](https://nestjs.com/)
69 | - Twitter - [@nestframework](https://twitter.com/nestframework)
70 |
71 | ## License
72 |
73 | Nest is [MIT licensed](LICENSE).
74 |
--------------------------------------------------------------------------------
/backend/nest-cli.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/nest-cli",
3 | "collection": "@nestjs/schematics",
4 | "sourceRoot": "src",
5 | "compilerOptions": {
6 | "deleteOutDir": true
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/backend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@virgile/backend",
3 | "version": "0.0.1",
4 | "description": "",
5 | "author": "",
6 | "private": true,
7 | "license": "UNLICENSED",
8 | "main": "./dist/remix/remix.service.js",
9 | "types": "./dist/remix/remix.service.d.ts",
10 | "scripts": {
11 | "dev": "run-p dev:compile dev:watch",
12 | "dev:compile": "tsc --build --watch",
13 | "dev:watch": "nodemon node dist/main.js",
14 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
15 | "prebuild": "rimraf dist tsconfig.tsbuildinfo",
16 | "build": "tsc --build",
17 | "prisma:prestart": "prisma generate",
18 | "prisma:generate": "prisma generate",
19 | "start": "node dist/main",
20 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\"",
21 | "typecheck": "tsc --noEmit",
22 | "seed:offers": "npm run prisma:generate && ts-node --transpile-only src/scripts/seed-offers.ts"
23 | },
24 | "dependencies": {
25 | "@aws-sdk/client-s3": "^3.835.0",
26 | "@aws-sdk/s3-request-presigner": "^3.835.0",
27 | "@nestjs/common": "^10.0.0",
28 | "@nestjs/core": "^10.0.0",
29 | "@nestjs/passport": "^10.0.3",
30 | "@nestjs/platform-express": "^10.0.0",
31 | "@paralleldrive/cuid2": "^2.2.2",
32 | "@prisma/client": "^5.12.1",
33 | "@remix-run/express": "^2.8.1",
34 | "bcryptjs": "^2.4.3",
35 | "body-parser": "^1.20.2",
36 | "connect-redis": "^7.1.1",
37 | "express-session": "^1.18.0",
38 | "ioredis": "^5.3.2",
39 | "passport": "^0.7.0",
40 | "passport-local": "^1.0.0",
41 | "reflect-metadata": "^0.2.0",
42 | "rxjs": "^7.8.1",
43 | "stripe": "^18.5.0"
44 | },
45 | "devDependencies": {
46 | "@nestjs/cli": "^10.0.0",
47 | "@nestjs/schematics": "^10.0.0",
48 | "@nestjs/testing": "^10.0.0",
49 | "@types/bcryptjs": "^2.4.6",
50 | "@types/body-parser": "^1.19.5",
51 | "@types/express": "^4.17.17",
52 | "@types/express-session": "^1.18.0",
53 | "@types/jest": "^29.5.2",
54 | "@types/node": "^20.3.1",
55 | "@types/passport-local": "^1.0.38",
56 | "@virgile/eslint-config": "*",
57 | "@virgile/frontend": "*",
58 | "@virgile/typescript-config": "*",
59 | "nodemon": "^3.1.0",
60 | "npm-run-all": "^4.1.5",
61 | "prettier": "^3.0.0",
62 | "prisma": "^5.12.1",
63 | "source-map-support": "^0.5.21",
64 | "ts-loader": "^9.4.3",
65 | "ts-node": "^10.9.1",
66 | "tsconfig-paths": "^4.2.0",
67 | "typescript": "^5.1.3"
68 | },
69 | "jest": {
70 | "moduleFileExtensions": [
71 | "js",
72 | "json",
73 | "ts"
74 | ],
75 | "rootDir": "src",
76 | "testRegex": ".*\\.spec\\.ts$",
77 | "transform": {
78 | "^.+\\.(t|j)s$": "ts-jest"
79 | },
80 | "collectCoverageFrom": [
81 | "**/*.(t|j)s"
82 | ],
83 | "coverageDirectory": "../coverage",
84 | "testEnvironment": "node"
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/backend/prisma/migrations/20240414130439_init_prisma/migration.sql:
--------------------------------------------------------------------------------
1 | -- CreateTable
2 | CREATE TABLE "User" (
3 | "id" TEXT NOT NULL,
4 | "email" TEXT NOT NULL,
5 | "name" TEXT,
6 | "password" TEXT NOT NULL,
7 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
8 | "updatedAt" TIMESTAMP(3) NOT NULL,
9 |
10 | CONSTRAINT "User_pkey" PRIMARY KEY ("id")
11 | );
12 |
13 | -- CreateTable
14 | CREATE TABLE "Session" (
15 | "id" TEXT NOT NULL,
16 | "userId" TEXT NOT NULL,
17 | "ipAddress" TEXT,
18 | "userAgent" TEXT,
19 | "sessionToken" TEXT NOT NULL,
20 |
21 | CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
22 | );
23 |
24 | -- CreateIndex
25 | CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
26 |
27 | -- CreateIndex
28 | CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken");
29 |
30 | -- AddForeignKey
31 | ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
32 |
--------------------------------------------------------------------------------
/backend/prisma/migrations/20240512104520_add_offers_and_transactions/migration.sql:
--------------------------------------------------------------------------------
1 | -- CreateTable
2 | CREATE TABLE "Offer" (
3 | "id" TEXT NOT NULL,
4 | "title" TEXT NOT NULL,
5 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
6 | "updatedAt" TIMESTAMP(3) NOT NULL,
7 | "description" TEXT NOT NULL,
8 | "price" DOUBLE PRECISION NOT NULL,
9 | "active" BOOLEAN NOT NULL DEFAULT false,
10 | "recurring" BOOLEAN NOT NULL DEFAULT false,
11 | "userId" TEXT NOT NULL,
12 |
13 | CONSTRAINT "Offer_pkey" PRIMARY KEY ("id")
14 | );
15 |
16 | -- CreateTable
17 | CREATE TABLE "Transaction" (
18 | "id" TEXT NOT NULL,
19 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
20 | "updatedAt" TIMESTAMP(3) NOT NULL,
21 | "offerId" TEXT NOT NULL,
22 | "userId" TEXT NOT NULL,
23 |
24 | CONSTRAINT "Transaction_pkey" PRIMARY KEY ("id")
25 | );
26 |
27 | -- AddForeignKey
28 | ALTER TABLE "Offer" ADD CONSTRAINT "Offer_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
29 |
30 | -- AddForeignKey
31 | ALTER TABLE "Transaction" ADD CONSTRAINT "Transaction_offerId_fkey" FOREIGN KEY ("offerId") REFERENCES "Offer"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
32 |
33 | -- AddForeignKey
34 | ALTER TABLE "Transaction" ADD CONSTRAINT "Transaction_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
35 |
--------------------------------------------------------------------------------
/backend/prisma/migrations/20240514173402_unique_user_offer/migration.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - A unique constraint covering the columns `[offerId,userId]` on the table `Transaction` will be added. If there are existing duplicate values, this will fail.
5 |
6 | */
7 | -- CreateIndex
8 | CREATE UNIQUE INDEX "Transaction_offerId_userId_key" ON "Transaction"("offerId", "userId");
9 |
--------------------------------------------------------------------------------
/backend/prisma/migrations/20240528171404_messages/migration.sql:
--------------------------------------------------------------------------------
1 | -- CreateTable
2 | CREATE TABLE "Message" (
3 | "id" TEXT NOT NULL,
4 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
5 | "updatedAt" TIMESTAMP(3) NOT NULL,
6 | "content" TEXT NOT NULL,
7 | "userId" TEXT NOT NULL,
8 | "transactionId" TEXT NOT NULL,
9 |
10 | CONSTRAINT "Message_pkey" PRIMARY KEY ("id")
11 | );
12 |
13 | -- AddForeignKey
14 | ALTER TABLE "Message" ADD CONSTRAINT "Message_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
15 |
16 | -- AddForeignKey
17 | ALTER TABLE "Message" ADD CONSTRAINT "Message_transactionId_fkey" FOREIGN KEY ("transactionId") REFERENCES "Transaction"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
18 |
--------------------------------------------------------------------------------
/backend/prisma/migrations/20240601114100_add_offer_in_message/migration.sql:
--------------------------------------------------------------------------------
1 | -- AlterTable
2 | ALTER TABLE "Message" ADD COLUMN "price" DOUBLE PRECISION,
3 | ADD COLUMN "status" INTEGER DEFAULT 0;
4 |
--------------------------------------------------------------------------------
/backend/prisma/migrations/20250624071110_add_images/migration.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - A unique constraint covering the columns `[imageFileKey]` on the table `Offer` will be added. If there are existing duplicate values, this will fail.
5 | - A unique constraint covering the columns `[avatarFileKey]` on the table `User` will be added. If there are existing duplicate values, this will fail.
6 |
7 | */
8 | -- AlterTable
9 | ALTER TABLE "Offer" ADD COLUMN "imageFileKey" TEXT;
10 |
11 | -- AlterTable
12 | ALTER TABLE "User" ADD COLUMN "avatarFileKey" TEXT;
13 |
14 | -- CreateIndex
15 | CREATE UNIQUE INDEX "Offer_imageFileKey_key" ON "Offer"("imageFileKey");
16 |
17 | -- CreateIndex
18 | CREATE UNIQUE INDEX "User_avatarFileKey_key" ON "User"("avatarFileKey");
19 |
--------------------------------------------------------------------------------
/backend/prisma/migrations/20250916115632_add_stripe_connect/migration.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - A unique constraint covering the columns `[stripeProductId]` on the table `Offer` will be added. If there are existing duplicate values, this will fail.
5 | - A unique constraint covering the columns `[stripePriceId]` on the table `Offer` will be added. If there are existing duplicate values, this will fail.
6 | - A unique constraint covering the columns `[stripePaymentIntentId]` on the table `Transaction` will be added. If there are existing duplicate values, this will fail.
7 | - A unique constraint covering the columns `[stripeCheckoutSessionId]` on the table `Transaction` will be added. If there are existing duplicate values, this will fail.
8 | - A unique constraint covering the columns `[stripeAccountId]` on the table `User` will be added. If there are existing duplicate values, this will fail.
9 | - A unique constraint covering the columns `[stripeCustomerId]` on the table `User` will be added. If there are existing duplicate values, this will fail.
10 |
11 | */
12 | -- AlterTable
13 | ALTER TABLE "Offer" ADD COLUMN "stripePriceId" TEXT,
14 | ADD COLUMN "stripeProductId" TEXT;
15 |
16 | -- AlterTable
17 | ALTER TABLE "Transaction" ADD COLUMN "stripeCheckoutSessionId" TEXT,
18 | ADD COLUMN "stripePaymentIntentId" TEXT;
19 |
20 | -- AlterTable
21 | ALTER TABLE "User" ADD COLUMN "chargesEnabled" BOOLEAN NOT NULL DEFAULT false,
22 | ADD COLUMN "detailsSubmitted" BOOLEAN NOT NULL DEFAULT false,
23 | ADD COLUMN "payoutsEnabled" BOOLEAN NOT NULL DEFAULT false,
24 | ADD COLUMN "stripeAccountId" TEXT,
25 | ADD COLUMN "stripeCustomerId" TEXT;
26 |
27 | -- CreateIndex
28 | CREATE UNIQUE INDEX "Offer_stripeProductId_key" ON "Offer"("stripeProductId");
29 |
30 | -- CreateIndex
31 | CREATE UNIQUE INDEX "Offer_stripePriceId_key" ON "Offer"("stripePriceId");
32 |
33 | -- CreateIndex
34 | CREATE UNIQUE INDEX "Transaction_stripePaymentIntentId_key" ON "Transaction"("stripePaymentIntentId");
35 |
36 | -- CreateIndex
37 | CREATE UNIQUE INDEX "Transaction_stripeCheckoutSessionId_key" ON "Transaction"("stripeCheckoutSessionId");
38 |
39 | -- CreateIndex
40 | CREATE UNIQUE INDEX "User_stripeAccountId_key" ON "User"("stripeAccountId");
41 |
42 | -- CreateIndex
43 | CREATE UNIQUE INDEX "User_stripeCustomerId_key" ON "User"("stripeCustomerId");
44 |
--------------------------------------------------------------------------------
/backend/prisma/migrations/migration_lock.toml:
--------------------------------------------------------------------------------
1 | # Please do not edit this file manually
2 | # It should be added in your version-control system (i.e. Git)
3 | provider = "postgresql"
--------------------------------------------------------------------------------
/backend/prisma/schema.prisma:
--------------------------------------------------------------------------------
1 | // This is your Prisma schema file,
2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema
3 |
4 | // Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
5 | // Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
6 |
7 | generator client {
8 | provider = "prisma-client-js"
9 | binaryTargets = ["native", "debian-openssl-1.1.x"]
10 | }
11 |
12 | datasource db {
13 | provider = "postgresql"
14 | url = env("DATABASE_URL")
15 | }
16 |
17 | model User {
18 | id String @id @default(cuid())
19 | email String @unique
20 | name String?
21 | password String
22 | createdAt DateTime @default(now())
23 | updatedAt DateTime @updatedAt
24 | avatarFileKey String? @unique
25 |
26 | // Stripe Connect
27 | stripeAccountId String? @unique
28 | stripeCustomerId String? @unique
29 | chargesEnabled Boolean @default(false)
30 | payoutsEnabled Boolean @default(false)
31 | detailsSubmitted Boolean @default(false)
32 |
33 | sessions Session[]
34 | offers Offer[]
35 | transactions Transaction[]
36 | Message Message[]
37 | }
38 |
39 | model Session {
40 | id String @id @default(cuid())
41 | userId String
42 | user User @relation(fields: [userId], references: [id])
43 | ipAddress String?
44 | userAgent String?
45 | sessionToken String @unique
46 | }
47 |
48 | model Offer {
49 | id String @id @default(cuid())
50 | title String
51 | createdAt DateTime @default(now())
52 | updatedAt DateTime @updatedAt
53 | description String
54 | price Float
55 | active Boolean @default(false) // Est-ce que l'offre est visible sur le site ?
56 | recurring Boolean @default(false) // Exemple : Si l'utilisateur vend un objet,
57 | // il ne peut vendre cet objet qu'une seule fois.
58 | // Par conre, s'il rend un service, il peut le rendre plusieurs fois. Dans ce cas, l'application ne va pas supprimer l'offre après la première vente.
59 | userId String
60 | user User @relation(fields: [userId], references: [id])
61 | imageFileKey String? @unique
62 |
63 | // Stripe Product/Price mapping (platform-side)
64 | stripeProductId String? @unique
65 | stripePriceId String? @unique
66 |
67 | transactions Transaction[]
68 | }
69 |
70 | model Transaction {
71 | id String @id @default(cuid())
72 | createdAt DateTime @default(now())
73 | updatedAt DateTime @updatedAt
74 | offerId String
75 | offer Offer @relation(fields: [offerId], references: [id])
76 |
77 | userId String
78 | user User @relation(fields: [userId], references: [id])
79 | messages Message[]
80 |
81 | stripePaymentIntentId String? @unique
82 | stripeCheckoutSessionId String? @unique
83 |
84 | @@unique([offerId, userId])
85 | }
86 |
87 | model Message {
88 | id String @id @default(cuid())
89 | createdAt DateTime @default(now())
90 | updatedAt DateTime @updatedAt
91 | content String
92 | userId String
93 | user User @relation(fields: [userId], references: [id])
94 | transactionId String
95 | transaction Transaction @relation(fields: [transactionId], references: [id])
96 | price Float?
97 | status Int? @default(0) // 0 = message, 10 = offre en attente, 20 = offre acceptée, 90 = offre refusée
98 | }
99 |
--------------------------------------------------------------------------------
/backend/src/app.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common';
2 | import { AuthController } from './auth/auth.controller';
3 | import { AuthModule } from './auth/auth.module';
4 | import { AwsS3Service } from './aws/aws-s3.service';
5 | import { PrismaService } from './prisma/prisma.service';
6 | import { RemixController } from './remix/remix.controller';
7 | import { RemixService } from './remix/remix.service';
8 | import { StripeController } from './stripe/stripe.controller';
9 | import { StripeService } from './stripe/stripe.service';
10 |
11 | @Module({
12 | imports: [AuthModule],
13 | controllers: [AuthController, StripeController, RemixController],
14 | providers: [PrismaService, RemixService, AwsS3Service, StripeService],
15 | })
16 | export class AppModule { }
17 |
--------------------------------------------------------------------------------
/backend/src/auth/auth.controller.ts:
--------------------------------------------------------------------------------
1 | import { Controller, Get, Next, Post, Query, Redirect, Req, Res, UseGuards } from '@nestjs/common';
2 | import { NextFunction, Response } from 'express';
3 | import { LocalAuthGuard } from './local-auth.guard';
4 |
5 | @Controller()
6 | export class AuthController {
7 | @UseGuards(LocalAuthGuard)
8 | @Get('/authenticate')
9 | @Redirect('/')
10 | login(
11 | @Query('redirectTo') redirectTo: string,
12 | ) {
13 | return {
14 | url: redirectTo
15 | }
16 | }
17 |
18 | @Post('auth/logout')
19 | async logout(
20 | @Req() request: Express.Request,
21 | @Res() response: Response,
22 | @Next() next: NextFunction,
23 | ) {
24 | // this will ensure that re-using the old session id
25 | // does not have a logged in user
26 | request.logOut(function (err) {
27 | if (err) {
28 | return next(err);
29 | }
30 | // Ensure the session is destroyed and the user is redirected.
31 | request.session.destroy(() => {
32 | response.clearCookie('connect.sid'); // The name of the cookie where express/connect stores its session_id
33 | response.redirect('/'); // Redirect to website after logout
34 | });
35 | });
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/backend/src/auth/auth.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common';
2 | import { PassportModule } from '@nestjs/passport';
3 | import { PrismaService } from '../prisma/prisma.service';
4 | import { AuthService } from './auth.service';
5 | import { CookieSerializer } from './cookie-serializer';
6 | import { LocalAuthGuard } from './local-auth.guard';
7 | import { LocalStrategy } from './local.strategy';
8 |
9 | @Module({
10 | imports: [
11 | PassportModule.register({
12 | defaultStrategy: 'local',
13 | property: 'user',
14 | session: true
15 | })
16 | ],
17 | controllers: [],
18 | providers: [LocalStrategy, LocalAuthGuard, CookieSerializer, PrismaService, AuthService],
19 | exports: [AuthService]
20 | })
21 | export class AuthModule { }
22 |
--------------------------------------------------------------------------------
/backend/src/auth/auth.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@nestjs/common';
2 | import { createId } from '@paralleldrive/cuid2';
3 | import { compare, hash } from 'bcryptjs';
4 | import { PrismaService } from '../prisma/prisma.service';
5 | const PASSWORD_SALT = 10;
6 |
7 | @Injectable()
8 | export class AuthService {
9 | constructor(private readonly prisma: PrismaService) { }
10 |
11 | public readonly checkIfUserExists = async ({
12 | email,
13 | password,
14 | withPassword,
15 | }: {
16 | email: string;
17 | withPassword: boolean;
18 | password: string;
19 | }) => {
20 | // Renvoie true si l'utilisateur n'existe pas
21 | // Renvoie false si l'utilisateur existe
22 | // 1. Vérifier que l'utilisateur existe sur l'email
23 | // 2. Si withPassword est activé, on vérifie que son mot de passe
24 | // est bien défini.
25 | const existingUser = await this.prisma.user.findUnique({
26 | where: {
27 | email,
28 | },
29 | select: {
30 | id: true,
31 | name: true,
32 | email: true,
33 | password: true,
34 | },
35 | });
36 | if (!existingUser) {
37 | return {
38 | message: "L'email est invalide",
39 | error: true,
40 | };
41 | }
42 |
43 | if (withPassword) {
44 | // Rajouter une logique de validation par mot de passez
45 | const isPasswordValid = await compare(password, existingUser.password);
46 |
47 | if (!isPasswordValid) {
48 | return {
49 | message: "Le mot de passe est invalide",
50 | error: true,
51 | };
52 | }
53 | }
54 | return {
55 | message: "Cet email est déjà utilisé.",
56 | error: false,
57 | };
58 | };
59 |
60 | public readonly createUser = async ({
61 | email,
62 | name,
63 | password,
64 | }: {
65 | email: string;
66 | name: string;
67 | password: string;
68 | }) => {
69 | const hashedPassword = await hash(password, PASSWORD_SALT);
70 | return await this.prisma.user.create({
71 | data: {
72 | email: email.toLowerCase(),
73 | password: hashedPassword,
74 | name,
75 | },
76 | select: {
77 | id: true,
78 | name: true,
79 | email: true,
80 | },
81 | });
82 | };
83 |
84 | public readonly authenticateUser = async ({
85 | email,
86 | }: {
87 | email: string;
88 | }) => {
89 | return await this.prisma.session.create({
90 | data: {
91 | user: {
92 | connect: {
93 | email
94 | },
95 | },
96 | sessionToken: createId()
97 | },
98 | select: {
99 | sessionToken: true
100 | }
101 | })
102 | };
103 | }
104 |
--------------------------------------------------------------------------------
/backend/src/auth/cookie-serializer.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/ban-types */
2 | import { Injectable } from '@nestjs/common';
3 | import { PassportSerializer } from '@nestjs/passport';
4 |
5 | @Injectable()
6 | export class CookieSerializer extends PassportSerializer {
7 | deserializeUser(payload: any, done: Function) {
8 | // console.log('deserializeUser', { payload });
9 | done(null, payload);
10 | }
11 | serializeUser(user: any, done: Function) {
12 | // console.log('serializeUser', { user });
13 | done(null, user);
14 | }
15 | }
--------------------------------------------------------------------------------
/backend/src/auth/exception.filter.ts:
--------------------------------------------------------------------------------
1 |
2 | import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/common';
3 | import { Request, Response } from 'express';
4 | import { RedirectException } from './redirected-error.exception';
5 |
6 | @Catch(HttpException)
7 | export class HttpExceptionFilter implements ExceptionFilter {
8 | catch(exception: HttpException, host: ArgumentsHost) {
9 | const ctx = host.switchToHttp();
10 | const response = ctx.getResponse();
11 | const request = ctx.getRequest();
12 | const status = exception.getStatus();
13 |
14 | if (exception instanceof RedirectException) {
15 | const { redirectUrl, message } = exception
16 | return response.redirect(302, `${redirectUrl}?error=${message}`)
17 | }
18 |
19 | response
20 | .status(status)
21 | .json({
22 | statusCode: status,
23 | timestamp: new Date().toISOString(),
24 | path: request.url,
25 | });
26 | }
27 | }
--------------------------------------------------------------------------------
/backend/src/auth/local-auth.guard.ts:
--------------------------------------------------------------------------------
1 |
2 | import {
3 | ExecutionContext,
4 | Injectable,
5 | UnauthorizedException,
6 | } from '@nestjs/common';
7 | import { AuthGuard } from '@nestjs/passport';
8 | import { Request } from 'express';
9 |
10 | @Injectable()
11 | export class LocalAuthGuard extends AuthGuard('local') {
12 | async canActivate(context: ExecutionContext): Promise {
13 | const request = context.switchToHttp().getRequest();
14 | request.body = {};
15 | request.body.email = 'email@example.com';
16 | request.body.password = '123';
17 |
18 | // Add your custom authentication logic here
19 | const canBeActivated = await super.canActivate(context) as boolean;
20 | // for example, call super.logIn(request) to establish a session.
21 | await super.logIn(request);
22 | return canBeActivated
23 | }
24 |
25 | // @ts-expect-error Fix that later
26 | handleRequest(err, user,) {
27 | // console.log({ user, err, info })
28 | // You can throw an exception based on either "info" or "err" arguments
29 | if (err || !user) {
30 | throw err || new UnauthorizedException("Vous n'avez pas le droit d'accéder à cette page.");
31 | }
32 | return user;
33 | }
34 | }
--------------------------------------------------------------------------------
/backend/src/auth/local.strategy.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@nestjs/common';
2 | import { PassportStrategy } from '@nestjs/passport';
3 | import { Request } from 'express';
4 | import { IStrategyOptionsWithRequest, Strategy } from 'passport-local';
5 | import { PrismaService } from '../prisma/prisma.service';
6 | import { RedirectException } from './redirected-error.exception';
7 |
8 | @Injectable()
9 | export class LocalStrategy extends PassportStrategy(Strategy, 'local') {
10 | constructor(
11 | private readonly prisma: PrismaService,
12 | ) {
13 | super({
14 | passReqToCallback: true,
15 | usernameField: 'email',
16 | } as IStrategyOptionsWithRequest);
17 | }
18 |
19 | async validate(request: Request) {
20 | const token = request.query.token as string
21 |
22 | const session = await this.prisma.session.findUnique({
23 | where: {
24 | sessionToken: token,
25 | },
26 | select: {
27 | user: {
28 | select: {
29 | email: true,
30 | id: true,
31 | }
32 | }
33 | },
34 | });
35 | if (!session) {
36 | throw new RedirectException("Votre session a expiré. Veuillez vous reconnecter.", '/login')
37 | }
38 |
39 | const { user } = session
40 |
41 | await this.prisma.session.delete({
42 | where: {
43 | sessionToken: token
44 | }
45 | })
46 |
47 | return { email: user.email, id: user.id };
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/backend/src/auth/redirected-error.exception.ts:
--------------------------------------------------------------------------------
1 | import { HttpException, HttpStatus } from '@nestjs/common';
2 |
3 | export class RedirectException extends HttpException {
4 | constructor(
5 | public readonly message: string,
6 | public readonly redirectUrl: string,
7 | ) {
8 | super(message, HttpStatus.PERMANENT_REDIRECT);
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/backend/src/aws/aws-s3.service.ts:
--------------------------------------------------------------------------------
1 | import {
2 | DeleteObjectCommand,
3 | GetObjectCommand,
4 | PutObjectCommand,
5 | S3Client,
6 | } from '@aws-sdk/client-s3';
7 | import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
8 | import { createId } from '@paralleldrive/cuid2';
9 | import 'dotenv/config';
10 | import { z } from 'zod';
11 |
12 |
13 | export const fileSchema = z.object({
14 | size: z.number(),
15 | buffer: z.instanceof(Buffer),
16 | originalname: z.string(),
17 | mimetype: z.string(),
18 | });
19 |
20 | const AWS_ACCESS_KEY = process.env.AWS_ACCESS_KEY;
21 | const AWS_SECRET = process.env.AWS_SECRET;
22 | const AWS_REGION = process.env.AWS_REGION;
23 |
24 | export class AwsS3Service {
25 | private readonly client: S3Client;
26 | constructor() {
27 | if (!AWS_ACCESS_KEY) {
28 | throw new Error('Invalid AWS_ACCESS_KEY');
29 | }
30 |
31 | if (!AWS_SECRET) {
32 | throw new Error('Invalid AWS_SECRET');
33 | }
34 | if (!AWS_REGION) {
35 | throw new Error('Invalid AWS_REGION');
36 | }
37 | const client = new S3Client({
38 | credentials: {
39 | accessKeyId: AWS_ACCESS_KEY,
40 | secretAccessKey: AWS_SECRET,
41 | },
42 | region: AWS_REGION,
43 | });
44 | this.client = client;
45 | }
46 |
47 | async uploadFile({ file }: { file: z.infer }) {
48 | const createdId = createId();
49 | const fileKey = createdId + file.originalname;
50 | const putObjectCommand = new PutObjectCommand({
51 | Bucket: process.env.AWS_BUCKET_NAME,
52 | Key: fileKey,
53 | ContentType: file.mimetype,
54 | Body: file.buffer,
55 | CacheControl: 'max-age=31536000',
56 | });
57 |
58 | const result = await this.client.send(putObjectCommand);
59 | if (result.$metadata.httpStatusCode !== 200) {
60 | console.error(result);
61 | }
62 | return { fileKey };
63 | }
64 |
65 | async deleteFile({ fileKey }: { fileKey: string }) {
66 | const deleteObjectCommand = new DeleteObjectCommand({
67 | Bucket: process.env.AWS_BUCKET_NAME,
68 | Key: fileKey,
69 | });
70 |
71 | const result = await this.client.send(deleteObjectCommand);
72 | if (result.$metadata.httpStatusCode !== 200) {
73 | // console.error(result);
74 | }
75 | }
76 |
77 | async getFileUrl({ fileKey }: { fileKey: string }) {
78 | const getObjectCommand = new GetObjectCommand({
79 | Bucket: process.env.AWS_BUCKET_NAME,
80 | Key: fileKey,
81 | });
82 |
83 | const result = await getSignedUrl(this.client, getObjectCommand);
84 | return result;
85 | }
86 | }
--------------------------------------------------------------------------------
/backend/src/main.ts:
--------------------------------------------------------------------------------
1 | import { NestFactory } from '@nestjs/core';
2 | import { NestExpressApplication } from '@nestjs/platform-express';
3 | import { getPublicDir, startDevServer } from '@virgile/frontend';
4 | import { AppModule } from './app.module';
5 |
6 | import { raw, urlencoded } from 'body-parser';
7 | import RedisStore from 'connect-redis';
8 | import session from 'express-session';
9 | import Redis from 'ioredis';
10 | import passport from 'passport';
11 | import { HttpExceptionFilter } from './auth/exception.filter';
12 |
13 | async function bootstrap() {
14 | const app = await NestFactory.create(AppModule, {
15 | bodyParser: false,
16 | });
17 |
18 | await startDevServer(app);
19 | app.use('/webhooks/stripe', raw({ type: 'application/json' }));
20 |
21 | // Initialize client
22 | const redisUrl = process.env.REDIS_URL || "redis://localhost:6379"
23 | const redisClient = new Redis(redisUrl, {
24 |
25 | }).on('error', console.error).on('connect', () => {
26 | console.log('Connected to Redis');
27 | });
28 |
29 | // Initialize store
30 | const redisStore = new RedisStore({
31 | client: redisClient,
32 | ttl: 86400 * 30
33 | })
34 |
35 | app.set('trust proxy', 1)
36 |
37 | app.use(
38 | session({
39 | store: redisStore,
40 | resave: false, // required: force lightweight session keep alive (touch)
41 | saveUninitialized: false, // recommended: only save session when data exists
42 | secret: process.env.SESSION_SECRET || '123',
43 | cookie: {
44 | maxAge: 1000 * 60 * 60 * 24 * 30, // 30 days
45 | sameSite: "lax",
46 | secure: process.env.NODE_ENV === 'production',
47 | }
48 | }),
49 | )
50 |
51 | app.useStaticAssets(getPublicDir(), {
52 | immutable: true,
53 | maxAge: '1y',
54 | index: false,
55 | });
56 |
57 | app.useGlobalFilters(new HttpExceptionFilter());
58 |
59 | app.use(passport.initialize())
60 | app.use(passport.session())
61 | app.use("/authenticate", urlencoded({ extended: true })) // Add this line
62 | app.use("/auth/logout", urlencoded({ extended: true })) // Add this line
63 |
64 | const selectedPort = process.env.PORT ?? 3000;
65 |
66 | console.log(`Running on port http://localhost:${selectedPort}`);
67 | await app.listen(selectedPort);
68 | }
69 | bootstrap();
70 |
--------------------------------------------------------------------------------
/backend/src/prisma/prisma.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from "@nestjs/common";
2 | import { PrismaClient } from "@prisma/client";
3 |
4 | @Injectable()
5 | export class PrismaService extends PrismaClient { }
--------------------------------------------------------------------------------
/backend/src/remix/remix.controller.ts:
--------------------------------------------------------------------------------
1 | import { All, Controller, Next, Req, Res } from '@nestjs/common';
2 | import { createRequestHandler } from '@remix-run/express';
3 | import { getServerBuild } from '@virgile/frontend';
4 | import { NextFunction, Request, Response } from 'express';
5 | import { RemixService } from './remix.service';
6 |
7 | @Controller()
8 | export class RemixController {
9 | constructor(private remixService: RemixService) { }
10 |
11 | @All('*')
12 | async handler(
13 | @Req() request: Request,
14 | @Res() response: Response,
15 | @Next() next: NextFunction,
16 | ) {
17 | //
18 | return createRequestHandler({
19 | build: await getServerBuild(),
20 | getLoadContext: () => ({
21 | user: request.user,
22 | remixService: this.remixService,
23 | }),
24 | })(request, response, next);
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/backend/src/remix/remix.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@nestjs/common';
2 | import { AuthService } from '../auth/auth.service';
3 | import { AwsS3Service } from '../aws/aws-s3.service';
4 | import { PrismaService } from '../prisma/prisma.service';
5 | import { StripeService } from '../stripe/stripe.service';
6 |
7 | // http://localhost:3000/webhooks/stripe
8 | @Injectable()
9 | export class RemixService {
10 | constructor(
11 | public readonly prisma: PrismaService,
12 | public readonly auth: AuthService,
13 | public readonly aws: AwsS3Service,
14 | public readonly stripe: StripeService,
15 | ) { }
16 | public readonly getUser = async ({ userId }: { userId: string }) => {
17 | return await this.prisma.user.findUnique({
18 | where: {
19 | id: userId,
20 | },
21 | select: {
22 | id: true,
23 | name: true,
24 | email: true,
25 | },
26 | });
27 | };
28 | }
29 |
--------------------------------------------------------------------------------
/backend/src/scripts/seed-offers.ts:
--------------------------------------------------------------------------------
1 | import { PrismaClient } from "@prisma/client";
2 | import bcrypt from "bcryptjs";
3 |
4 | const prisma = new PrismaClient();
5 |
6 | type SeedUser = {
7 | name: string;
8 | email: string;
9 | passwordHash: string;
10 | };
11 |
12 | const PRESET_USERS: Array<{ name: string; email: string }> = [
13 | { name: "Jean Dupont", email: "jean.dupont+seed@coupdepouce.test" },
14 | { name: "Marie Curie", email: "marie.curie+seed@coupdepouce.test" },
15 | { name: "Paul Martin", email: "paul.martin+seed@coupdepouce.test" },
16 | { name: "Sophie Bernard", email: "sophie.bernard+seed@coupdepouce.test" },
17 | { name: "Luc Moreau", email: "luc.moreau+seed@coupdepouce.test" },
18 | { name: "Emma Lefevre", email: "emma.lefevre+seed@coupdepouce.test" },
19 | { name: "Hugo Garcia", email: "hugo.garcia+seed@coupdepouce.test" },
20 | { name: "Chloé Lambert", email: "chloe.lambert+seed@coupdepouce.test" },
21 | { name: "Louis Fontaine", email: "louis.fontaine+seed@coupdepouce.test" },
22 | { name: "Camille Mercier", email: "camille.mercier+seed@coupdepouce.test" },
23 | { name: "Nathan Garnier", email: "nathan.garnier+seed@coupdepouce.test" },
24 | { name: "Léa Faure", email: "lea.faure+seed@coupdepouce.test" },
25 | { name: "Julien Caron", email: "julien.caron+seed@coupdepouce.test" },
26 | { name: "Manon Chevalier", email: "manon.chevalier+seed@coupdepouce.test" },
27 | { name: "Mathis Lucas", email: "mathis.lucas+seed@coupdepouce.test" },
28 | { name: "Inès Robin", email: "ines.robin+seed@coupdepouce.test" },
29 | { name: "Tom Robert", email: "tom.robert+seed@coupdepouce.test" },
30 | { name: "Clara Gaillard", email: "clara.gaillard+seed@coupdepouce.test" },
31 | { name: "Noah Guerin", email: "noah.guerin+seed@coupdepouce.test" },
32 | { name: "Sarah Masson", email: "sarah.masson+seed@coupdepouce.test" },
33 | ];
34 |
35 | const CATEGORIES = [
36 | "jardinage",
37 | "bricolage",
38 | "ménage",
39 | "cuisine",
40 | "garde d'enfants",
41 | "cours particuliers",
42 | "aide administrative",
43 | "informatique",
44 | "coaching sportif",
45 | "photographie",
46 | "musique",
47 | "déménagement",
48 | "livraison",
49 | "promenade d'animaux",
50 | "montage de meubles",
51 | "peinture",
52 | "plomberie légère",
53 | "électricité légère",
54 | "traduction",
55 | "rédaction",
56 | ] as const;
57 |
58 | const SERVICE_DESCRIPTIONS = [
59 | "Service entre particuliers, flexible et au juste prix.",
60 | "Intervention rapide, sérieuse et conviviale.",
61 | "Matériel fourni si besoin, échanges simples et sécurisés.",
62 | "Expérience confirmée, satisfaction garantie.",
63 | "Idéal pour un coup de pouce près de chez vous.",
64 | "Horaires souples, adaptation à vos contraintes.",
65 | "Conseils personnalisés et suivi.",
66 | "Tarif transparent, sans surprise.",
67 | ];
68 |
69 | const ADJECTIFS = [
70 | "rapide",
71 | "soigné",
72 | "fiable",
73 | "efficace",
74 | "sympa",
75 | "professionnel",
76 | "minutieux",
77 | "ponctuel",
78 | "attentionné",
79 | "rigoureux",
80 | ];
81 |
82 | function pick(arr: readonly T[]): T {
83 | return arr[Math.floor(Math.random() * arr.length)];
84 | }
85 |
86 | function buildTitle(): string {
87 | const cat = pick(CATEGORIES);
88 | const adj = pick(ADJECTIFS);
89 | // Ex: "Aide jardinage - service rapide et soigné"
90 | return `Aide ${cat} — service ${adj}`;
91 | }
92 |
93 | function buildDescription(): string {
94 | const d1 = pick(SERVICE_DESCRIPTIONS);
95 | const d2 = pick(SERVICE_DESCRIPTIONS);
96 | const d3 = pick(SERVICE_DESCRIPTIONS);
97 | return `${d1} ${d2} ${d3}`;
98 | }
99 |
100 | async function upsertUsers(): Promise {
101 | const salt = await bcrypt.genSalt(10);
102 | const passwordHash = await bcrypt.hash("motdepasse123", salt);
103 |
104 | const created = await Promise.all(
105 | PRESET_USERS.map(async (u) => {
106 | const user = await prisma.user.upsert({
107 | where: { email: u.email },
108 | update: { name: u.name, password: passwordHash },
109 | create: { name: u.name, email: u.email, password: passwordHash },
110 | select: { id: true },
111 | });
112 | return user.id;
113 | })
114 | );
115 |
116 | return created;
117 | }
118 |
119 | async function main(): Promise {
120 | const count = Number(process.env.SEED_OFFERS_COUNT ?? 1000);
121 | // Ensure we have a stable pool of users to attach offers to
122 | const userIds = await upsertUsers();
123 |
124 | // Generate 1000 distinct prices from 10.00€ upward, +0.01€ each
125 | // Guarantees uniqueness and realism for a P2P service marketplace
126 | const baseCents = 1000; // 10.00€
127 |
128 | const offerRows: Array<{
129 | title: string;
130 | description: string;
131 | price: number;
132 | active: boolean;
133 | recurring: boolean;
134 | userId: string;
135 | }> = [];
136 |
137 | for (let i = 0; i < count; i++) {
138 | const priceCents = baseCents + i; // unique price each time
139 | const price = Math.round(priceCents) / 100;
140 | const userId = userIds[i % userIds.length];
141 |
142 | offerRows.push({
143 | title: buildTitle(),
144 | description: buildDescription(),
145 | price,
146 | active: true,
147 | recurring: true,
148 | userId,
149 | });
150 | }
151 |
152 | // Clean up previous seed (optional): we keep idempotency by not deleting; prices are unique per run
153 | // Using createMany for performance
154 | const result = await prisma.offer.createMany({ data: offerRows });
155 |
156 | // eslint-disable-next-line no-console
157 | console.log(`✅ Création d'offres terminée: ${result.count} offres créées.`);
158 | }
159 |
160 | main()
161 | .catch((err) => {
162 | // eslint-disable-next-line no-console
163 | console.error("❌ Erreur lors du seed des offres:", err);
164 | process.exitCode = 1;
165 | })
166 | .finally(async () => {
167 | await prisma.$disconnect();
168 | });
169 |
170 |
171 |
--------------------------------------------------------------------------------
/backend/src/stripe/stripe.controller.ts:
--------------------------------------------------------------------------------
1 | import { Body, Controller, Headers, Post, Req, Res } from '@nestjs/common';
2 | import { Request, Response } from 'express';
3 | import { PrismaService } from '../prisma/prisma.service';
4 | import { StripeService } from './stripe.service';
5 |
6 | @Controller()
7 | export class StripeController {
8 | constructor(
9 | private readonly stripeService: StripeService,
10 | private readonly prisma: PrismaService,
11 | ) {}
12 |
13 | @Post('/webhooks/stripe')
14 | async handleWebhook(
15 | @Req() req: Request,
16 | @Res() res: Response,
17 | @Headers('stripe-signature') sig?: string,
18 | @Body() _body?: unknown, // parsed ignored; we use raw
19 | ) {
20 | try {
21 | const event = this.stripeService.constructEventFromPayload(sig, req.body as unknown as Buffer);
22 |
23 | if (event.type === 'account.updated') {
24 | const account = event.data.object ;
25 | const user = await this.prisma.user.findFirst({
26 | where: { stripeAccountId: account.id },
27 | select: { id: true },
28 | });
29 | if (user) {
30 | await this.prisma.user.update({
31 | where: { id: user.id },
32 | data: {
33 | chargesEnabled: Boolean(account.charges_enabled),
34 | payoutsEnabled: Boolean(account.payouts_enabled),
35 | detailsSubmitted: Boolean(account.details_submitted),
36 | },
37 | });
38 | }
39 | }
40 |
41 | if (event.type === 'checkout.session.completed' || event.type === 'payment_intent.succeeded') {
42 | await this.stripeService.handleWebhookEvent(event);
43 | }
44 |
45 | res.status(200).send({ received: true });
46 | } catch (err) {
47 | console.error('Stripe webhook error', err);
48 | res.status(400).send(`Webhook Error`);
49 | }
50 | }
51 | }
--------------------------------------------------------------------------------
/backend/start.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | set -ex
4 | cd backend
5 | npx prisma migrate deploy
6 | npm run start
--------------------------------------------------------------------------------
/backend/tsconfig.build.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
4 | }
5 |
--------------------------------------------------------------------------------
/backend/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@virgile/typescript-config/base.json",
3 | "compilerOptions": {
4 | "emitDecoratorMetadata": true,
5 | "experimentalDecorators": true,
6 | "strict": true,
7 | "outDir": "./dist",
8 | "rootDir": "./src",
9 | "noEmit": false,
10 | "lib": ["DOM", "ES2023"]
11 | },
12 | "include": ["src/**/*.ts", "src/exports.js"]
13 | }
14 |
--------------------------------------------------------------------------------
/biome.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://biomejs.dev/schemas/1.7.1/schema.json",
3 | "organizeImports": {
4 | "enabled": true
5 | },
6 | "files": {
7 | "ignore": ["**/*.json"]
8 | },
9 |
10 | "formatter": {
11 | "enabled": true,
12 | "indentStyle": "space",
13 | "indentWidth": 2
14 | },
15 | "javascript": {
16 | "parser": {
17 | "unsafeParameterDecoratorsEnabled": true
18 | }
19 | },
20 | "linter": {
21 | "enabled": true,
22 | "rules": {
23 | "recommended": true,
24 | "correctness": {
25 | "noUnusedImports": "error",
26 | "noUnusedVariables": "error",
27 | "useExhaustiveDependencies": "off",
28 | "noUnsafeOptionalChaining": "off"
29 | },
30 | "style": {
31 | "noUnusedTemplateLiteral": "off",
32 | "useTemplate": "off",
33 | "useSelfClosingElements": "off",
34 | "noParameterAssign": "off",
35 | "noUselessElse": "off",
36 | "useImportType": "off"
37 | },
38 | "security": {
39 | "noDangerouslySetInnerHtml": "off"
40 | },
41 | "a11y": {
42 | "useButtonType": "off",
43 | "noSvgWithoutTitle": "off",
44 | "noAutofocus": "off"
45 | },
46 | "complexity": {
47 | "noUselessFragments": "off",
48 | "useLiteralKeys": "off",
49 | "noForEach": "off",
50 | "useOptionalChain": "off"
51 | },
52 | "suspicious": {
53 | "noArrayIndexKey": "off",
54 | "noExplicitAny": "off",
55 | "noMisleadingCharacterClass": "off",
56 | "noAssignInExpressions": "off",
57 | "noShadowRestrictedNames": "off",
58 | "noGlobalIsNan": "off",
59 | "useDefaultSwitchClauseLast": "off"
60 | }
61 | }
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/cache/dump.rdb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Varkoff/remix-nestjs-monorepo/de83000dd44bfb70ea687c440765f6964c128444/cache/dump.rdb
--------------------------------------------------------------------------------
/docker-compose.dev.yml:
--------------------------------------------------------------------------------
1 | services:
2 | monorepo_dev:
3 | environment:
4 | - REDIS_URL=redis://redis_dev:6379
5 | - NODE_ENV=development
6 | - DATABASE_URL
7 |
8 | container_name: nestjs-remix-monorepo-dev
9 | build:
10 | context: .
11 | dockerfile: Dockerfile
12 | # image: varkoff/nestjs-remix-monorepo:dev
13 | restart: always
14 | ports:
15 | - 3000:3000
16 | redis_dev:
17 | image: redis:latest
18 | restart: always
19 | ports:
20 | - '6379:6379'
21 | command: ["redis-server"]
22 | # --save 20 1 --loglevel warning --requirepass eYVX7EwVmmxKPCDmwMtyKVge8oLd2t81
23 | volumes:
24 | - ./cache:/data
25 | # - /dev/volumes/nestjs-remix/dev/sessions/:/data
26 |
--------------------------------------------------------------------------------
/docker-compose.prod.yml:
--------------------------------------------------------------------------------
1 | services:
2 | monorepo_prod:
3 | environment:
4 | - REDIS_URL=redis://redis_prod:6379
5 | - NODE_ENV
6 | - DATABASE_URL
7 |
8 |
9 | container_name: nestjs-remix-monorepo-prod
10 | # build:
11 | # context: .
12 | # dockerfile: Dockerfile
13 | image: varkoff/nestjs-remix-monorepo:production
14 | restart: always
15 | ports:
16 | - 3000:3000
17 | redis_prod:
18 | image: redis:latest
19 | restart: always
20 | # ports:
21 | # - '6379:6379'
22 | command: ["redis-server"]
23 | # --save 20 1 --loglevel warning --requirepass eYVX7EwVmmxKPCDmwMtyKVge8oLd2t81
24 | volumes:
25 | - /dev/volumes/nestjs-remix/production/sessions/:/data
26 |
27 |
--------------------------------------------------------------------------------
/docker-compose.redis.yml:
--------------------------------------------------------------------------------
1 | services:
2 | redis_standalone:
3 | image: redis:latest
4 | restart: always
5 | ports:
6 | - '6379:6379'
7 | command: ["redis-server"]
8 | # --save 20 1 --loglevel warning --requirepass eYVX7EwVmmxKPCDmwMtyKVge8oLd2t81
9 | volumes:
10 | - ./cache:/data
11 | # - /dev/volumes/nestjs-remix/dev/sessions/:/data
12 |
--------------------------------------------------------------------------------
/frontend/.dockerignore:
--------------------------------------------------------------------------------
1 |
2 | /node_modules
3 | *.log
4 | .DS_Store
5 | .env
6 | /.cache
7 | /public/build
8 | /build
--------------------------------------------------------------------------------
/frontend/.eslintignore:
--------------------------------------------------------------------------------
1 | /node_modules
2 | /build
--------------------------------------------------------------------------------
/frontend/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | /** @type {import("eslint").Linter.Config} */
2 | module.exports = {
3 | root: true,
4 | extends: [
5 | '@virgile/eslint-config/base.js',
6 | '@remix-run/eslint-config',
7 | '@remix-run/eslint-config/node',
8 | // 'plugin:tailwindcss/recommended',
9 | 'plugin:remix-react-routes/recommended',
10 | ],
11 | settings: {
12 | // tailwindcss: {
13 | // config: 'tailwind.config.ts',
14 | // },
15 | 'import/resolver': {
16 | node: {
17 | extensions: ['.js', '.jsx', '.ts', '.tsx'],
18 | },
19 | },
20 | },
21 | rules: {
22 | "import/no-unresolved": "off",
23 | },
24 |
25 | overrides: [
26 | {
27 | extends: ['@remix-run/eslint-config/jest-testing-library'],
28 | files: ['app/**/__tests__/**/*', 'app/**/*.{spec,test}.*'],
29 | rules: {
30 | 'testing-library/no-await-sync-events': 'off',
31 | 'jest-dom/prefer-in-document': 'off',
32 | },
33 | // we're using vitest which has a very similar API to jest
34 | // (so the linting plugins work nicely), but it means we have to explicitly
35 | // set the jest version.
36 | settings: {
37 | jest: {
38 | version: 28,
39 | },
40 | },
41 |
42 | },
43 | ],
44 | };
45 |
--------------------------------------------------------------------------------
/frontend/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 |
3 | /.cache
4 | /build
5 | /public/build
6 | .env
7 |
--------------------------------------------------------------------------------
/frontend/README.md:
--------------------------------------------------------------------------------
1 | # Welcome to Remix!
2 |
3 | - [Remix Docs](https://remix.run/docs)
4 |
5 | ## Development
6 |
7 | From your terminal:
8 |
9 | ```sh
10 | npm run dev
11 | ```
12 |
13 | This starts your app in development mode, rebuilding assets on file changes.
14 |
15 | ## Deployment
16 |
17 | First, build your app for production:
18 |
19 | ```sh
20 | npm run build
21 | ```
22 |
23 | Then run the app in production mode:
24 |
25 | ```sh
26 | npm start
27 | ```
28 |
29 | Now you'll need to pick a host to deploy it to.
30 |
31 | ### DIY
32 |
33 | If you're familiar with deploying node applications, the built-in Remix app server is production-ready.
34 |
35 | Make sure to deploy the output of `remix build`
36 |
37 | - `build/`
38 | - `public/build/`
39 |
--------------------------------------------------------------------------------
/frontend/app/components/Footer.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from '@remix-run/react';
2 |
3 | export const Footer = () => {
4 | return (
5 |
28 | );
29 | };
--------------------------------------------------------------------------------
/frontend/app/components/forms.tsx:
--------------------------------------------------------------------------------
1 | import { useInputControl } from '@conform-to/react';
2 | import { useId } from 'react';
3 | import { Checkbox, type CheckboxProps } from './ui/checkbox';
4 | import { Input } from './ui/input';
5 | import { Label } from './ui/label';
6 | import { Textarea } from './ui/textarea';
7 |
8 | export function Field({
9 | labelProps,
10 | inputProps,
11 | errors,
12 | className,
13 | }: {
14 | labelProps: React.LabelHTMLAttributes
15 | inputProps: React.InputHTMLAttributes
16 | errors?: string[] | undefined;
17 | className?: string
18 | }) {
19 | const fallbackId = useId()
20 | const id = inputProps.id ?? fallbackId
21 | const errorId = errors?.length ? `${id}-error` : undefined
22 | return (
23 |
24 |
25 |
31 | {errorId ?
32 |
33 |
34 |
: null}
35 |
36 | )
37 | }
38 | export function TextareaField({
39 | labelProps,
40 | textareaProps,
41 | errors,
42 | className,
43 | }: {
44 | labelProps: React.LabelHTMLAttributes
45 | textareaProps: React.TextareaHTMLAttributes
46 | errors?: string[] | undefined;
47 | className?: string
48 | }) {
49 | const fallbackId = useId()
50 | const id = textareaProps.id ?? textareaProps.name ?? fallbackId
51 | const errorId = errors?.length ? `${id}-error` : undefined
52 | return (
53 |
54 |
55 |
61 |
62 | {errorId ? : null}
63 |
64 |
65 | )
66 | }
67 |
68 | export function CheckboxField({
69 | labelProps,
70 | buttonProps,
71 | errors,
72 | className,
73 | }: {
74 | labelProps: JSX.IntrinsicElements['label']
75 | buttonProps: CheckboxProps & {
76 | name: string
77 | form: string
78 | value?: string
79 | }
80 | errors?: string[] | undefined;
81 | className?: string
82 | }) {
83 | const { key, defaultChecked, ...checkboxProps } = buttonProps
84 | const fallbackId = useId()
85 | const checkedValue = buttonProps.value ?? 'on'
86 | const input = useInputControl({
87 | key,
88 | name: buttonProps.name,
89 | formId: buttonProps.form,
90 | initialValue: defaultChecked ? checkedValue : undefined,
91 | })
92 | const id = buttonProps.id ?? fallbackId
93 | const errorId = errors?.length ? `${id}-error` : undefined
94 |
95 | return (
96 |
97 |
98 | {
105 | input.change(state.valueOf() ? checkedValue : '')
106 | buttonProps.onCheckedChange?.(state)
107 | }}
108 | onFocus={event => {
109 | input.focus()
110 | buttonProps.onFocus?.(event)
111 | }}
112 | onBlur={event => {
113 | input.blur()
114 | buttonProps.onBlur?.(event)
115 | }}
116 | type="button"
117 | />
118 |
123 |
124 |
125 | {errorId ? : null}
126 |
127 |
128 | )
129 | }
130 |
131 | export function ErrorList({
132 | id,
133 | errors,
134 | }: {
135 | errors?: string[] | undefined;
136 | id?: string
137 | }) {
138 | const errorsToRender = errors?.filter(Boolean)
139 | if (!errorsToRender?.length) return null
140 | return (
141 |
142 | {errorsToRender.map(e => (
143 | -
144 | {e}
145 |
146 | ))}
147 |
148 | )
149 | }
--------------------------------------------------------------------------------
/frontend/app/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import { Slot } from "@radix-ui/react-slot"
2 | import { cva, type VariantProps } from "class-variance-authority"
3 | import * as React from "react"
4 | import { cn } from "~/lib/utils"
5 |
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
9 | {
10 | variants: {
11 | variant: {
12 | primary: "bg-bleu text-white hover:bg-bleu/90",
13 | secondary: "bg-bleuClair text-khmerCurry hover:bg-bleuClair/90",
14 | greenOutline: "bg-transparent text-vert hover:text-vert/80",
15 | redOutline: "bg-transparent text-khmerCurry hover:text-khmerCurry/80",
16 | blueOutline: "bg-transparent text-bleu hover:text-bleu/80",
17 | oauth: "bg-bleuClair text-darkIron hover:text-bleu",
18 | default: "bg-primary text-primary-foreground hover:bg-primary/90",
19 | destructive:
20 | "bg-destructive text-destructive-foreground hover:bg-destructive/90",
21 | outline:
22 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
23 | // secondary:
24 | // "bg-secondary text-secondary-foreground hover:bg-secondary/80",
25 | ghost: "hover:bg-accent hover:text-accent-foreground",
26 | link: "text-primary underline-offset-4 hover:underline",
27 | },
28 | size: {
29 | default: "h-10 px-4 py-2",
30 | sm: "h-9 rounded-md px-3",
31 | lg: "h-11 rounded-md px-8",
32 | icon: "h-10 w-10",
33 | },
34 | },
35 | defaultVariants: {
36 | variant: "default",
37 | size: "default",
38 | },
39 | }
40 | )
41 |
42 | export interface ButtonProps
43 | extends React.ButtonHTMLAttributes,
44 | VariantProps {
45 | asChild?: boolean
46 | }
47 |
48 | const Button = React.forwardRef(
49 | ({ className, variant, size, asChild = false, ...props }, ref) => {
50 | const Comp = asChild ? Slot : "button"
51 | return (
52 |
57 | )
58 | }
59 | )
60 | Button.displayName = "Button"
61 |
62 | export { Button, buttonVariants }
63 |
--------------------------------------------------------------------------------
/frontend/app/components/ui/checkbox.tsx:
--------------------------------------------------------------------------------
1 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
2 | import { Check } from "lucide-react"
3 | import * as React from "react"
4 |
5 | import { cn } from "~/lib/utils"
6 |
7 | export type CheckboxProps = Omit<
8 | React.ComponentPropsWithoutRef,
9 | 'type'
10 | > & {
11 | type?: string
12 | }
13 |
14 |
15 | const Checkbox = React.forwardRef<
16 | React.ElementRef,
17 | React.ComponentPropsWithoutRef
18 | >(({ className, ...props }, ref) => (
19 |
27 |
30 |
31 |
32 |
33 | ))
34 | Checkbox.displayName = CheckboxPrimitive.Root.displayName
35 |
36 | export { Checkbox }
37 |
--------------------------------------------------------------------------------
/frontend/app/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "~/lib/utils"
4 |
5 | export interface InputProps
6 | extends React.InputHTMLAttributes {}
7 |
8 | const Input = React.forwardRef(
9 | ({ className, type, ...props }, ref) => {
10 | return (
11 |
20 | )
21 | }
22 | )
23 | Input.displayName = "Input"
24 |
25 | export { Input }
26 |
--------------------------------------------------------------------------------
/frontend/app/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | import * as LabelPrimitive from "@radix-ui/react-label"
2 | import { cva, type VariantProps } from "class-variance-authority"
3 | import * as React from "react"
4 | import { cn } from "~/lib/utils"
5 |
6 |
7 | const labelVariants = cva(
8 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
9 | )
10 |
11 | const Label = React.forwardRef<
12 | React.ElementRef,
13 | React.ComponentPropsWithoutRef &
14 | VariantProps
15 | >(({ className, ...props }, ref) => (
16 |
21 | ))
22 | Label.displayName = LabelPrimitive.Root.displayName
23 |
24 | export { Label }
25 |
--------------------------------------------------------------------------------
/frontend/app/components/ui/pagination.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"
3 |
4 | import { cn } from "~/lib/utils"
5 | import { ButtonProps, buttonVariants } from "~/components/ui/button"
6 |
7 | const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
8 |
14 | )
15 | Pagination.displayName = "Pagination"
16 |
17 | const PaginationContent = React.forwardRef<
18 | HTMLUListElement,
19 | React.ComponentProps<"ul">
20 | >(({ className, ...props }, ref) => (
21 |
26 | ))
27 | PaginationContent.displayName = "PaginationContent"
28 |
29 | const PaginationItem = React.forwardRef<
30 | HTMLLIElement,
31 | React.ComponentProps<"li">
32 | >(({ className, ...props }, ref) => (
33 |
34 | ))
35 | PaginationItem.displayName = "PaginationItem"
36 |
37 | type PaginationLinkProps = {
38 | isActive?: boolean
39 | } & Pick &
40 | React.ComponentProps<"a">
41 |
42 | const PaginationLink = ({
43 | className,
44 | isActive,
45 | size = "icon",
46 | ...props
47 | }: PaginationLinkProps) => (
48 |
59 | )
60 | PaginationLink.displayName = "PaginationLink"
61 |
62 | const PaginationPrevious = ({
63 | className,
64 | ...props
65 | }: React.ComponentProps) => (
66 |
72 |
73 | Previous
74 |
75 | )
76 | PaginationPrevious.displayName = "PaginationPrevious"
77 |
78 | const PaginationNext = ({
79 | className,
80 | ...props
81 | }: React.ComponentProps) => (
82 |
88 | Next
89 |
90 |
91 | )
92 | PaginationNext.displayName = "PaginationNext"
93 |
94 | const PaginationEllipsis = ({
95 | className,
96 | ...props
97 | }: React.ComponentProps<"span">) => (
98 |
103 |
104 | More pages
105 |
106 | )
107 | PaginationEllipsis.displayName = "PaginationEllipsis"
108 |
109 | export {
110 | Pagination,
111 | PaginationContent,
112 | PaginationEllipsis,
113 | PaginationItem,
114 | PaginationLink,
115 | PaginationNext,
116 | PaginationPrevious,
117 | }
118 |
--------------------------------------------------------------------------------
/frontend/app/components/ui/select.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as SelectPrimitive from "@radix-ui/react-select"
3 | import { Check, ChevronDown, ChevronUp } from "lucide-react"
4 |
5 | import { cn } from "~/lib/utils"
6 |
7 | const Select = SelectPrimitive.Root
8 |
9 | const SelectGroup = SelectPrimitive.Group
10 |
11 | const SelectValue = SelectPrimitive.Value
12 |
13 | const SelectTrigger = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef
16 | >(({ className, children, ...props }, ref) => (
17 | span]:line-clamp-1",
21 | className
22 | )}
23 | {...props}
24 | >
25 | {children}
26 |
27 |
28 |
29 |
30 | ))
31 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
32 |
33 | const SelectScrollUpButton = React.forwardRef<
34 | React.ElementRef,
35 | React.ComponentPropsWithoutRef
36 | >(({ className, ...props }, ref) => (
37 |
45 |
46 |
47 | ))
48 | SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
49 |
50 | const SelectScrollDownButton = React.forwardRef<
51 | React.ElementRef,
52 | React.ComponentPropsWithoutRef
53 | >(({ className, ...props }, ref) => (
54 |
62 |
63 |
64 | ))
65 | SelectScrollDownButton.displayName =
66 | SelectPrimitive.ScrollDownButton.displayName
67 |
68 | const SelectContent = React.forwardRef<
69 | React.ElementRef,
70 | React.ComponentPropsWithoutRef
71 | >(({ className, children, position = "popper", ...props }, ref) => (
72 |
73 |
84 |
85 |
92 | {children}
93 |
94 |
95 |
96 |
97 | ))
98 | SelectContent.displayName = SelectPrimitive.Content.displayName
99 |
100 | const SelectLabel = React.forwardRef<
101 | React.ElementRef,
102 | React.ComponentPropsWithoutRef
103 | >(({ className, ...props }, ref) => (
104 |
109 | ))
110 | SelectLabel.displayName = SelectPrimitive.Label.displayName
111 |
112 | const SelectItem = React.forwardRef<
113 | React.ElementRef,
114 | React.ComponentPropsWithoutRef
115 | >(({ className, children, ...props }, ref) => (
116 |
124 |
125 |
126 |
127 |
128 |
129 |
130 | {children}
131 |
132 | ))
133 | SelectItem.displayName = SelectPrimitive.Item.displayName
134 |
135 | const SelectSeparator = React.forwardRef<
136 | React.ElementRef,
137 | React.ComponentPropsWithoutRef
138 | >(({ className, ...props }, ref) => (
139 |
144 | ))
145 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName
146 |
147 | export {
148 | Select,
149 | SelectGroup,
150 | SelectValue,
151 | SelectTrigger,
152 | SelectContent,
153 | SelectLabel,
154 | SelectItem,
155 | SelectSeparator,
156 | SelectScrollUpButton,
157 | SelectScrollDownButton,
158 | }
159 |
--------------------------------------------------------------------------------
/frontend/app/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "~/lib/utils"
4 |
5 | export interface TextareaProps
6 | extends React.TextareaHTMLAttributes {}
7 |
8 | const Textarea = React.forwardRef(
9 | ({ className, ...props }, ref) => {
10 | return (
11 |
19 | )
20 | }
21 | )
22 | Textarea.displayName = "Textarea"
23 |
24 | export { Textarea }
25 |
--------------------------------------------------------------------------------
/frontend/app/entry.client.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * By default, Remix will handle hydrating your app on the client for you.
3 | * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
4 | * For more information, see https://remix.run/file-conventions/entry.client
5 | */
6 |
7 | import { RemixBrowser } from '@remix-run/react';
8 | import { startTransition, StrictMode } from 'react';
9 | import { hydrateRoot } from 'react-dom/client';
10 |
11 | startTransition(() => {
12 | hydrateRoot(
13 | document,
14 |
15 |
16 |
17 | );
18 | });
19 |
--------------------------------------------------------------------------------
/frontend/app/entry.server.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * By default, Remix will handle generating the HTTP Response for you.
3 | * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
4 | * For more information, see https://remix.run/file-conventions/entry.server
5 | */
6 |
7 | import { PassThrough } from 'node:stream';
8 |
9 | import { type AppLoadContext, type EntryContext , createReadableStreamFromReadable } from '@remix-run/node';
10 | import { RemixServer } from '@remix-run/react';
11 | import { isbot } from 'isbot';
12 | import { renderToPipeableStream } from 'react-dom/server';
13 |
14 | const ABORT_DELAY = 5_000;
15 |
16 | export default function handleRequest(
17 | request: Request,
18 | responseStatusCode: number,
19 | responseHeaders: Headers,
20 | remixContext: EntryContext,
21 | // This is ignored so we can keep it in the template for visibility. Feel
22 | // free to delete this parameter in your app if you're not using it!
23 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
24 | loadContext: AppLoadContext
25 | ) {
26 | return isbot(request.headers.get('user-agent') || '')
27 | ? handleBotRequest(
28 | request,
29 | responseStatusCode,
30 | responseHeaders,
31 | remixContext
32 | )
33 | : handleBrowserRequest(
34 | request,
35 | responseStatusCode,
36 | responseHeaders,
37 | remixContext
38 | );
39 | }
40 |
41 | function handleBotRequest(
42 | request: Request,
43 | responseStatusCode: number,
44 | responseHeaders: Headers,
45 | remixContext: EntryContext
46 | ) {
47 | return new Promise((resolve, reject) => {
48 | let shellRendered = false;
49 | const { pipe, abort } = renderToPipeableStream(
50 | ,
55 | {
56 | onAllReady() {
57 | shellRendered = true;
58 | const body = new PassThrough();
59 | const stream = createReadableStreamFromReadable(body);
60 |
61 | responseHeaders.set('Content-Type', 'text/html');
62 |
63 | resolve(
64 | new Response(stream, {
65 | headers: responseHeaders,
66 | status: responseStatusCode,
67 | })
68 | );
69 |
70 | pipe(body);
71 | },
72 | onShellError(error: unknown) {
73 | reject(error);
74 | },
75 | onError(error: unknown) {
76 | responseStatusCode = 500;
77 | // Log streaming rendering errors from inside the shell. Don't log
78 | // errors encountered during initial shell rendering since they'll
79 | // reject and get logged in handleDocumentRequest.
80 | if (shellRendered) {
81 | console.error(error);
82 | }
83 | },
84 | }
85 | );
86 |
87 | setTimeout(abort, ABORT_DELAY);
88 | });
89 | }
90 |
91 | function handleBrowserRequest(
92 | request: Request,
93 | responseStatusCode: number,
94 | responseHeaders: Headers,
95 | remixContext: EntryContext
96 | ) {
97 | return new Promise((resolve, reject) => {
98 | let shellRendered = false;
99 | const { pipe, abort } = renderToPipeableStream(
100 | ,
105 | {
106 | onShellReady() {
107 | shellRendered = true;
108 | const body = new PassThrough();
109 | const stream = createReadableStreamFromReadable(body);
110 |
111 | responseHeaders.set('Content-Type', 'text/html');
112 |
113 | resolve(
114 | new Response(stream, {
115 | headers: responseHeaders,
116 | status: responseStatusCode,
117 | })
118 | );
119 |
120 | pipe(body);
121 | },
122 | onShellError(error: unknown) {
123 | reject(error);
124 | },
125 | onError(error: unknown) {
126 | responseStatusCode = 500;
127 | // Log streaming rendering errors from inside the shell. Don't log
128 | // errors encountered during initial shell rendering since they'll
129 | // reject and get logged in handleDocumentRequest.
130 | if (shellRendered) {
131 | console.error(error);
132 | }
133 | },
134 | }
135 | );
136 |
137 | setTimeout(abort, ABORT_DELAY);
138 | });
139 | }
140 |
--------------------------------------------------------------------------------
/frontend/app/global.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | :root {
7 | --background: 0 0% 100%;
8 | --foreground: 222.2 84% 4.9%;
9 |
10 | --card: 0 0% 100%;
11 | --card-foreground: 222.2 84% 4.9%;
12 |
13 | --popover: 0 0% 100%;
14 | --popover-foreground: 222.2 84% 4.9%;
15 |
16 | --primary: 222.2 47.4% 11.2%;
17 | --primary-foreground: 210 40% 98%;
18 |
19 | --secondary: 210 40% 96.1%;
20 | --secondary-foreground: 222.2 47.4% 11.2%;
21 |
22 | --muted: 210 40% 96.1%;
23 | --muted-foreground: 215.4 16.3% 46.9%;
24 |
25 | --accent: 210 40% 96.1%;
26 | --accent-foreground: 222.2 47.4% 11.2%;
27 |
28 | --destructive: 0 84.2% 60.2%;
29 | --destructive-foreground: 210 40% 98%;
30 |
31 | --border: 214.3 31.8% 91.4%;
32 | --input: 214.3 31.8% 91.4%;
33 | --ring: 222.2 84% 4.9%;
34 |
35 | --radius: 0.5rem;
36 | }
37 |
38 | .dark {
39 | --background: 222.2 84% 4.9%;
40 | --foreground: 210 40% 98%;
41 |
42 | --card: 222.2 84% 4.9%;
43 | --card-foreground: 210 40% 98%;
44 |
45 | --popover: 222.2 84% 4.9%;
46 | --popover-foreground: 210 40% 98%;
47 |
48 | --primary: 210 40% 98%;
49 | --primary-foreground: 222.2 47.4% 11.2%;
50 |
51 | --secondary: 217.2 32.6% 17.5%;
52 | --secondary-foreground: 210 40% 98%;
53 |
54 | --muted: 217.2 32.6% 17.5%;
55 | --muted-foreground: 215 20.2% 65.1%;
56 |
57 | --accent: 217.2 32.6% 17.5%;
58 | --accent-foreground: 210 40% 98%;
59 |
60 | --destructive: 0 62.8% 30.6%;
61 | --destructive-foreground: 210 40% 98%;
62 |
63 | --border: 217.2 32.6% 17.5%;
64 | --input: 217.2 32.6% 17.5%;
65 | --ring: 212.7 26.8% 83.9%;
66 | }
67 | }
68 |
69 | @layer base {
70 | * {
71 | @apply border-border;
72 | }
73 | body {
74 | @apply bg-background text-foreground;
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/frontend/app/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
8 | export const formatDate = ({ date }: { date: Date | string }) => {
9 | return new Date(date).toLocaleDateString()
10 | }
11 |
12 | export const formatPrice = ({ price }: { price: number }) => {
13 | return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(price)
14 | }
15 |
16 | export type PaginationInput = {
17 | page?: number | string | null
18 | perPage?: number | string | null
19 | maxPerPage?: number
20 | }
21 |
22 | export type PaginationResult = {
23 | skip: number
24 | take: number
25 | page: number
26 | perPage: number
27 | }
28 |
29 | export const getPagination = ({ page, perPage, maxPerPage = 50 }: PaginationInput): PaginationResult => {
30 | const parsedPage = Math.max(Number(page ?? 1) || 1, 1)
31 | const parsedPerPageRaw = Number(perPage ?? 10) || 10
32 | const parsedPerPage = Math.min(Math.max(parsedPerPageRaw, 1), maxPerPage)
33 | const skip = (parsedPage - 1) * parsedPerPage
34 | const take = parsedPerPage
35 | return { skip, take, page: parsedPage, perPage: parsedPerPage }
36 | }
--------------------------------------------------------------------------------
/frontend/app/root.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | json,
3 | type LinksFunction,
4 | type LoaderFunctionArgs,
5 | } from "@remix-run/node";
6 | import { NuqsAdapter } from "nuqs/adapters/remix";
7 |
8 | import {
9 | Links,
10 | Meta,
11 | Outlet,
12 | Scripts,
13 | ScrollRestoration,
14 | useRouteLoaderData,
15 | } from "@remix-run/react";
16 | import { type RemixService } from "@virgile/backend";
17 | import { Footer } from "./components/Footer";
18 | import { Navbar } from "./components/Navbar";
19 | import stylesheet from "./global.css?url";
20 | import logo from "./routes/_assets/logo-coup-de-pouce-dark.png";
21 | import {
22 | getNotificationStats,
23 | getNotifications,
24 | getOptionalUser,
25 | } from "./server/auth.server";
26 | import { getConnectStatus } from "./server/stripe.server";
27 |
28 | export const links: LinksFunction = () => [
29 | { rel: "stylesheet", href: stylesheet },
30 | ];
31 |
32 | export const loader = async ({ request, context }: LoaderFunctionArgs) => {
33 | const user = await getOptionalUser({ context });
34 | let notificationStats: Awaited<
35 | ReturnType
36 | > | null = null;
37 | let notifications: Awaited> | null = null;
38 | let connectStatus: Awaited> | null = null;
39 |
40 | if (user) {
41 | notificationStats = await getNotificationStats({
42 | context,
43 | userId: user.id,
44 | });
45 | notifications = await getNotifications({ context, userId: user.id });
46 | connectStatus = await getConnectStatus({ context, userId: user.id });
47 | }
48 |
49 | return json({
50 | user,
51 | notificationStats,
52 | notifications,
53 | connectStatus,
54 | });
55 | };
56 |
57 | export const useOptionalUser = () => {
58 | const data = useRouteLoaderData("root");
59 | if (!data) {
60 | return null;
61 | // throw new Error('Root Loader did not return anything')
62 | }
63 | return data.user;
64 | };
65 |
66 | export const useNotificationStats = () => {
67 | const data = useRouteLoaderData("root");
68 | if (!data) {
69 | return null;
70 | }
71 | return data.notificationStats;
72 | };
73 |
74 | export const useNotifications = () => {
75 | const data = useRouteLoaderData("root");
76 | if (!data) {
77 | return null;
78 | }
79 | return data.notifications;
80 | };
81 |
82 | export const useConnectStatus = () => {
83 | const data = useRouteLoaderData("root");
84 | if (!data) {
85 | return null;
86 | }
87 | return data.connectStatus;
88 | };
89 |
90 | export const useUser = () => {
91 | const user = useOptionalUser();
92 | if (!user) {
93 | // return null;
94 | throw new Error("L'utilisateur n'est pas connecté");
95 | }
96 | return user;
97 | };
98 |
99 | declare module "@remix-run/node" {
100 | interface AppLoadContext {
101 | remixService: RemixService;
102 | user: unknown;
103 | }
104 | }
105 |
106 | export function Layout({ children }: { children: React.ReactNode }) {
107 | return (
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 | {children}
118 |
119 |
120 |
121 |
122 |
123 | );
124 | }
125 |
126 | export default function App() {
127 | return (
128 |
129 | ;
130 |
131 | );
132 | }
133 |
--------------------------------------------------------------------------------
/frontend/app/routes/_assets/logo-coup-de-pouce-dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Varkoff/remix-nestjs-monorepo/de83000dd44bfb70ea687c440765f6964c128444/frontend/app/routes/_assets/logo-coup-de-pouce-dark.png
--------------------------------------------------------------------------------
/frontend/app/routes/_index.tsx:
--------------------------------------------------------------------------------
1 | import { json, type LoaderFunctionArgs, type SerializeFrom } from "@remix-run/node";
2 | import { Link, useLoaderData, useNavigation } from "@remix-run/react";
3 | import { ChevronLeft, ChevronRight, Loader2 } from "lucide-react";
4 | import { createSerializer, parseAsInteger, parseAsString, useQueryStates } from "nuqs";
5 | import { createLoader } from "nuqs/server";
6 | import { useCallback, useMemo } from "react";
7 | import { Input } from "~/components/ui/input";
8 | import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink } from "~/components/ui/pagination";
9 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
10 | import { formatDate, formatPrice } from "~/lib/utils";
11 | import { useOptionalUser } from "~/root";
12 | import { getOptionalUser } from "~/server/auth.server";
13 | import { getOffers } from "~/server/offers.server";
14 |
15 | const filtersParams = {
16 | q: parseAsString.withDefault("").withOptions({
17 | limitUrlUpdates: {
18 | method: 'throttle',
19 | timeMs: 400
20 | }
21 | }),
22 | page: parseAsInteger.withDefault(1),
23 | perPage: parseAsInteger.withDefault(10),
24 | } as const
25 |
26 |
27 | export const filtersLoader = createLoader(filtersParams)
28 |
29 |
30 | export const loader = async ({ context, request }: LoaderFunctionArgs) => {
31 | const user = await getOptionalUser({ context });
32 | const filters = await filtersLoader(request)
33 |
34 | const offers = await getOffers({
35 | context,
36 | userId: user?.id,
37 | filters
38 | });
39 | return json({ offers, });
40 | };
41 |
42 |
43 | export default function Index() {
44 | const { offers } = useLoaderData();
45 | const { page, pageCount, hasPreviousPage, hasNextPage, perPage, total } = offers.pageInfo;
46 |
47 | const navigation = useNavigation()
48 | const isLoading = navigation.state === 'loading'
49 |
50 | const [filters, setFilters] = useQueryStates(filtersParams, {
51 | shallow: false
52 | })
53 |
54 | const perPageOptions = useMemo(() => [10, 20, 30, 50], []);
55 | const handlePerPageChange = useCallback(
56 | (value: string) => {
57 | setFilters({ page: 1, perPage: Number(value) })
58 | },
59 | [filters]
60 | );
61 |
62 |
63 | const makeHref = (p: number) => {
64 | const serializer = createSerializer(filtersParams)
65 | return serializer("/", { ...filters, page: p })
66 | }
67 |
68 |
69 | const pagesToShow = (() => {
70 | const pages: Array = [];
71 | const add = (p: number) => {
72 | if (p >= 1 && p <= pageCount && !pages.includes(p)) pages.push(p);
73 | };
74 | add(1);
75 | add(pageCount);
76 | for (let p = page - 2; p <= page + 2; p++) add(p);
77 | const sorted = pages
78 | .filter((v): v is number => typeof v === "number")
79 | .sort((a, b) => a - b);
80 | const withEllipsis: Array = [];
81 | for (let i = 0; i < sorted.length; i++) {
82 | const current = sorted[i];
83 | const prev = sorted[i - 1];
84 | if (i > 0 && prev !== undefined && current - prev > 1) {
85 | withEllipsis.push("ellipsis");
86 | }
87 | withEllipsis.push(current);
88 | }
89 | return withEllipsis;
90 | })();
91 | return (
92 |
93 |
94 | {/* Header Section */}
95 |
96 |
Découvrez nos services
97 |
98 | Explorez les dernières annonces et trouvez le service qui vous convient
99 |
100 |
101 |
102 | {/* Search Bar */}
103 |
104 | setFilters({ q: e.target.value })}
111 | />
112 | Total : {total}
113 | {isLoading ? (
114 |
115 | ) : null}
116 |
117 |
118 | {/* Services Grid */}
119 | {offers.items.length === 0 ? (
120 |
121 |
Aucun service disponible
122 |
123 | Revenez plus tard pour découvrir de nouveaux services
124 |
125 |
126 | ) : (
127 |
128 | {offers.items.map((offer) => (
129 |
130 | ))}
131 |
132 | )}
133 |
134 | {/* Pagination */}
135 |
136 |
137 |
138 | Résultats par page
139 |
151 |
152 |
Page {page} / {pageCount}
153 |
Total : {total}
154 |
155 |
156 |
157 |
158 |
159 |
165 |
166 | Précédent
167 |
168 |
169 | {pagesToShow.map((p, idx) => (
170 |
171 | {p === "ellipsis" ? (
172 |
173 | ) : (
174 |
175 | {p}
176 |
177 | )}
178 |
179 | ))}
180 |
181 |
187 | Suivant
188 |
189 |
190 |
191 |
192 |
193 | {isLoading ? (
194 |
195 | ) : null}
196 |
197 |
198 |
199 |
200 | );
201 | }
202 |
203 | const ServiceCard = ({ offer }: { offer: SerializeFrom["offers"]["items"][number] }) => {
204 | const { updatedAt, description, imageUrl, price, title, userId, hasActiveTransaction } = offer;
205 | const user = useOptionalUser()
206 | const isOwner = user?.id === userId;
207 |
208 | return (
209 |
213 | {/* Image Section */}
214 |
215 |

220 | {isOwner && (
221 |
222 | Votre offre
223 |
224 | )}
225 | {!isOwner && hasActiveTransaction && (
226 |
227 | Conversation active
228 |
229 | )}
230 | {/* Price Badge */}
231 |
232 |
233 |
234 | {formatPrice({ price: price })}
235 |
236 |
237 |
238 |
239 |
240 | {/* Content Section */}
241 |
242 |
243 | {title}
244 |
245 |
246 |
247 | {description}
248 |
249 |
250 | {/* Footer */}
251 |
252 |
253 | {formatDate({ date: updatedAt })}
254 |
255 |
256 |
257 |
258 | );
259 | };
260 |
261 | // Prix
262 | // Libéllé
263 | // Lieu
264 | // Heure de publication
265 |
--------------------------------------------------------------------------------
/frontend/app/routes/_public+/_layout.tsx:
--------------------------------------------------------------------------------
1 | import { Link, Outlet, useFetcher, useLocation, useNavigation } from '@remix-run/react';
2 | import { Loader2 } from 'lucide-react';
3 | import { Button } from "~/components/ui/button";
4 | import { useConnectStatus, useOptionalUser } from "~/root";
5 |
6 | export default function Layout() {
7 | const user = useOptionalUser();
8 | const connectStatus = useConnectStatus();
9 | const location = useLocation();
10 | const navigation = useNavigation();
11 | const isRouteLoading = navigation.state === 'loading';
12 | const fetcher = useFetcher();
13 | const isRefreshing = fetcher.state !== 'idle';
14 |
15 | const requirementLabel = (key: string) => {
16 | if (key.includes('individual.verification.document')) return "Document d'identité";
17 | if (key.includes('individual.verification.additional_document')) return "Document d'identité supplémentaire";
18 | if (key.includes('company.verification.document')) return "Document de l'entreprise";
19 | if (key.includes('external_account')) return "Compte bancaire";
20 | if (key.includes('business_profile')) return "Profil d'activité";
21 | if (key.includes('tos_acceptance')) return "Acceptation des CGU";
22 | if (key.includes('representative')) return "Informations du représentant";
23 | if (key.includes('owners')) return "Informations sur les propriétaires";
24 | if (key.includes('directors')) return "Informations sur les directeurs";
25 | if (key.includes('person.verification')) return "Vérification d'identité";
26 | return key;
27 | };
28 |
29 | const currentlyDue = connectStatus?.requirements?.currentlyDue ?? [];
30 | const pastDue = connectStatus?.requirements?.pastDue ?? [];
31 | const dueCount = (currentlyDue?.length ?? 0) + (pastDue?.length ?? 0);
32 |
33 | const needsStripeAttention = Boolean(
34 | user &&
35 | connectStatus?.stripeAccountId &&
36 | (
37 | !connectStatus.detailsSubmitted ||
38 | !connectStatus.chargesEnabled ||
39 | !connectStatus.payoutsEnabled ||
40 | dueCount > 0 ||
41 | Boolean(connectStatus.requirements?.disabledReason)
42 | )
43 | );
44 |
45 | return (
46 |
47 | {needsStripeAttention ? (
48 |
49 |
50 |
51 |
Votre compte prestataire nécessite une action sur Stripe.
52 | {dueCount > 0 ? (
53 |
54 |
Éléments requis: {dueCount}
55 |
56 | {[...pastDue, ...currentlyDue].slice(0, 4).map((k) => (
57 | - {requirementLabel(k)}
58 | ))}
59 | {[...pastDue, ...currentlyDue].length > 4 ? (
60 | - …
61 | ) : null}
62 |
63 |
64 | ) : null}
65 | {connectStatus?.requirements?.disabledReason ? (
66 |
Raison: {connectStatus.requirements.disabledReason}
67 | ) : null}
68 |
69 |
70 |
78 |
79 |
85 |
86 |
87 |
88 |
89 | ) : null}
90 |
91 |
92 | );
93 | }
94 |
--------------------------------------------------------------------------------
/frontend/app/routes/_public+/login.tsx:
--------------------------------------------------------------------------------
1 | import { getFormProps, getInputProps, useForm } from '@conform-to/react';
2 | import { getZodConstraint, parseWithZod } from '@conform-to/zod';
3 | import { json, redirect, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node";
4 | import { Form, useActionData } from '@remix-run/react';
5 | import { LogIn } from "lucide-react";
6 | import { z } from 'zod';
7 | import { Field } from '~/components/forms';
8 | import { Button } from '~/components/ui/button';
9 | import { getPagination } from "~/lib/utils";
10 | import { getOptionalUser } from "~/server/auth.server";
11 |
12 |
13 | export const loader = async ({ context }: LoaderFunctionArgs) => {
14 | const user = await getOptionalUser({ context })
15 | if (user) {
16 | return redirect('/')
17 | }
18 | return null;
19 | };
20 |
21 |
22 | export const action = async ({ request, context }: ActionFunctionArgs) => {
23 | const formData = await request.formData();
24 |
25 | const submission = await parseWithZod(formData, {
26 | async: true,
27 | schema: LoginSchema.superRefine(async (data, ctx) => {
28 | const { email, password } = data;
29 |
30 | const existingUser = await context.remixService.auth.checkIfUserExists({
31 | email,
32 | withPassword: true,
33 | password,
34 | });
35 |
36 | if (existingUser.error) {
37 | ctx.addIssue({
38 | code: 'custom',
39 | path: ['email'],
40 | message: existingUser.message,
41 | });
42 | }
43 | }),
44 | });
45 |
46 | if (submission.status !== 'success') {
47 | return json(
48 | { result: submission.reply() },
49 | {
50 | status: 400,
51 | }
52 | );
53 | }
54 | // l'email et le mot de passe sont valides, et un compte utilisateur existe.
55 | // connecter l'utilisateur.
56 | const { email } = submission.value;
57 | const { sessionToken } = await context.remixService.auth.authenticateUser({
58 | email,
59 | });
60 |
61 | const urlParams = new URL(request.url).searchParams
62 | const redirectTo = urlParams.get('redirectTo') || '/';
63 | // Example usage to ensure the helper is available and tree-shaken if unused
64 | getPagination({ page: 1, perPage: 10 });
65 | // Connecter l'utilisateur associé à l'email
66 | return redirect(`/authenticate?token=${sessionToken}&redirectTo=${redirectTo}`);
67 | };
68 |
69 | const LoginSchema = z.object({
70 | email: z
71 | .string({
72 | required_error: "L'email est obligatoire.",
73 | })
74 | .email({
75 | message: 'Cet email est invalide.',
76 | }),
77 | password: z.string({ required_error: 'Le mot de passe est obligatoire.' }),
78 | });
79 |
80 | export default function Login() {
81 | const actionData = useActionData();
82 | const [form, fields] = useForm({
83 | constraint: getZodConstraint(LoginSchema),
84 | onValidate({ formData }) {
85 | return parseWithZod(formData, {
86 | schema: LoginSchema,
87 | });
88 | },
89 | lastResult: actionData?.result,
90 | });
91 |
92 | return (
93 |
94 |
95 | {/* Header */}
96 |
97 |
98 |
Connexion
99 |
100 |
101 | {/* Login Form */}
102 |
103 |
133 |
134 |
135 |
136 | );
137 | }
138 |
--------------------------------------------------------------------------------
/frontend/app/routes/_public+/my-services.$offerId.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | getFormProps,
3 | getInputProps,
4 | getTextareaProps,
5 | useForm,
6 | } from "@conform-to/react";
7 | import { getZodConstraint, parseWithZod } from "@conform-to/zod";
8 | import {
9 | json,
10 | redirect,
11 | unstable_createMemoryUploadHandler,
12 | unstable_parseMultipartFormData,
13 | type ActionFunctionArgs,
14 | type LoaderFunctionArgs,
15 | } from "@remix-run/node";
16 | import {
17 | Form,
18 | useActionData,
19 | useLoaderData,
20 | useNavigation,
21 | useParams,
22 | } from "@remix-run/react";
23 | import { z } from "zod";
24 | import { CheckboxField, ErrorList, Field, TextareaField } from "~/components/forms";
25 | import { Button } from "~/components/ui/button";
26 | import { Input } from "~/components/ui/input";
27 | import { Label } from "~/components/ui/label";
28 | import { requireUser } from "~/server/auth.server";
29 | import { createOffer, editOffer, getUserOffer } from "~/server/profile.server";
30 |
31 | export const loader = async ({ context, params }: LoaderFunctionArgs) => {
32 | const offerId = params.offerId;
33 | if (!offerId) {
34 | throw new Error("Did not find offerId");
35 | }
36 | const user = await requireUser({ context });
37 |
38 | if (offerId === "new") {
39 | return json({
40 | offer: null,
41 | });
42 | }
43 |
44 | const offer = await getUserOffer({
45 | context,
46 | userId: user.id,
47 | offerId,
48 | });
49 |
50 | if (!offer) {
51 | return redirect("/my-services");
52 | }
53 |
54 | return json({
55 | offer,
56 | });
57 | };
58 |
59 | export const CreateOfferSchema = z.object({
60 | title: z.string({
61 | required_error: "Votre annonce doit avoir un titre",
62 | }),
63 | description: z.string({
64 | required_error: "Votre annonce doit avoir une description.",
65 | }),
66 | price: z
67 | .number({
68 | required_error: "Votre annonce doit avoir un prix.",
69 | })
70 | .min(0, "Le prix doit être positif.")
71 | .max(500, "Le prix doit être inférieur à 500€."),
72 | active: z
73 | .boolean({
74 | required_error: "Votre annonce doit être active ou non.",
75 | })
76 | .default(false),
77 | image: z.instanceof(File).superRefine((file, ctx) => {
78 | if (file.size > 10_000_000) {
79 | ctx.addIssue({
80 | code: z.ZodIssueCode.custom,
81 | message: "L'image est trop lourde (10Mo max)",
82 | });
83 | return false;
84 | }
85 | }).optional(),
86 | });
87 |
88 | export const action = async ({
89 | request,
90 | context,
91 | params,
92 | }: ActionFunctionArgs) => {
93 | const offerId = params.offerId;
94 | if (!offerId) {
95 | throw new Error("Did not find offerId");
96 | }
97 |
98 | const user = await requireUser({ context });
99 | const imageHandler = unstable_createMemoryUploadHandler({
100 | maxPartSize: 10_000_000,
101 | });
102 | const formData = await unstable_parseMultipartFormData(request, imageHandler);
103 | const submission = parseWithZod(formData, {
104 | schema: CreateOfferSchema,
105 | });
106 |
107 | if (submission.status !== "success") {
108 | return json(
109 | { result: submission.reply() },
110 | {
111 | status: 400,
112 | },
113 | );
114 | }
115 |
116 | if (offerId === "new") {
117 | const { id: createdOfferId } = await createOffer({
118 | context,
119 | offerData: submission.value,
120 | userId: user.id,
121 | });
122 |
123 | return redirect(`/my-services/${createdOfferId}`);
124 | }
125 |
126 | await editOffer({
127 | context,
128 | offerData: submission.value,
129 | userId: user.id,
130 | offerId,
131 | });
132 |
133 | return null;
134 | };
135 |
136 | export default function CreateOffer() {
137 | const { offer } = useLoaderData();
138 | const actionData = useActionData();
139 | const [form, fields] = useForm({
140 | constraint: getZodConstraint(CreateOfferSchema),
141 | onValidate({ formData }) {
142 | return parseWithZod(formData, {
143 | schema: CreateOfferSchema,
144 | });
145 | },
146 | lastResult: actionData?.result,
147 | defaultValue: {
148 | title: offer?.title ?? "",
149 | description: offer?.description ?? "",
150 | price: offer?.price ?? "",
151 | active: offer?.active ?? false,
152 | },
153 | });
154 | const { offerId } = useParams();
155 |
156 | const isNew = offerId === "new";
157 | const isLoading = useNavigation().state === "submitting";
158 | return (
159 |
160 |
161 | {isNew ? "Ajouter une offre" : `${offer?.title}`}
162 |
163 |
239 |
240 | );
241 | }
242 |
--------------------------------------------------------------------------------
/frontend/app/routes/_public+/my-services.index.tsx:
--------------------------------------------------------------------------------
1 | import { json, type LoaderFunctionArgs, type SerializeFrom } from "@remix-run/node";
2 | import { Link, useLoaderData } from "@remix-run/react";
3 | import { Eye, EyeOff, Plus, Settings } from "lucide-react";
4 | import { formatDate, formatPrice } from "~/lib/utils";
5 | import { requireUser } from "~/server/auth.server";
6 | import { getUserOffers, getUserWithAvatar } from "~/server/profile.server";
7 |
8 | export const loader = async ({ context }: LoaderFunctionArgs) => {
9 | const user = await requireUser({ context });
10 | const [offers, userWithAvatar] = await Promise.all([
11 | getUserOffers({
12 | context,
13 | userId: user.id
14 | }),
15 | getUserWithAvatar({
16 | context,
17 | userId: user.id
18 | })
19 | ]);
20 | return json({ offers, user: userWithAvatar });
21 | };
22 |
23 | export default function MyServices() {
24 | const { offers, user } = useLoaderData();
25 |
26 | const activeOffers = offers.filter(offer => offer.active);
27 | const inactiveOffers = offers.filter(offer => !offer.active);
28 |
29 | return (
30 |
31 |
32 | {/* Header */}
33 |
34 |
35 |
36 |
Mes services
37 |
38 |
42 |
43 | Ajouter
44 |
45 |
46 |
47 | {offers.length === 0 ? (
48 |
49 |
50 |
Aucun service
51 |
Créez votre première offre de service
52 |
56 |
57 | Créer une offre
58 |
59 |
60 | ) : (
61 |
62 | {/* Active Services */}
63 | {activeOffers.length > 0 && (
64 |
65 |
66 |
67 |
68 |
Services actifs ({activeOffers.length})
69 |
Visibles par les autres utilisateurs
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 | {activeOffers.map((offer) => (
78 |
79 | ))}
80 |
81 |
82 |
83 | )}
84 |
85 | {/* Inactive Services */}
86 | {inactiveOffers.length > 0 && (
87 |
88 |
89 |
90 |
91 |
Services inactifs ({inactiveOffers.length})
92 |
Non visibles par les autres utilisateurs
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 | {inactiveOffers.map((offer) => (
101 |
102 | ))}
103 |
104 |
105 |
106 | )}
107 |
108 | )}
109 |
110 |
111 | );
112 | }
113 |
114 | const ServiceCard = ({
115 | offer,
116 | user
117 | }: {
118 | offer: SerializeFrom>>[0];
119 | user: SerializeFrom>>;
120 | }) => {
121 | const { updatedAt, description, price, title, active, imageUrl } = offer;
122 |
123 | return (
124 |
128 | {/* Service Image and User Avatar */}
129 |
130 | {/* Service Image */}
131 |
132 | {imageUrl ? (
133 |

138 | ) : (
139 |
140 |
141 |
142 | )}
143 |
144 | {/* User Avatar - positioned in bottom right corner */}
145 |
146 |
147 | {user.avatarUrl ? (
148 |

153 | ) : (
154 |
155 |
156 | {user.name
157 | ?.split(' ')
158 | .map(word => word.charAt(0))
159 | .join('')
160 | .toUpperCase()
161 | .slice(0, 2) || "?"}
162 |
163 |
164 | )}
165 |
166 |
167 |
168 |
169 | {/* Content */}
170 |
171 |
172 | {title}
173 |
174 |
175 |
176 | {formatPrice({ price })}
177 |
178 |
179 | {offer.isStripeSynced ? (
180 |
Stripe OK
181 | ) : (
182 |
Stripe à synchroniser
183 | )}
184 |
185 |
186 | {active ? 'Actif' : 'Inactif'}
187 |
188 |
189 |
190 |
191 |
192 | {description}
193 |
194 |
195 | {formatDate({ date: updatedAt })}
196 |
197 |
198 |
199 |
200 | );
201 | };
--------------------------------------------------------------------------------
/frontend/app/routes/_public+/prestataires.$prestataireId.tsx:
--------------------------------------------------------------------------------
1 | import { json, type LoaderFunctionArgs } from "@remix-run/node";
2 | import { Link, useLoaderData } from "@remix-run/react";
3 | import { formatPrice } from "~/lib/utils";
4 |
5 | export const loader = async ({ params, context }: LoaderFunctionArgs) => {
6 | const prestataireId = params.prestataireId;
7 | if (!prestataireId) {
8 | throw new Response("Missing provider id", { status: 400 });
9 | }
10 |
11 | const user = await context.remixService.prisma.user.findUnique({
12 | where: { id: prestataireId },
13 | select: {
14 | id: true,
15 | name: true,
16 | avatarFileKey: true,
17 | chargesEnabled: true,
18 | payoutsEnabled: true,
19 | detailsSubmitted: true,
20 | _count: { select: { offers: true, transactions: true } },
21 | },
22 | });
23 | if (!user) throw new Response("Not found", { status: 404 });
24 |
25 | let avatarUrl = "";
26 | if (user.avatarFileKey) {
27 | avatarUrl = await context.remixService.aws.getFileUrl({ fileKey: user.avatarFileKey });
28 | }
29 |
30 | const offers = await context.remixService.prisma.offer.findMany({
31 | where: { userId: prestataireId, active: true },
32 | orderBy: { updatedAt: "desc" },
33 | select: {
34 | id: true,
35 | title: true,
36 | description: true,
37 | price: true,
38 | imageFileKey: true,
39 | },
40 | });
41 |
42 | const offersWithImages = await Promise.all(
43 | offers.map(async (o) => {
44 | let imageUrl = "";
45 | if (o.imageFileKey) {
46 | imageUrl = await context.remixService.aws.getFileUrl({ fileKey: o.imageFileKey });
47 | }
48 | const { imageFileKey, ...rest } = o;
49 | return { ...rest, imageUrl };
50 | }),
51 | );
52 |
53 | return json({
54 | user: {
55 | id: user.id,
56 | name: user.name,
57 | avatarUrl,
58 | verified: Boolean(user.chargesEnabled && user.payoutsEnabled && user.detailsSubmitted),
59 | counts: user._count,
60 | },
61 | offers: offersWithImages,
62 | });
63 | };
64 |
65 | export default function PrestataireDetailPage() {
66 | const { user, offers } = useLoaderData();
67 | return (
68 |
69 |
70 |
71 |

72 |
73 |
{user.name ?? "Utilisateur"}
74 |
75 | ID: {user.id}
76 | {user.verified ? (
77 | prestataire certifié
78 | ) : null}
79 | {user.counts.offers} offre{user.counts.offers > 1 ? "s" : ""}
80 | •
81 | {user.counts.transactions} transaction{user.counts.transactions > 1 ? "s" : ""}
82 |
83 |
84 |
85 |
86 | {offers.length === 0 ? (
87 |
Aucune offre publiée.
88 | ) : (
89 |
90 | {offers.map((o) => (
91 | -
92 | {o.imageUrl ? (
93 |
94 | ) : (
95 |
96 | )}
97 |
98 |
{o.title}
99 |
{o.description}
100 |
{formatPrice({ price: o.price })}
101 |
102 | Voir l’offre
103 |
104 |
105 |
106 | ))}
107 |
108 | )}
109 |
110 |
111 | ← Retour aux prestataires
112 |
113 |
114 |
115 | );
116 | }
117 |
118 |
119 |
--------------------------------------------------------------------------------
/frontend/app/routes/_public+/prestataires.index.tsx:
--------------------------------------------------------------------------------
1 | import { json, type LoaderFunctionArgs } from "@remix-run/node";
2 | import { Link, useLoaderData, useSearchParams } from "@remix-run/react";
3 | import { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious } from "~/components/ui/pagination";
4 | import { getCertifiedProviders } from "~/server/providers.server";
5 |
6 | export const loader = async ({ request, context }: LoaderFunctionArgs) => {
7 | const url = new URL(request.url);
8 | const page = url.searchParams.get("page");
9 | const perPage = url.searchParams.get("perPage");
10 | const data = await getCertifiedProviders({ context, page, perPage });
11 | return json(data);
12 | };
13 |
14 | export default function PrestatairesPage() {
15 | const { items, pageInfo } = useLoaderData();
16 | const [params] = useSearchParams();
17 | const pageParam = params.get("page") ?? "1";
18 | const page = Math.max(Number(pageParam) || 1, 1);
19 |
20 | return (
21 |
22 |
23 |
24 |
Prestataires
25 |
Professionnels avec compte Connect vérifié
26 |
27 |
28 | {items.length === 0 ? (
29 |
Aucun prestataire pour le moment.
30 | ) : (
31 |
32 | {items.map((u) => (
33 | -
34 |
35 |
36 |
37 |
38 | {u.name ?? "Utilisateur"}
39 | prestataire certifié
40 |
41 |
42 | {u.counts.offers} offre{u.counts.offers > 1 ? "s" : ""}
43 | •
44 | {u.counts.transactions} transaction{u.counts.transactions > 1 ? "s" : ""}
45 |
46 |
47 |
48 |
49 | ))}
50 |
51 | )}
52 |
53 |
54 |
55 |
56 |
57 |
62 |
63 |
64 | {pageInfo.page}
65 |
66 |
67 |
72 |
73 |
74 |
75 |
76 |
77 |
78 | Retour à l’accueil
79 |
80 |
81 |
82 | );
83 | }
84 |
85 |
86 |
--------------------------------------------------------------------------------
/frontend/app/routes/_public+/register.tsx:
--------------------------------------------------------------------------------
1 | import { getFormProps, getInputProps, useForm } from '@conform-to/react';
2 | import { getZodConstraint, parseWithZod } from '@conform-to/zod';
3 | import {
4 | json,
5 | redirect,
6 | type ActionFunctionArgs,
7 | type LoaderFunctionArgs,
8 | } from '@remix-run/node';
9 | import { Form, useActionData, useNavigation } from '@remix-run/react';
10 | import { UserPlus } from "lucide-react";
11 | import { z } from 'zod';
12 | import { Field } from '~/components/forms';
13 | import { Button } from '~/components/ui/button';
14 | import { getOptionalUser } from '~/server/auth.server';
15 |
16 | export const loader = async ({ context }: LoaderFunctionArgs) => {
17 | const user = await getOptionalUser({ context });
18 | if (user) {
19 | return redirect('/');
20 | }
21 | return null;
22 | };
23 |
24 | const RegisterSchema = z.object({
25 | email: z
26 | .string({
27 | required_error: "L'email est obligatoire.",
28 | })
29 | .email({
30 | message: 'Cet email est invalide.',
31 | }),
32 | firstname: z.string({
33 | required_error: 'Le prénom est obligatoire',
34 | }),
35 | password: z.string({ required_error: 'Le mot de passe est obligatoire.' }),
36 | });
37 |
38 | export const action = async ({ request, context }: ActionFunctionArgs) => {
39 | const formData = await request.formData();
40 | const submission = await parseWithZod(formData, {
41 | async: true,
42 | schema: RegisterSchema.superRefine(async (data, ctx) => {
43 | const { email } = data;
44 |
45 | const existingUser = await context.remixService.auth.checkIfUserExists({
46 | email,
47 | withPassword: false,
48 | password: '',
49 | });
50 |
51 | if (existingUser.error === false) {
52 | ctx.addIssue({
53 | code: 'custom',
54 | path: ['email'],
55 | message: 'Cet utilisateur existe déjà.',
56 | });
57 | }
58 | }),
59 | });
60 |
61 | if (submission.status !== 'success') {
62 | return json(
63 | { result: submission.reply() },
64 | {
65 | status: 400,
66 | }
67 | );
68 | }
69 | const { email, firstname, password } = submission.value;
70 |
71 | const { email: createdUserEmail } =
72 | await context.remixService.auth.createUser({
73 | email,
74 | name: firstname,
75 | password,
76 | });
77 |
78 | const { sessionToken } = await context.remixService.auth.authenticateUser({
79 | email: createdUserEmail,
80 | });
81 |
82 | // Connecter l'utilisateur associé à l'email
83 | return redirect(`/authenticate?token=${sessionToken}`);
84 | };
85 |
86 | export default function Register() {
87 | const actionData = useActionData();
88 | const [form, fields] = useForm({
89 | constraint: getZodConstraint(RegisterSchema),
90 | onValidate({ formData }) {
91 | return parseWithZod(formData, {
92 | schema: RegisterSchema,
93 | });
94 | },
95 | lastResult: actionData?.result,
96 | });
97 |
98 | const isLoading = useNavigation().state === 'submitting';
99 |
100 | return (
101 |
102 |
103 | {/* Header */}
104 |
105 |
106 |
Création de compte
107 |
108 |
109 | {/* Register Form */}
110 |
111 |
151 |
152 |
153 |
154 | );
155 | }
156 |
--------------------------------------------------------------------------------
/frontend/app/routes/_public+/stripe.dashboard.tsx:
--------------------------------------------------------------------------------
1 | import { type LoaderFunctionArgs, redirect } from "@remix-run/node";
2 | import { requireUser } from "~/server/auth.server";
3 | import { createDashboardLoginLink } from "~/server/stripe.server";
4 |
5 | export const loader = async ({ context, request }: LoaderFunctionArgs) => {
6 | const user = await requireUser({ context, redirectTo: "/stripe/dashboard" });
7 | const url = await createDashboardLoginLink({
8 | context,
9 | userId: user.id,
10 | requestUrl: request.url,
11 | });
12 | throw redirect(url);
13 | };
14 |
15 |
16 |
--------------------------------------------------------------------------------
/frontend/app/routes/_public+/stripe.onboarding.tsx:
--------------------------------------------------------------------------------
1 | import { type LoaderFunctionArgs, redirect } from "@remix-run/node";
2 | import { requireUser } from "~/server/auth.server";
3 | import { createOnboardingLink } from "~/server/stripe.server";
4 |
5 | export const loader = async ({ context, request }: LoaderFunctionArgs) => {
6 | const user = await requireUser({ context, redirectTo: "/stripe/onboarding" });
7 | const url = await createOnboardingLink({
8 | context,
9 | userId: user.id,
10 | requestUrl: request.url,
11 | });
12 | throw redirect(url);
13 | };
--------------------------------------------------------------------------------
/frontend/app/routes/_public+/stripe.refresh.tsx:
--------------------------------------------------------------------------------
1 | import { json, type LoaderFunctionArgs } from "@remix-run/node";
2 | import { requireUser } from "~/server/auth.server";
3 | import { refreshAccountStatus } from "~/server/stripe.server";
4 |
5 | export const loader = async ({ context }: LoaderFunctionArgs) => {
6 | const user = await requireUser({ context, redirectTo: "/profile" });
7 | const status = await refreshAccountStatus({ context, userId: user.id });
8 | return json({ ok: true, status });
9 | };
10 |
11 |
12 |
--------------------------------------------------------------------------------
/frontend/app/routes/_public+/stripe.return.tsx:
--------------------------------------------------------------------------------
1 | import { type LoaderFunctionArgs, redirect } from "@remix-run/node";
2 | import { requireUser } from "~/server/auth.server";
3 | import { refreshAccountStatus } from "~/server/stripe.server";
4 |
5 | export const loader = async ({ context }: LoaderFunctionArgs) => {
6 | const user = await requireUser({ context, redirectTo: "/stripe/return" });
7 | await refreshAccountStatus({ context, userId: user.id });
8 | return redirect("/profile"); // or wherever makes sense
9 | };
--------------------------------------------------------------------------------
/frontend/app/server/auth.server.ts:
--------------------------------------------------------------------------------
1 | import { redirect, type AppLoadContext } from "@remix-run/node";
2 | import { z } from "zod";
3 |
4 | const authenticatedUserSchema = z.object({
5 | id: z.string(),
6 | email: z.string(),
7 | });
8 |
9 | export const getOptionalUser = async ({
10 | context,
11 | }: { context: AppLoadContext }) => {
12 | const user = authenticatedUserSchema
13 | .optional()
14 | .nullable()
15 | .parse(context.user);
16 | if (user) {
17 | return await context.remixService.getUser({
18 | userId: user.id,
19 | });
20 | }
21 | return null;
22 | };
23 |
24 | export const requireUser = async ({
25 | context,
26 | redirectTo = '/',
27 | }: {
28 | context: AppLoadContext;
29 | redirectTo?: string;
30 | }) => {
31 | const user = await getOptionalUser({ context });
32 | if (!user) {
33 | throw redirect(`/login?redirectTo=${redirectTo}`);
34 | }
35 |
36 | return user;
37 | };
38 |
39 | export const getNotificationStats = async ({
40 | context,
41 | userId,
42 | }: {
43 | context: AppLoadContext;
44 | userId: string;
45 | }) => {
46 | // Compter les offres en attente que l'utilisateur doit accepter/refuser
47 | const pendingOffers = await context.remixService.prisma.message.count({
48 | where: {
49 | status: 10, // PENDING_OFFER
50 | transaction: {
51 | offer: {
52 | userId: userId, // L'utilisateur est le propriétaire de l'offre
53 | },
54 | },
55 | },
56 | });
57 |
58 | // Compter les transactions actives (où l'utilisateur est impliqué)
59 | const activeTransactions = await context.remixService.prisma.transaction.count({
60 | where: {
61 | OR: [
62 | { userId: userId }, // L'utilisateur a initié la transaction
63 | { offer: { userId: userId } }, // L'utilisateur possède l'offre
64 | ],
65 | },
66 | });
67 |
68 | // Compter les offres actives de l'utilisateur
69 | const activeOffers = await context.remixService.prisma.offer.count({
70 | where: {
71 | userId: userId,
72 | active: true,
73 | },
74 | });
75 |
76 | // Compter les messages récents où l'utilisateur doit répondre
77 | // (messages dans les transactions où l'utilisateur est impliqué mais n'est pas le dernier à avoir écrit)
78 | const transactionsWithLastMessage = await context.remixService.prisma.transaction.findMany({
79 | where: {
80 | OR: [
81 | { userId: userId },
82 | { offer: { userId: userId } },
83 | ],
84 | },
85 | include: {
86 | messages: {
87 | orderBy: { createdAt: 'desc' },
88 | take: 1,
89 | include: {
90 | user: true,
91 | },
92 | },
93 | },
94 | });
95 |
96 | const pendingReplies = transactionsWithLastMessage.filter(transaction => {
97 | const lastMessage = transaction.messages[0];
98 | return lastMessage && lastMessage.userId !== userId && lastMessage.status === 0; // Message normal, pas une offre
99 | }).length;
100 |
101 | return {
102 | pendingOffers,
103 | activeTransactions,
104 | activeOffers,
105 | pendingReplies,
106 | };
107 | };
108 |
109 | export const getNotifications = async ({
110 | context,
111 | userId,
112 | }: {
113 | context: AppLoadContext;
114 | userId: string;
115 | }) => {
116 | // Récupérer les offres en attente avec détails
117 | const pendingOffers = await context.remixService.prisma.message.findMany({
118 | where: {
119 | status: 10, // PENDING_OFFER
120 | transaction: {
121 | offer: {
122 | userId: userId, // L'utilisateur est le propriétaire de l'offre
123 | },
124 | },
125 | },
126 | include: {
127 | user: {
128 | select: {
129 | id: true,
130 | name: true,
131 | },
132 | },
133 | transaction: {
134 | include: {
135 | offer: {
136 | select: {
137 | id: true,
138 | title: true,
139 | },
140 | },
141 | },
142 | },
143 | },
144 | orderBy: {
145 | createdAt: 'desc',
146 | },
147 | take: 10,
148 | });
149 |
150 | // Récupérer les messages récents où l'utilisateur doit répondre
151 | const recentMessages = await context.remixService.prisma.message.findMany({
152 | where: {
153 | status: 0, // Messages normaux
154 | transaction: {
155 | OR: [
156 | { userId: userId },
157 | { offer: { userId: userId } },
158 | ],
159 | },
160 | NOT: {
161 | userId: userId, // Exclure les messages de l'utilisateur lui-même
162 | },
163 | },
164 | include: {
165 | user: {
166 | select: {
167 | id: true,
168 | name: true,
169 | },
170 | },
171 | transaction: {
172 | include: {
173 | offer: {
174 | select: {
175 | id: true,
176 | title: true,
177 | },
178 | },
179 | },
180 | },
181 | },
182 | orderBy: {
183 | createdAt: 'desc',
184 | },
185 | take: 10,
186 | });
187 |
188 | // Récupérer les transactions récemment créées
189 | const recentTransactions = await context.remixService.prisma.transaction.findMany({
190 | where: {
191 | offer: {
192 | userId: userId, // L'utilisateur possède l'offre
193 | },
194 | },
195 | include: {
196 | user: {
197 | select: {
198 | id: true,
199 | name: true,
200 | },
201 | },
202 | offer: {
203 | select: {
204 | id: true,
205 | title: true,
206 | },
207 | },
208 | },
209 | orderBy: {
210 | createdAt: 'desc',
211 | },
212 | take: 5,
213 | });
214 |
215 | // Combiner et trier toutes les notifications par date
216 | const allNotifications = [
217 | ...pendingOffers.map(offer => ({
218 | id: offer.id,
219 | type: 'pending_offer' as const,
220 | title: `Offre de ${offer.price}€ reçue`,
221 | description: `${offer.user.name} vous a fait une offre pour "${offer.transaction.offer.title}"`,
222 | createdAt: offer.createdAt,
223 | transactionId: offer.transaction.id,
224 | urgent: true,
225 | })),
226 | ...recentMessages.map(message => ({
227 | id: message.id,
228 | type: 'message' as const,
229 | title: 'Nouveau message',
230 | description: `${message.user.name} a écrit concernant "${message.transaction.offer.title}"`,
231 | createdAt: message.createdAt,
232 | transactionId: message.transaction.id,
233 | urgent: false,
234 | })),
235 | ...recentTransactions.map(transaction => ({
236 | id: transaction.id,
237 | type: 'transaction' as const,
238 | title: 'Nouvelle demande',
239 | description: `${transaction.user.name} s'intéresse à "${transaction.offer.title}"`,
240 | createdAt: transaction.createdAt,
241 | transactionId: transaction.id,
242 | urgent: false,
243 | })),
244 | ];
245 |
246 | // Trier par date décroissante et limiter à 15 notifications
247 | return allNotifications
248 | .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
249 | .slice(0, 15);
250 | };
251 |
--------------------------------------------------------------------------------
/frontend/app/server/offers.server.ts:
--------------------------------------------------------------------------------
1 | import { type AppLoadContext } from "@remix-run/node";
2 | import { getPagination } from "~/lib/utils";
3 | import { filtersLoader } from "~/routes/_index";
4 |
5 | export const getOffers = async ({
6 | context,
7 | userId,
8 | filters,
9 | }: {
10 | context: AppLoadContext;
11 | userId?: string;
12 | filters: Awaited>;
13 | }) => {
14 | const {
15 | skip,
16 | take,
17 | page: currentPage,
18 | perPage: safePerPage,
19 | } = getPagination({
20 | page: filters.page,
21 | perPage: filters.perPage,
22 | maxPerPage: 50,
23 | });
24 |
25 | const where = {
26 | active: true as const,
27 | ...(filters.q && filters.q.trim().length > 0
28 | ? {
29 | OR: [
30 | { title: { contains: filters.q, mode: "insensitive" as const } },
31 | {
32 | description: {
33 | contains: filters.q,
34 | mode: "insensitive" as const,
35 | },
36 | },
37 | ],
38 | }
39 | : {}),
40 | };
41 |
42 | const [offers, total] = await Promise.all([
43 | context.remixService.prisma.offer.findMany({
44 | select: {
45 | id: true,
46 | title: true,
47 | description: true,
48 | price: true,
49 | updatedAt: true,
50 | userId: true,
51 | imageFileKey: true,
52 | transactions: userId
53 | ? {
54 | where: {
55 | userId: userId,
56 | },
57 | select: {
58 | id: true,
59 | },
60 | }
61 | : false,
62 | },
63 | where,
64 | orderBy: { updatedAt: "desc" },
65 | skip,
66 | take,
67 | }),
68 | context.remixService.prisma.offer.count({ where }),
69 | ]);
70 |
71 | const offersWithImagesAndTransactions = await Promise.all(
72 | offers.map(async (offer) => {
73 | let imageUrl = "";
74 | if (offer.imageFileKey) {
75 | imageUrl = await context.remixService.aws.getFileUrl({
76 | fileKey: offer.imageFileKey,
77 | });
78 | }
79 |
80 | const { imageFileKey, transactions, ...offerProps } = offer;
81 | return {
82 | ...offerProps,
83 | imageUrl,
84 | hasActiveTransaction: userId
85 | ? Boolean(transactions && transactions.length > 0)
86 | : false,
87 | };
88 | }),
89 | );
90 |
91 | const pageCount = Math.max(Math.ceil(total / safePerPage), 1);
92 |
93 | return {
94 | items: offersWithImagesAndTransactions,
95 | pageInfo: {
96 | page: currentPage,
97 | perPage: safePerPage,
98 | total,
99 | pageCount,
100 | hasPreviousPage: currentPage > 1,
101 | hasNextPage: currentPage < pageCount,
102 | },
103 | };
104 | };
105 |
106 | export const getOffer = async ({
107 | offerId,
108 | context,
109 | }: {
110 | offerId: string;
111 | context: AppLoadContext;
112 | }) => {
113 | const offer = await context.remixService.prisma.offer.findUnique({
114 | where: {
115 | id: offerId,
116 | active: true,
117 | },
118 | select: {
119 | id: true,
120 | title: true,
121 | description: true,
122 | price: true,
123 | updatedAt: true,
124 | userId: true,
125 | imageFileKey: true,
126 | stripeProductId: true,
127 | stripePriceId: true,
128 | user: {
129 | select: {
130 | id: true,
131 | name: true,
132 | avatarFileKey: true,
133 | },
134 | },
135 | },
136 | });
137 |
138 | if (!offer) {
139 | return null;
140 | }
141 |
142 | let imageUrl = "";
143 | if (offer.imageFileKey) {
144 | imageUrl = await context.remixService.aws.getFileUrl({
145 | fileKey: offer.imageFileKey,
146 | });
147 | }
148 |
149 | let userAvatarUrl = "";
150 | if (offer.user.avatarFileKey) {
151 | userAvatarUrl = await context.remixService.aws.getFileUrl({
152 | fileKey: offer.user.avatarFileKey,
153 | });
154 | }
155 |
156 | return {
157 | ...offer,
158 | imageUrl,
159 | isStripeSynced: Boolean(offer.stripeProductId && offer.stripePriceId),
160 | user: {
161 | ...offer.user,
162 | avatarUrl: userAvatarUrl,
163 | },
164 | };
165 | };
166 |
167 | export const getExistingTransaction = async ({
168 | offerId,
169 | userId,
170 | context,
171 | }: {
172 | offerId: string;
173 | userId: string;
174 | context: AppLoadContext;
175 | }) => {
176 | return await context.remixService.prisma.transaction.findUnique({
177 | where: {
178 | offerId_userId: {
179 | offerId,
180 | userId,
181 | },
182 | },
183 | select: {
184 | id: true,
185 | },
186 | });
187 | };
188 |
--------------------------------------------------------------------------------
/frontend/app/server/profile.server.ts:
--------------------------------------------------------------------------------
1 | import { type AppLoadContext } from "@remix-run/node";
2 | import { type z } from "zod";
3 | import { type CreateOfferSchema } from "~/routes/_public+/my-services.$offerId";
4 | import { type EditProfileSchema } from "~/routes/_public+/profile";
5 |
6 | export const getUserOffers = async ({
7 | userId,
8 | context,
9 | }: { context: AppLoadContext; userId: string }) => {
10 | const offers = await context.remixService.prisma.offer.findMany({
11 | select: {
12 | id: true,
13 | title: true,
14 | description: true,
15 | price: true,
16 | updatedAt: true,
17 | active: true,
18 | recurring: true,
19 | imageFileKey: true,
20 | stripeProductId: true,
21 | stripePriceId: true,
22 | },
23 | where: {
24 | userId,
25 | },
26 | orderBy: {
27 | createdAt: "asc",
28 | },
29 | });
30 |
31 | // Get image URLs for all offers
32 | const offersWithImages = await Promise.all(
33 | offers.map(async (offer) => {
34 | let imageUrl = "";
35 | if (offer.imageFileKey) {
36 | imageUrl = await context.remixService.aws.getFileUrl({
37 | fileKey: offer.imageFileKey
38 | });
39 | }
40 | return {
41 | ...offer,
42 | imageUrl,
43 | isStripeSynced: Boolean(offer.stripeProductId && offer.stripePriceId),
44 | };
45 | })
46 | );
47 |
48 | return offersWithImages;
49 | };
50 |
51 | export const createOffer = async ({
52 | context,
53 | offerData,
54 | userId,
55 | }: {
56 | context: AppLoadContext;
57 | offerData: z.infer;
58 | userId: string;
59 | }) => {
60 | let imageFileKey : string | null = null;
61 | if (offerData.image) {
62 | const { fileKey } = await context.remixService.aws.uploadFile({
63 | file: {
64 | size: offerData.image.size,
65 | mimetype: offerData.image.type,
66 | originalname: offerData.image.name,
67 | buffer: Buffer.from(await offerData.image.arrayBuffer()),
68 | },
69 | })
70 | imageFileKey = fileKey;
71 | }
72 | const created = await context.remixService.prisma.offer.create({
73 | data: {
74 | title: offerData.title,
75 | description: offerData.description,
76 | price: offerData.price,
77 | ...(imageFileKey && { imageFileKey }),
78 | user: {
79 | connect: {
80 | id: userId,
81 | },
82 | },
83 | },
84 | select: {
85 | id: true,
86 | },
87 | });
88 | await context.remixService.stripe.upsertOfferProduct(created.id);
89 | return created;
90 | };
91 |
92 | export const getUserOffer = async ({
93 | userId,
94 | context,
95 | offerId,
96 | }: { context: AppLoadContext; userId: string; offerId: string }) => {
97 | const offer = await context.remixService.prisma.offer.findUnique({
98 | select: {
99 | id: true,
100 | title: true,
101 | description: true,
102 | price: true,
103 | updatedAt: true,
104 | active: true,
105 | recurring: true,
106 | imageFileKey: true,
107 | },
108 | where: {
109 | id: offerId,
110 | userId,
111 | },
112 | });
113 | let imageUrl = "";
114 | if (offer?.imageFileKey) {
115 | imageUrl = await context.remixService.aws.getFileUrl({fileKey: offer.imageFileKey})
116 | }
117 | return {
118 | ...offer,
119 | imageUrl,
120 | }
121 | };
122 |
123 | export const editOffer = async ({
124 | context,
125 | offerId,
126 | offerData,
127 | userId,
128 | }: {
129 | context: AppLoadContext;
130 | offerData: z.infer;
131 | userId: string;
132 | offerId: string;
133 | }) => {
134 | let imageFileKey : string | null = null;
135 | if (offerData.image) {
136 | const { fileKey } = await context.remixService.aws.uploadFile({
137 | file: {
138 | size: offerData.image.size,
139 | mimetype: offerData.image.type,
140 | originalname: offerData.image.name,
141 | buffer: Buffer.from(await offerData.image.arrayBuffer()),
142 | },
143 | })
144 | imageFileKey = fileKey;
145 | }
146 | const updated = await context.remixService.prisma.offer.update({
147 | where: {
148 | id: offerId,
149 | userId,
150 | },
151 | data: {
152 | title: offerData.title,
153 | description: offerData.description,
154 | price: offerData.price,
155 | ...(imageFileKey && { imageFileKey }),
156 | user: {
157 | connect: {
158 | id: userId,
159 | },
160 | },
161 | active: offerData.active,
162 | },
163 | select: {
164 | id: true,
165 | },
166 | });
167 | await context.remixService.stripe.upsertOfferProduct(updated.id);
168 | return updated;
169 | };
170 |
171 | export const getUserWithAvatar = async ({
172 | userId,
173 | context,
174 | }: { context: AppLoadContext; userId: string }) => {
175 | const user = await context.remixService.prisma.user.findUnique({
176 | select: {
177 | id: true,
178 | email: true,
179 | name: true,
180 | avatarFileKey: true,
181 | },
182 | where: {
183 | id: userId,
184 | },
185 | });
186 |
187 | if (!user) {
188 | throw new Error("User not found");
189 | }
190 |
191 | let avatarUrl = "";
192 | if (user.avatarFileKey) {
193 | avatarUrl = await context.remixService.aws.getFileUrl({
194 | fileKey: user.avatarFileKey
195 | });
196 | }
197 |
198 | return {
199 | ...user,
200 | avatarUrl,
201 | };
202 | };
203 |
204 | export const editProfile = async ({
205 | context,
206 | profileData,
207 | userId,
208 | }: {
209 | context: AppLoadContext;
210 | profileData: z.infer;
211 | userId: string;
212 | }) => {
213 | let avatarFileKey: string | null = null;
214 | if (profileData.avatar) {
215 | const { fileKey } = await context.remixService.aws.uploadFile({
216 | file: {
217 | size: profileData.avatar.size,
218 | mimetype: profileData.avatar.type,
219 | originalname: profileData.avatar.name,
220 | buffer: Buffer.from(await profileData.avatar.arrayBuffer()),
221 | },
222 | });
223 | avatarFileKey = fileKey;
224 | }
225 |
226 | return await context.remixService.prisma.user.update({
227 | where: {
228 | id: userId,
229 | },
230 | data: {
231 | email: profileData.email,
232 | name: profileData.name,
233 | ...(avatarFileKey && { avatarFileKey }),
234 | },
235 | select: {
236 | id: true,
237 | },
238 | });
239 | };
240 |
--------------------------------------------------------------------------------
/frontend/app/server/providers.server.ts:
--------------------------------------------------------------------------------
1 | import { type AppLoadContext } from "@remix-run/node";
2 | import { getPagination } from "~/lib/utils";
3 |
4 | export type CertifiedProvider = {
5 | id: string;
6 | name: string | null;
7 | avatarUrl: string;
8 | counts: {
9 | offers: number;
10 | transactions: number;
11 | };
12 | };
13 |
14 | export const getCertifiedProviders = async ({
15 | context,
16 | page,
17 | perPage,
18 | }: {
19 | context: AppLoadContext;
20 | page?: number | string | null;
21 | perPage?: number | string | null;
22 | }) => {
23 | const { skip, take, page: currentPage, perPage: safePerPage } = getPagination({
24 | page,
25 | perPage,
26 | maxPerPage: 50,
27 | });
28 |
29 | const where = {
30 | chargesEnabled: true as const,
31 | payoutsEnabled: true as const,
32 | detailsSubmitted: true as const,
33 | };
34 |
35 | const [users, total] = await Promise.all([
36 | context.remixService.prisma.user.findMany({
37 | where,
38 | orderBy: { createdAt: "desc" },
39 | skip,
40 | take,
41 | select: {
42 | id: true,
43 | name: true,
44 | avatarFileKey: true,
45 | _count: {
46 | select: {
47 | offers: true,
48 | transactions: true,
49 | },
50 | },
51 | },
52 | }),
53 | context.remixService.prisma.user.count({ where }),
54 | ]);
55 |
56 | const items: CertifiedProvider[] = await Promise.all(
57 | users.map(async (u) => {
58 | let avatarUrl = "";
59 | if (u.avatarFileKey) {
60 | avatarUrl = await context.remixService.aws.getFileUrl({ fileKey: u.avatarFileKey });
61 | }
62 | return {
63 | id: u.id,
64 | name: u.name ?? null,
65 | avatarUrl,
66 | counts: {
67 | offers: u._count.offers,
68 | transactions: u._count.transactions,
69 | },
70 | };
71 | }),
72 | );
73 |
74 | const pageCount = Math.max(Math.ceil(total / safePerPage), 1);
75 |
76 | return {
77 | items,
78 | pageInfo: {
79 | page: currentPage,
80 | perPage: safePerPage,
81 | total,
82 | pageCount,
83 | hasPreviousPage: currentPage > 1,
84 | hasNextPage: currentPage < pageCount,
85 | },
86 | };
87 | };
88 |
89 |
90 |
--------------------------------------------------------------------------------
/frontend/app/server/stripe.server.ts:
--------------------------------------------------------------------------------
1 | import { type AppLoadContext } from "@remix-run/node";
2 |
3 | export const createOnboardingLink = async ({
4 | context,
5 | userId,
6 | requestUrl,
7 | }: {
8 | context: AppLoadContext;
9 | userId: string;
10 | requestUrl: string;
11 | }) => {
12 | const origin = new URL(requestUrl).origin;
13 | const refreshUrl = `${origin}/stripe/onboarding`;
14 | const returnUrl = `${origin}/stripe/return`;
15 |
16 | const url = await context.remixService.stripe.createAccountLink({
17 | userId,
18 | refreshUrl,
19 | returnUrl,
20 | });
21 | return url;
22 | };
23 |
24 | export const refreshAccountStatus = async ({
25 | context,
26 | userId,
27 | }: {
28 | context: AppLoadContext;
29 | userId: string;
30 | }) => {
31 | return await context.remixService.stripe.refreshAccountStatus(userId);
32 | };
33 |
34 | export const createDashboardLoginLink = async ({
35 | context,
36 | userId,
37 | requestUrl,
38 | }: {
39 | context: AppLoadContext;
40 | userId: string;
41 | requestUrl: string;
42 | }) => {
43 | const origin = new URL(requestUrl).origin;
44 | const redirectUrl = `${origin}/profile`;
45 | const url = await context.remixService.stripe.createDashboardLoginLink({
46 | userId,
47 | redirectUrl,
48 | });
49 | return url;
50 | };
51 |
52 | export const getConnectStatus = async ({
53 | context,
54 | userId,
55 | }: {
56 | context: AppLoadContext;
57 | userId: string;
58 | }) => {
59 | return await context.remixService.stripe.getConnectStatus(userId);
60 | };
61 |
62 | export const createCheckoutSessionForOffer = async ({
63 | context,
64 | offerId,
65 | buyerUserId,
66 | requestUrl,
67 | }: {
68 | context: AppLoadContext;
69 | offerId: string;
70 | buyerUserId: string;
71 | requestUrl: string;
72 | }) => {
73 | const origin = new URL(requestUrl).origin;
74 | // Include a redirect to the transaction after success, using session_id
75 | // We'll resolve session_id -> transaction on server webhook and optionally client can fetch by session_id
76 | const successUrl = `${origin}/transactions?session_id={CHECKOUT_SESSION_ID}`;
77 | const cancelUrl = `${origin}/offers/${encodeURIComponent(offerId)}`;
78 | return await context.remixService.stripe.createCheckoutSessionForOffer({
79 | offerId,
80 | buyerUserId,
81 | successUrl,
82 | cancelUrl,
83 | });
84 | };
--------------------------------------------------------------------------------
/frontend/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "default",
4 | "rsc": false,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.cjs",
8 | "css": "app/global.css",
9 | "baseColor": "slate",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "~/components",
15 | "utils": "~/lib/utils"
16 | }
17 | }
--------------------------------------------------------------------------------
/frontend/env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
--------------------------------------------------------------------------------
/frontend/index.cjs:
--------------------------------------------------------------------------------
1 | const path = require('node:path');
2 |
3 | let devServer;
4 | const SERVER_DIR = path.join(__dirname, 'build/server/index.js');
5 | const PUBLIC_DIR = path.join(__dirname, 'build/client');
6 |
7 | module.exports.getPublicDir = function getPublicDir() {
8 | return PUBLIC_DIR;
9 | };
10 |
11 | module.exports.getServerBuild = async function getServerBuild() {
12 | if (process.env.NODE_ENV === 'production' || devServer === null) {
13 | return import(SERVER_DIR);
14 | }
15 | const ssrModule = await devServer.ssrLoadModule('virtual:remix/server-build');
16 | return ssrModule;
17 | };
18 |
19 | module.exports.startDevServer = async function startDevServer(app) {
20 | if (process.env.NODE_ENV === 'production') return;
21 | const vite = await import('vite');
22 | devServer = await vite.createServer({
23 | server: { middlewareMode: 'true' },
24 | root: __dirname,
25 | });
26 |
27 | app.use(devServer.middlewares);
28 | return devServer;
29 | // ...continues
30 | };
31 |
--------------------------------------------------------------------------------
/frontend/index.d.cts:
--------------------------------------------------------------------------------
1 | declare module '@virgile/frontend' {
2 | export function getPublicDir(): string;
3 | export function getServerBuild(): Promise;
4 | export function startDevServer(app: any): Promise;
5 | }
6 |
--------------------------------------------------------------------------------
/frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@virgile/frontend",
3 | "private": true,
4 | "sideEffects": false,
5 | "type": "module",
6 | "main": "./index.cjs",
7 | "types": "./index.d.cts",
8 | "scripts": {
9 | "start": "remix-serve ./build/server/index.js",
10 | "old-dev": "remix vite:dev",
11 | "build": "remix vite:build",
12 | "lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .",
13 | "typecheck": "tsc"
14 | },
15 | "dependencies": {
16 | "@conform-to/react": "^1.1.0",
17 | "@conform-to/zod": "^1.1.0",
18 | "@radix-ui/react-checkbox": "^1.0.4",
19 | "@radix-ui/react-label": "^2.0.2",
20 | "@radix-ui/react-select": "^2.2.6",
21 | "@radix-ui/react-slot": "^1.0.2",
22 | "@remix-run/node": "^2.8.1",
23 | "@remix-run/react": "^2.8.1",
24 | "@remix-run/serve": "^2.8.1",
25 | "class-variance-authority": "^0.7.0",
26 | "clsx": "^2.1.0",
27 | "isbot": "^5.1.2",
28 | "lucide-react": "^0.365.0",
29 | "nuqs": "^2.6.0",
30 | "react": "^18.2.0",
31 | "react-dom": "^18.2.0",
32 | "remix-flat-routes": "^0.6.4",
33 | "tailwind-merge": "^2.2.2",
34 | "tailwindcss-animate": "^1.0.7",
35 | "zod": "^3.22.4"
36 | },
37 | "devDependencies": {
38 | "@remix-run/dev": "^2.8.1",
39 | "@types/react": "^18.2.67",
40 | "@types/react-dom": "^18.2.22",
41 | "@virgile/eslint-config": "*",
42 | "@virgile/typescript-config": "*",
43 | "autoprefixer": "^10.4.19",
44 | "eslint": "^8.57.0",
45 | "eslint-import-resolver-typescript": "^3.6.1",
46 | "eslint-plugin-import": "^2.29.1",
47 | "eslint-plugin-jsx-a11y": "^6.8.0",
48 | "eslint-plugin-react": "^7.34.1",
49 | "eslint-plugin-react-hooks": "^4.6.0",
50 | "eslint-plugin-remix-react-routes": "^1.0.5",
51 | "eslint-plugin-tailwindcss": "^3.15.1",
52 | "postcss": "^8.4.38",
53 | "tailwindcss": "^3.4.3",
54 | "typescript": "^5.4.3",
55 | "vite": "^5.2.2",
56 | "vite-tsconfig-paths": "^4.3.2"
57 | },
58 | "engines": {
59 | "node": ">=18.0.0"
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/frontend/postcss.config.cjs:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | /** @type {import('postcss-load-config').Config} */
3 | module.exports = {
4 | plugins: {
5 | tailwindcss: {
6 | config: path.resolve(__dirname, 'tailwind.config.cjs'),
7 | },
8 | autoprefixer: {},
9 | },
10 | };
11 |
--------------------------------------------------------------------------------
/frontend/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Varkoff/remix-nestjs-monorepo/de83000dd44bfb70ea687c440765f6964c128444/frontend/public/favicon.ico
--------------------------------------------------------------------------------
/frontend/tailwind.config.cjs:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | /** @type {import('tailwindcss').Config} */
3 | module.exports = {
4 | darkMode: ['class'],
5 | content: [path.join(__dirname, './app/**/*.{ts,tsx}')],
6 | prefix: '',
7 | theme: {
8 | container: {
9 | center: true,
10 | padding: '2rem',
11 | screens: {
12 | '2xl': '1400px',
13 | },
14 | },
15 | extend: {
16 | colors: {
17 | khmerCurry: '#ED5555',
18 | persianIndigo: '#350B60',
19 | vert: '#1FDC93',
20 | bleu: '#031754',
21 | bleuClair: '#D0EDFC',
22 | lightTurquoise: '#E2F2F1',
23 | extraLightTurquoise: '#F3F8F8',
24 | darkIron: '#B0B0B0',
25 | iron: '#EEEEEE',
26 | border: 'hsl(var(--border))',
27 | input: 'hsl(var(--input))',
28 | ring: 'hsl(var(--ring))',
29 | background: 'hsl(var(--background))',
30 | foreground: 'hsl(var(--foreground))',
31 | primary: {
32 | DEFAULT: 'hsl(var(--primary))',
33 | foreground: 'hsl(var(--primary-foreground))',
34 | },
35 | secondary: {
36 | DEFAULT: 'hsl(var(--secondary))',
37 | foreground: 'hsl(var(--secondary-foreground))',
38 | },
39 | destructive: {
40 | DEFAULT: 'hsl(var(--destructive))',
41 | foreground: 'hsl(var(--destructive-foreground))',
42 | },
43 | muted: {
44 | DEFAULT: 'hsl(var(--muted))',
45 | foreground: 'hsl(var(--muted-foreground))',
46 | },
47 | accent: {
48 | DEFAULT: 'hsl(var(--accent))',
49 | foreground: 'hsl(var(--accent-foreground))',
50 | },
51 | popover: {
52 | DEFAULT: 'hsl(var(--popover))',
53 | foreground: 'hsl(var(--popover-foreground))',
54 | },
55 | card: {
56 | DEFAULT: 'hsl(var(--card))',
57 | foreground: 'hsl(var(--card-foreground))',
58 | },
59 | },
60 | borderRadius: {
61 | lg: 'var(--radius)',
62 | md: 'calc(var(--radius) - 2px)',
63 | sm: 'calc(var(--radius) - 4px)',
64 | },
65 | keyframes: {
66 | 'accordion-down': {
67 | from: { height: '0' },
68 | to: { height: 'var(--radix-accordion-content-height)' },
69 | },
70 | 'accordion-up': {
71 | from: { height: 'var(--radix-accordion-content-height)' },
72 | to: { height: '0' },
73 | },
74 | },
75 | animation: {
76 | 'accordion-down': 'accordion-down 0.2s ease-out',
77 | 'accordion-up': 'accordion-up 0.2s ease-out',
78 | },
79 | },
80 | },
81 | plugins: [require('tailwindcss-animate')],
82 | };
83 |
--------------------------------------------------------------------------------
/frontend/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@virgile/typescript-config/base.json",
3 | "include": ["env.d.ts", "**/*.ts", "**/*.tsx", "../playwright.config.ts", "tailwind.config.cjs"],
4 | "exclude": ["index.d.cts"],
5 | "compilerOptions": {
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 | "lib": ["DOM", "DOM.Iterable", "ES2019"],
9 | "isolatedModules": true,
10 | "esModuleInterop": false,
11 | "jsx": "react-jsx",
12 | "noImplicitAny": false,
13 | "moduleResolution": "bundler",
14 | "resolveJsonModule": true,
15 | "target": "ES2019",
16 | "strict": true,
17 | "allowJs": true,
18 | "forceConsistentCasingInFileNames": true,
19 | "baseUrl": ".",
20 | "paths": {
21 | "~/*": ["./app/*"]
22 | },
23 | "noEmit": true
24 | },
25 |
26 | }
27 |
--------------------------------------------------------------------------------
/frontend/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { vitePlugin as remix } from '@remix-run/dev';
2 | import { installGlobals } from '@remix-run/node';
3 | import { resolve } from 'path';
4 | import { flatRoutes } from 'remix-flat-routes';
5 | import { defineConfig } from 'vite';
6 | import tsconfigPaths from 'vite-tsconfig-paths';
7 |
8 | const MODE = process.env.NODE_ENV;
9 | installGlobals();
10 |
11 | export default defineConfig({
12 | resolve: {
13 | preserveSymlinks: true,
14 | },
15 | build: {
16 | cssMinify: MODE === 'production',
17 | sourcemap: true,
18 | commonjsOptions: {
19 | include: [/frontend/, /backend/, /node_modules/],
20 | },
21 | },
22 | plugins: [
23 | // cjsInterop({
24 | // dependencies: ['remix-utils', 'is-ip', '@markdoc/markdoc'],
25 | // }),
26 | tsconfigPaths({}),
27 | remix({
28 | ignoredRouteFiles: ['**/*'],
29 | future: {
30 | v3_fetcherPersist: true,
31 | },
32 |
33 | // When running locally in development mode, we use the built in remix
34 | // server. This does not understand the vercel lambda module format,
35 | // so we default back to the standard build output.
36 | // ignoredRouteFiles: ['**/.*', '**/*.test.{js,jsx,ts,tsx}'],
37 | serverModuleFormat: 'esm',
38 |
39 | routes: async (defineRoutes) => {
40 | return flatRoutes("routes", defineRoutes, {
41 | ignoredRouteFiles: [
42 | ".*",
43 | "**/*.css",
44 | "**/*.test.{js,jsx,ts,tsx}",
45 | "**/__*.*",
46 | // This is for server-side utilities you want to colocate next to
47 | // your routes without making an additional directory.
48 | // If you need a route that includes "server" or "client" in the
49 | // filename, use the escape brackets like: my-route.[server].tsx
50 | // '**/*.server.*',
51 | // '**/*.client.*',
52 | ],
53 | // Since process.cwd() is the server directory, we need to resolve the path to remix project
54 | appDir: resolve(__dirname, "app"),
55 | });
56 | },
57 | }),
58 | ],
59 | });
60 |
--------------------------------------------------------------------------------
/frontend/vite.config.ts.timestamp-1751794841752-fdfb69122621c.mjs:
--------------------------------------------------------------------------------
1 | // ../frontend/vite.config.ts
2 | import { vitePlugin as remix } from "file:///Users/algomax/dev/courses/remix-nestjs-monorepo/node_modules/@remix-run/dev/dist/index.js";
3 | import { installGlobals } from "file:///Users/algomax/dev/courses/remix-nestjs-monorepo/node_modules/@remix-run/node/dist/index.js";
4 | import { resolve } from "path";
5 | import { flatRoutes } from "file:///Users/algomax/dev/courses/remix-nestjs-monorepo/node_modules/remix-flat-routes/dist/index.js";
6 | import { defineConfig } from "file:///Users/algomax/dev/courses/remix-nestjs-monorepo/node_modules/vite/dist/node/index.js";
7 | import tsconfigPaths from "file:///Users/algomax/dev/courses/remix-nestjs-monorepo/node_modules/vite-tsconfig-paths/dist/index.mjs";
8 | var __vite_injected_original_dirname = "/Users/algomax/dev/courses/remix-nestjs-monorepo/frontend";
9 | var MODE = process.env.NODE_ENV;
10 | installGlobals();
11 | var vite_config_default = defineConfig({
12 | resolve: {
13 | preserveSymlinks: true
14 | },
15 | build: {
16 | cssMinify: MODE === "production",
17 | sourcemap: true,
18 | commonjsOptions: {
19 | include: [/frontend/, /backend/, /node_modules/]
20 | }
21 | },
22 | plugins: [
23 | // cjsInterop({
24 | // dependencies: ['remix-utils', 'is-ip', '@markdoc/markdoc'],
25 | // }),
26 | tsconfigPaths({}),
27 | remix({
28 | ignoredRouteFiles: ["**/*"],
29 | future: {
30 | v3_fetcherPersist: true
31 | },
32 | // When running locally in development mode, we use the built in remix
33 | // server. This does not understand the vercel lambda module format,
34 | // so we default back to the standard build output.
35 | // ignoredRouteFiles: ['**/.*', '**/*.test.{js,jsx,ts,tsx}'],
36 | serverModuleFormat: "esm",
37 | routes: async (defineRoutes) => {
38 | return flatRoutes("routes", defineRoutes, {
39 | ignoredRouteFiles: [
40 | ".*",
41 | "**/*.css",
42 | "**/*.test.{js,jsx,ts,tsx}",
43 | "**/__*.*"
44 | // This is for server-side utilities you want to colocate next to
45 | // your routes without making an additional directory.
46 | // If you need a route that includes "server" or "client" in the
47 | // filename, use the escape brackets like: my-route.[server].tsx
48 | // '**/*.server.*',
49 | // '**/*.client.*',
50 | ],
51 | // Since process.cwd() is the server directory, we need to resolve the path to remix project
52 | appDir: resolve(__vite_injected_original_dirname, "app")
53 | });
54 | }
55 | })
56 | ]
57 | });
58 | export {
59 | vite_config_default as default
60 | };
61 | //# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsiLi4vZnJvbnRlbmQvdml0ZS5jb25maWcudHMiXSwKICAic291cmNlc0NvbnRlbnQiOiBbImNvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9kaXJuYW1lID0gXCIvVXNlcnMvYWxnb21heC9kZXYvY291cnNlcy9yZW1peC1uZXN0anMtbW9ub3JlcG8vZnJvbnRlbmRcIjtjb25zdCBfX3ZpdGVfaW5qZWN0ZWRfb3JpZ2luYWxfZmlsZW5hbWUgPSBcIi9Vc2Vycy9hbGdvbWF4L2Rldi9jb3Vyc2VzL3JlbWl4LW5lc3Rqcy1tb25vcmVwby9mcm9udGVuZC92aXRlLmNvbmZpZy50c1wiO2NvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9pbXBvcnRfbWV0YV91cmwgPSBcImZpbGU6Ly8vVXNlcnMvYWxnb21heC9kZXYvY291cnNlcy9yZW1peC1uZXN0anMtbW9ub3JlcG8vZnJvbnRlbmQvdml0ZS5jb25maWcudHNcIjtpbXBvcnQgeyB2aXRlUGx1Z2luIGFzIHJlbWl4IH0gZnJvbSAnQHJlbWl4LXJ1bi9kZXYnO1xuaW1wb3J0IHsgaW5zdGFsbEdsb2JhbHMgfSBmcm9tICdAcmVtaXgtcnVuL25vZGUnO1xuaW1wb3J0IHsgcmVzb2x2ZSB9IGZyb20gJ3BhdGgnO1xuaW1wb3J0IHsgZmxhdFJvdXRlcyB9IGZyb20gJ3JlbWl4LWZsYXQtcm91dGVzJztcbmltcG9ydCB7IGRlZmluZUNvbmZpZyB9IGZyb20gJ3ZpdGUnO1xuaW1wb3J0IHRzY29uZmlnUGF0aHMgZnJvbSAndml0ZS10c2NvbmZpZy1wYXRocyc7XG5cbmNvbnN0IE1PREUgPSBwcm9jZXNzLmVudi5OT0RFX0VOVjtcbmluc3RhbGxHbG9iYWxzKCk7XG5cbmV4cG9ydCBkZWZhdWx0IGRlZmluZUNvbmZpZyh7XG5cdHJlc29sdmU6IHtcblx0XHRwcmVzZXJ2ZVN5bWxpbmtzOiB0cnVlLFxuXHR9LFxuXHRidWlsZDoge1xuXHRcdGNzc01pbmlmeTogTU9ERSA9PT0gJ3Byb2R1Y3Rpb24nLFxuXHRcdHNvdXJjZW1hcDogdHJ1ZSxcblx0XHRjb21tb25qc09wdGlvbnM6IHtcblx0XHRcdGluY2x1ZGU6IFsvZnJvbnRlbmQvLCAvYmFja2VuZC8sIC9ub2RlX21vZHVsZXMvXSxcblx0XHR9LFxuXHR9LFxuXHRwbHVnaW5zOiBbXG5cdFx0Ly8gY2pzSW50ZXJvcCh7XG5cdFx0Ly8gXHRkZXBlbmRlbmNpZXM6IFsncmVtaXgtdXRpbHMnLCAnaXMtaXAnLCAnQG1hcmtkb2MvbWFya2RvYyddLFxuXHRcdC8vIH0pLFxuXHRcdHRzY29uZmlnUGF0aHMoe30pLFxuXHRcdHJlbWl4KHtcblx0XHRcdGlnbm9yZWRSb3V0ZUZpbGVzOiBbJyoqLyonXSxcblx0XHRcdGZ1dHVyZToge1xuXHRcdFx0XHR2M19mZXRjaGVyUGVyc2lzdDogdHJ1ZSxcblx0XHRcdH0sXG5cblx0XHRcdC8vIFdoZW4gcnVubmluZyBsb2NhbGx5IGluIGRldmVsb3BtZW50IG1vZGUsIHdlIHVzZSB0aGUgYnVpbHQgaW4gcmVtaXhcblx0XHRcdC8vIHNlcnZlci4gVGhpcyBkb2VzIG5vdCB1bmRlcnN0YW5kIHRoZSB2ZXJjZWwgbGFtYmRhIG1vZHVsZSBmb3JtYXQsXG5cdFx0XHQvLyBzbyB3ZSBkZWZhdWx0IGJhY2sgdG8gdGhlIHN0YW5kYXJkIGJ1aWxkIG91dHB1dC5cblx0XHRcdC8vIGlnbm9yZWRSb3V0ZUZpbGVzOiBbJyoqLy4qJywgJyoqLyoudGVzdC57anMsanN4LHRzLHRzeH0nXSxcblx0XHRcdHNlcnZlck1vZHVsZUZvcm1hdDogJ2VzbScsXG5cblx0XHRcdHJvdXRlczogYXN5bmMgKGRlZmluZVJvdXRlcykgPT4ge1xuXHRcdFx0XHRyZXR1cm4gZmxhdFJvdXRlcyhcInJvdXRlc1wiLCBkZWZpbmVSb3V0ZXMsIHtcblx0XHRcdFx0XHRpZ25vcmVkUm91dGVGaWxlczogW1xuXHRcdFx0XHRcdFx0XCIuKlwiLFxuXHRcdFx0XHRcdFx0XCIqKi8qLmNzc1wiLFxuXHRcdFx0XHRcdFx0XCIqKi8qLnRlc3Que2pzLGpzeCx0cyx0c3h9XCIsXG5cdFx0XHRcdFx0XHRcIioqL19fKi4qXCIsXG5cdFx0XHRcdFx0XHQvLyBUaGlzIGlzIGZvciBzZXJ2ZXItc2lkZSB1dGlsaXRpZXMgeW91IHdhbnQgdG8gY29sb2NhdGUgbmV4dCB0b1xuXHRcdFx0XHRcdFx0Ly8geW91ciByb3V0ZXMgd2l0aG91dCBtYWtpbmcgYW4gYWRkaXRpb25hbCBkaXJlY3RvcnkuXG5cdFx0XHRcdFx0XHQvLyBJZiB5b3UgbmVlZCBhIHJvdXRlIHRoYXQgaW5jbHVkZXMgXCJzZXJ2ZXJcIiBvciBcImNsaWVudFwiIGluIHRoZVxuXHRcdFx0XHRcdFx0Ly8gZmlsZW5hbWUsIHVzZSB0aGUgZXNjYXBlIGJyYWNrZXRzIGxpa2U6IG15LXJvdXRlLltzZXJ2ZXJdLnRzeFxuXHRcdFx0XHRcdFx0Ly8gXHQnKiovKi5zZXJ2ZXIuKicsXG5cdFx0XHRcdFx0XHQvLyBcdCcqKi8qLmNsaWVudC4qJyxcblx0XHRcdFx0XHRdLFxuXHRcdFx0XHRcdC8vIFNpbmNlIHByb2Nlc3MuY3dkKCkgaXMgdGhlIHNlcnZlciBkaXJlY3RvcnksIHdlIG5lZWQgdG8gcmVzb2x2ZSB0aGUgcGF0aCB0byByZW1peCBwcm9qZWN0XG5cdFx0XHRcdFx0YXBwRGlyOiByZXNvbHZlKF9fZGlybmFtZSwgXCJhcHBcIiksXG5cdFx0XHRcdH0pO1xuXHRcdFx0fSxcblx0XHR9KSxcblx0XSxcbn0pO1xuIl0sCiAgIm1hcHBpbmdzIjogIjtBQUE2VixTQUFTLGNBQWMsYUFBYTtBQUNqWSxTQUFTLHNCQUFzQjtBQUMvQixTQUFTLGVBQWU7QUFDeEIsU0FBUyxrQkFBa0I7QUFDM0IsU0FBUyxvQkFBb0I7QUFDN0IsT0FBTyxtQkFBbUI7QUFMMUIsSUFBTSxtQ0FBbUM7QUFPekMsSUFBTSxPQUFPLFFBQVEsSUFBSTtBQUN6QixlQUFlO0FBRWYsSUFBTyxzQkFBUSxhQUFhO0FBQUEsRUFDM0IsU0FBUztBQUFBLElBQ1Isa0JBQWtCO0FBQUEsRUFDbkI7QUFBQSxFQUNBLE9BQU87QUFBQSxJQUNOLFdBQVcsU0FBUztBQUFBLElBQ3BCLFdBQVc7QUFBQSxJQUNYLGlCQUFpQjtBQUFBLE1BQ2hCLFNBQVMsQ0FBQyxZQUFZLFdBQVcsY0FBYztBQUFBLElBQ2hEO0FBQUEsRUFDRDtBQUFBLEVBQ0EsU0FBUztBQUFBO0FBQUE7QUFBQTtBQUFBLElBSVIsY0FBYyxDQUFDLENBQUM7QUFBQSxJQUNoQixNQUFNO0FBQUEsTUFDTCxtQkFBbUIsQ0FBQyxNQUFNO0FBQUEsTUFDMUIsUUFBUTtBQUFBLFFBQ1AsbUJBQW1CO0FBQUEsTUFDcEI7QUFBQTtBQUFBO0FBQUE7QUFBQTtBQUFBLE1BTUEsb0JBQW9CO0FBQUEsTUFFcEIsUUFBUSxPQUFPLGlCQUFpQjtBQUMvQixlQUFPLFdBQVcsVUFBVSxjQUFjO0FBQUEsVUFDekMsbUJBQW1CO0FBQUEsWUFDbEI7QUFBQSxZQUNBO0FBQUEsWUFDQTtBQUFBLFlBQ0E7QUFBQTtBQUFBO0FBQUE7QUFBQTtBQUFBO0FBQUE7QUFBQSxVQU9EO0FBQUE7QUFBQSxVQUVBLFFBQVEsUUFBUSxrQ0FBVyxLQUFLO0FBQUEsUUFDakMsQ0FBQztBQUFBLE1BQ0Y7QUFBQSxJQUNELENBQUM7QUFBQSxFQUNGO0FBQ0QsQ0FBQzsiLAogICJuYW1lcyI6IFtdCn0K
62 |
--------------------------------------------------------------------------------
/frontend/vite.config.ts.timestamp-1758024100248-611b4875cc2f78.mjs:
--------------------------------------------------------------------------------
1 | // ../frontend/vite.config.ts
2 | import { vitePlugin as remix } from "file:///Users/algomax/dev/courses/remix-nestjs-monorepo/node_modules/@remix-run/dev/dist/index.js";
3 | import { installGlobals } from "file:///Users/algomax/dev/courses/remix-nestjs-monorepo/node_modules/@remix-run/node/dist/index.js";
4 | import { resolve } from "path";
5 | import { flatRoutes } from "file:///Users/algomax/dev/courses/remix-nestjs-monorepo/node_modules/remix-flat-routes/dist/index.js";
6 | import { defineConfig } from "file:///Users/algomax/dev/courses/remix-nestjs-monorepo/node_modules/vite/dist/node/index.js";
7 | import tsconfigPaths from "file:///Users/algomax/dev/courses/remix-nestjs-monorepo/node_modules/vite-tsconfig-paths/dist/index.mjs";
8 | var __vite_injected_original_dirname = "/Users/algomax/dev/courses/remix-nestjs-monorepo/frontend";
9 | var MODE = process.env.NODE_ENV;
10 | installGlobals();
11 | var vite_config_default = defineConfig({
12 | resolve: {
13 | preserveSymlinks: true
14 | },
15 | build: {
16 | cssMinify: MODE === "production",
17 | sourcemap: true,
18 | commonjsOptions: {
19 | include: [/frontend/, /backend/, /node_modules/]
20 | }
21 | },
22 | plugins: [
23 | // cjsInterop({
24 | // dependencies: ['remix-utils', 'is-ip', '@markdoc/markdoc'],
25 | // }),
26 | tsconfigPaths({}),
27 | remix({
28 | ignoredRouteFiles: ["**/*"],
29 | future: {
30 | v3_fetcherPersist: true
31 | },
32 | // When running locally in development mode, we use the built in remix
33 | // server. This does not understand the vercel lambda module format,
34 | // so we default back to the standard build output.
35 | // ignoredRouteFiles: ['**/.*', '**/*.test.{js,jsx,ts,tsx}'],
36 | serverModuleFormat: "esm",
37 | routes: async (defineRoutes) => {
38 | return flatRoutes("routes", defineRoutes, {
39 | ignoredRouteFiles: [
40 | ".*",
41 | "**/*.css",
42 | "**/*.test.{js,jsx,ts,tsx}",
43 | "**/__*.*"
44 | // This is for server-side utilities you want to colocate next to
45 | // your routes without making an additional directory.
46 | // If you need a route that includes "server" or "client" in the
47 | // filename, use the escape brackets like: my-route.[server].tsx
48 | // '**/*.server.*',
49 | // '**/*.client.*',
50 | ],
51 | // Since process.cwd() is the server directory, we need to resolve the path to remix project
52 | appDir: resolve(__vite_injected_original_dirname, "app")
53 | });
54 | }
55 | })
56 | ]
57 | });
58 | export {
59 | vite_config_default as default
60 | };
61 | //# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsiLi4vZnJvbnRlbmQvdml0ZS5jb25maWcudHMiXSwKICAic291cmNlc0NvbnRlbnQiOiBbImNvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9kaXJuYW1lID0gXCIvVXNlcnMvYWxnb21heC9kZXYvY291cnNlcy9yZW1peC1uZXN0anMtbW9ub3JlcG8vZnJvbnRlbmRcIjtjb25zdCBfX3ZpdGVfaW5qZWN0ZWRfb3JpZ2luYWxfZmlsZW5hbWUgPSBcIi9Vc2Vycy9hbGdvbWF4L2Rldi9jb3Vyc2VzL3JlbWl4LW5lc3Rqcy1tb25vcmVwby9mcm9udGVuZC92aXRlLmNvbmZpZy50c1wiO2NvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9pbXBvcnRfbWV0YV91cmwgPSBcImZpbGU6Ly8vVXNlcnMvYWxnb21heC9kZXYvY291cnNlcy9yZW1peC1uZXN0anMtbW9ub3JlcG8vZnJvbnRlbmQvdml0ZS5jb25maWcudHNcIjtpbXBvcnQgeyB2aXRlUGx1Z2luIGFzIHJlbWl4IH0gZnJvbSAnQHJlbWl4LXJ1bi9kZXYnO1xuaW1wb3J0IHsgaW5zdGFsbEdsb2JhbHMgfSBmcm9tICdAcmVtaXgtcnVuL25vZGUnO1xuaW1wb3J0IHsgcmVzb2x2ZSB9IGZyb20gJ3BhdGgnO1xuaW1wb3J0IHsgZmxhdFJvdXRlcyB9IGZyb20gJ3JlbWl4LWZsYXQtcm91dGVzJztcbmltcG9ydCB7IGRlZmluZUNvbmZpZyB9IGZyb20gJ3ZpdGUnO1xuaW1wb3J0IHRzY29uZmlnUGF0aHMgZnJvbSAndml0ZS10c2NvbmZpZy1wYXRocyc7XG5cbmNvbnN0IE1PREUgPSBwcm9jZXNzLmVudi5OT0RFX0VOVjtcbmluc3RhbGxHbG9iYWxzKCk7XG5cbmV4cG9ydCBkZWZhdWx0IGRlZmluZUNvbmZpZyh7XG5cdHJlc29sdmU6IHtcblx0XHRwcmVzZXJ2ZVN5bWxpbmtzOiB0cnVlLFxuXHR9LFxuXHRidWlsZDoge1xuXHRcdGNzc01pbmlmeTogTU9ERSA9PT0gJ3Byb2R1Y3Rpb24nLFxuXHRcdHNvdXJjZW1hcDogdHJ1ZSxcblx0XHRjb21tb25qc09wdGlvbnM6IHtcblx0XHRcdGluY2x1ZGU6IFsvZnJvbnRlbmQvLCAvYmFja2VuZC8sIC9ub2RlX21vZHVsZXMvXSxcblx0XHR9LFxuXHR9LFxuXHRwbHVnaW5zOiBbXG5cdFx0Ly8gY2pzSW50ZXJvcCh7XG5cdFx0Ly8gXHRkZXBlbmRlbmNpZXM6IFsncmVtaXgtdXRpbHMnLCAnaXMtaXAnLCAnQG1hcmtkb2MvbWFya2RvYyddLFxuXHRcdC8vIH0pLFxuXHRcdHRzY29uZmlnUGF0aHMoe30pLFxuXHRcdHJlbWl4KHtcblx0XHRcdGlnbm9yZWRSb3V0ZUZpbGVzOiBbJyoqLyonXSxcblx0XHRcdGZ1dHVyZToge1xuXHRcdFx0XHR2M19mZXRjaGVyUGVyc2lzdDogdHJ1ZSxcblx0XHRcdH0sXG5cblx0XHRcdC8vIFdoZW4gcnVubmluZyBsb2NhbGx5IGluIGRldmVsb3BtZW50IG1vZGUsIHdlIHVzZSB0aGUgYnVpbHQgaW4gcmVtaXhcblx0XHRcdC8vIHNlcnZlci4gVGhpcyBkb2VzIG5vdCB1bmRlcnN0YW5kIHRoZSB2ZXJjZWwgbGFtYmRhIG1vZHVsZSBmb3JtYXQsXG5cdFx0XHQvLyBzbyB3ZSBkZWZhdWx0IGJhY2sgdG8gdGhlIHN0YW5kYXJkIGJ1aWxkIG91dHB1dC5cblx0XHRcdC8vIGlnbm9yZWRSb3V0ZUZpbGVzOiBbJyoqLy4qJywgJyoqLyoudGVzdC57anMsanN4LHRzLHRzeH0nXSxcblx0XHRcdHNlcnZlck1vZHVsZUZvcm1hdDogJ2VzbScsXG5cblx0XHRcdHJvdXRlczogYXN5bmMgKGRlZmluZVJvdXRlcykgPT4ge1xuXHRcdFx0XHRyZXR1cm4gZmxhdFJvdXRlcyhcInJvdXRlc1wiLCBkZWZpbmVSb3V0ZXMsIHtcblx0XHRcdFx0XHRpZ25vcmVkUm91dGVGaWxlczogW1xuXHRcdFx0XHRcdFx0XCIuKlwiLFxuXHRcdFx0XHRcdFx0XCIqKi8qLmNzc1wiLFxuXHRcdFx0XHRcdFx0XCIqKi8qLnRlc3Que2pzLGpzeCx0cyx0c3h9XCIsXG5cdFx0XHRcdFx0XHRcIioqL19fKi4qXCIsXG5cdFx0XHRcdFx0XHQvLyBUaGlzIGlzIGZvciBzZXJ2ZXItc2lkZSB1dGlsaXRpZXMgeW91IHdhbnQgdG8gY29sb2NhdGUgbmV4dCB0b1xuXHRcdFx0XHRcdFx0Ly8geW91ciByb3V0ZXMgd2l0aG91dCBtYWtpbmcgYW4gYWRkaXRpb25hbCBkaXJlY3RvcnkuXG5cdFx0XHRcdFx0XHQvLyBJZiB5b3UgbmVlZCBhIHJvdXRlIHRoYXQgaW5jbHVkZXMgXCJzZXJ2ZXJcIiBvciBcImNsaWVudFwiIGluIHRoZVxuXHRcdFx0XHRcdFx0Ly8gZmlsZW5hbWUsIHVzZSB0aGUgZXNjYXBlIGJyYWNrZXRzIGxpa2U6IG15LXJvdXRlLltzZXJ2ZXJdLnRzeFxuXHRcdFx0XHRcdFx0Ly8gXHQnKiovKi5zZXJ2ZXIuKicsXG5cdFx0XHRcdFx0XHQvLyBcdCcqKi8qLmNsaWVudC4qJyxcblx0XHRcdFx0XHRdLFxuXHRcdFx0XHRcdC8vIFNpbmNlIHByb2Nlc3MuY3dkKCkgaXMgdGhlIHNlcnZlciBkaXJlY3RvcnksIHdlIG5lZWQgdG8gcmVzb2x2ZSB0aGUgcGF0aCB0byByZW1peCBwcm9qZWN0XG5cdFx0XHRcdFx0YXBwRGlyOiByZXNvbHZlKF9fZGlybmFtZSwgXCJhcHBcIiksXG5cdFx0XHRcdH0pO1xuXHRcdFx0fSxcblx0XHR9KSxcblx0XSxcbn0pO1xuIl0sCiAgIm1hcHBpbmdzIjogIjtBQUE2VixTQUFTLGNBQWMsYUFBYTtBQUNqWSxTQUFTLHNCQUFzQjtBQUMvQixTQUFTLGVBQWU7QUFDeEIsU0FBUyxrQkFBa0I7QUFDM0IsU0FBUyxvQkFBb0I7QUFDN0IsT0FBTyxtQkFBbUI7QUFMMUIsSUFBTSxtQ0FBbUM7QUFPekMsSUFBTSxPQUFPLFFBQVEsSUFBSTtBQUN6QixlQUFlO0FBRWYsSUFBTyxzQkFBUSxhQUFhO0FBQUEsRUFDM0IsU0FBUztBQUFBLElBQ1Isa0JBQWtCO0FBQUEsRUFDbkI7QUFBQSxFQUNBLE9BQU87QUFBQSxJQUNOLFdBQVcsU0FBUztBQUFBLElBQ3BCLFdBQVc7QUFBQSxJQUNYLGlCQUFpQjtBQUFBLE1BQ2hCLFNBQVMsQ0FBQyxZQUFZLFdBQVcsY0FBYztBQUFBLElBQ2hEO0FBQUEsRUFDRDtBQUFBLEVBQ0EsU0FBUztBQUFBO0FBQUE7QUFBQTtBQUFBLElBSVIsY0FBYyxDQUFDLENBQUM7QUFBQSxJQUNoQixNQUFNO0FBQUEsTUFDTCxtQkFBbUIsQ0FBQyxNQUFNO0FBQUEsTUFDMUIsUUFBUTtBQUFBLFFBQ1AsbUJBQW1CO0FBQUEsTUFDcEI7QUFBQTtBQUFBO0FBQUE7QUFBQTtBQUFBLE1BTUEsb0JBQW9CO0FBQUEsTUFFcEIsUUFBUSxPQUFPLGlCQUFpQjtBQUMvQixlQUFPLFdBQVcsVUFBVSxjQUFjO0FBQUEsVUFDekMsbUJBQW1CO0FBQUEsWUFDbEI7QUFBQSxZQUNBO0FBQUEsWUFDQTtBQUFBLFlBQ0E7QUFBQTtBQUFBO0FBQUE7QUFBQTtBQUFBO0FBQUE7QUFBQSxVQU9EO0FBQUE7QUFBQSxVQUVBLFFBQVEsUUFBUSxrQ0FBVyxLQUFLO0FBQUEsUUFDakMsQ0FBQztBQUFBLE1BQ0Y7QUFBQSxJQUNELENBQUM7QUFBQSxFQUNGO0FBQ0QsQ0FBQzsiLAogICJuYW1lcyI6IFtdCn0K
62 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "nestjs-remix-monorepo",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "scripts": {
7 | "test": "echo \"Error: no test specified\" && exit 1",
8 | "dev": "NODE_ENV=development turbo dev",
9 | "lint": "turbo lint",
10 | "build": "turbo build",
11 | "typecheck": "turbo typecheck",
12 | "start": "cd backend && npm run start",
13 | "seed:offers": "cd backend && npm run seed:offers",
14 | "clean-node-modules": "rm -rf {node_modules,package-lock.json} **/{node_modules,package-lock.json}",
15 | "clean-turbo-cache": "rm -rf .turbo **/.turbo"
16 | },
17 | "keywords": [],
18 | "author": "",
19 | "license": "ISC",
20 | "packageManager": "npm@10.2.3",
21 | "workspaces": [
22 | "backend",
23 | "frontend",
24 | "packages/*"
25 | ],
26 | "devDependencies": {
27 | "turbo": "^1.13.0"
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/packages/eslint-config/base.js:
--------------------------------------------------------------------------------
1 | /** @type {import('@types/eslint').Linter.BaseConfig} */
2 | module.exports = {
3 | extends: [
4 | "@remix-run/eslint-config",
5 | "@remix-run/eslint-config/node",
6 | "prettier",
7 | "plugin:import/recommended",
8 | ],
9 | parser: "@typescript-eslint/parser",
10 | rules: {
11 | "import/no-duplicates": ["warn", { "prefer-inline": true }],
12 | "import/consistent-type-specifier-style": ["warn", "prefer-inline"],
13 | "import/order": [
14 | "warn",
15 | {
16 | alphabetize: { order: "asc", caseInsensitive: true },
17 | groups: [
18 | "builtin",
19 | "external",
20 | "internal",
21 | "parent",
22 | "sibling",
23 | "index",
24 | ],
25 | },
26 | ],
27 | },
28 | };
29 |
--------------------------------------------------------------------------------
/packages/eslint-config/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@virgile/eslint-config",
3 | "version": "0.0.0",
4 | "private": true,
5 | "license": "MIT",
6 | "files": [
7 | "base.js"
8 | ],
9 | "devDependencies": {
10 | "@remix-run/eslint-config": "^2.8.1",
11 | "@types/eslint": "^8.56.5",
12 | "@typescript-eslint/eslint-plugin": "^7.2.0",
13 | "@typescript-eslint/parser": "^7.2.0",
14 | "eslint": "^8.57.0",
15 | "eslint-config-prettier": "^9.1.0",
16 | "eslint-plugin-import": "^2.29.1",
17 | "eslint-plugin-prettier": "^5.1.3",
18 | "typescript": "^5.4.2"
19 | },
20 | "prettier": {},
21 | "scripts": {
22 | "lint:parent": "cd ../../ && npm run lint"
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/packages/typescript-config/base.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "module": "CommonJS",
4 | "declaration": true,
5 | "removeComments": true,
6 | "allowSyntheticDefaultImports": true,
7 | "target": "ES2022",
8 | "sourceMap": true,
9 | "strict": true,
10 | "skipLibCheck": true,
11 | "forceConsistentCasingInFileNames": true,
12 | "esModuleInterop": true,
13 | "isolatedModules": false,
14 | "moduleResolution": "Node",
15 | "resolveJsonModule": false,
16 | "allowJs": false,
17 | "lib": ["ES2023"]
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/packages/typescript-config/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@virgile/typescript-config",
3 | "version": "0.0.0",
4 | "private": true,
5 | "license": "MIT",
6 | "publishConfig": {
7 | "access": "public"
8 | },
9 | "prettier": {}
10 | }
11 |
--------------------------------------------------------------------------------
/turbo.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://turbo.build/schema.json",
3 | "pipeline": {
4 | "dev": {
5 | "cache": false,
6 | "persistent": true
7 | },
8 | "build": {
9 | "outputs": ["backend/dist/**", "frontend/build/**"],
10 | "dependsOn": ["^build"]
11 | },
12 | "typecheck": {},
13 | "lint": {
14 | "cache": false
15 | }
16 | }
17 | }
18 |
--------------------------------------------------------------------------------