diff --git a/.codex b/.codex new file mode 100644 index 0000000..e69de29 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ba0d71e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +node_modules +.turbo +.git +.gitignore +.codex +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +.next +dist +coverage +tmp +.env.local +.env.*.local + diff --git a/.env.docker.dev b/.env.docker.dev new file mode 100644 index 0000000..89bfd92 --- /dev/null +++ b/.env.docker.dev @@ -0,0 +1,37 @@ +DATABASE_URL=postgresql://postgres:password@postgres:5432/rentaldrivego +REDIS_URL=redis://redis:6379 +API_PORT=4000 +API_URL=http://localhost:4000 +API_INTERNAL_URL=http://api:4000/api/v1 +NEXT_PUBLIC_API_URL=http://localhost:4000/api/v1 +NEXT_PUBLIC_MARKETING_URL=http://localhost:3000 +NEXT_PUBLIC_MARKETPLACE_URL=http://localhost:3000/explore +NEXT_PUBLIC_DASHBOARD_URL=http://localhost:3001 +NEXT_PUBLIC_ADMIN_URL=http://localhost:3002 +NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=localhost:3003 +DASHBOARD_URL=http://localhost:3001 +JWT_SECRET=dev-secret +JWT_EXPIRY=8h +RENTER_JWT_EXPIRY=7d +ADMIN_SEED_EMAIL=admin@rentaldrivego.com +ADMIN_SEED_PASSWORD=changeme123 +ADMIN_SEED_FIRST_NAME=Platform +ADMIN_SEED_LAST_NAME=Admin +NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= +CLERK_SECRET_KEY= +NODE_ENV=development +CORS_ORIGINS=http://localhost:3000,http://localhost:3001,http://localhost:3002,http://localhost:3003 + +# Email - disable Resend (placeholder), use SMTP instead +RESEND_API_KEY=re_... +EMAIL_FROM=rentaldrivego@gmail.com +EMAIL_FROM_NAME=RentalDriveGo +MAIL_HOST=smtp.gmail.com +MAIL_PORT=587 +MAIL_SCHEME=smtp +MAIL_USERNAME=rentaldrivego@gmail.com +MAIL_PASSWORD=xmhg ibqy muoc rntc +MAIL_FROM_ADDRESS=rentaldrivego@gmail.com +MAIL_FROM_NAME=RentalDriveGo +MAIL_REPLY_TO_ADDRESS=rentaldrivego@gmail.com +MAIL_REPLY_TO_NAME=RentalDriveGo diff --git a/.env.docker.production.example b/.env.docker.production.example new file mode 100644 index 0000000..4c73881 --- /dev/null +++ b/.env.docker.production.example @@ -0,0 +1,66 @@ +# ── Traefik domains — used in docker-compose labels ────────────────────────── +ACME_EMAIL=rentaldrivego@gmail.com +MARKETPLACE_DOMAIN=rentaldrivego.ma +API_DOMAIN=api.rentaldrivego.ma +DASHBOARD_DOMAIN=dashboard.rentaldrivego.ma +ADMIN_DOMAIN=admin.rentaldrivego.ma +PUBLIC_SITE_DOMAIN=rentaldrivego.ma +PGMANAGE_DOMAIN=pgmanage.rentaldrivego.ma + +DATABASE_URL=postgresql://postgres:change-me@postgres:5432/rentaldrivego +REDIS_URL=redis://redis:6379 + +# ── API ──────────────────────────────────────────────────────────────────────── +API_PORT=4000 +# SSR server-side calls go via the internal Docker network +API_INTERNAL_URL=http://api:4000/api/v1 +# Public-facing API URL visible to browsers — MUST be your real domain, not localhost +API_URL=https://api.rentaldrivego.ma +# Baked into Next.js bundles at build time — MUST be set before running `npm run build` +NEXT_PUBLIC_API_URL=https://api.rentaldrivego.ma/api/v1 + +# ── Frontend public URLs ─────────────────────────────────────────────────────── +NEXT_PUBLIC_MARKETING_URL=https://rentaldrivego.ma +NEXT_PUBLIC_MARKETPLACE_URL=https://rentaldrivego.ma/explore +NEXT_PUBLIC_DASHBOARD_URL=https://dashboard.rentaldrivego.ma +NEXT_PUBLIC_ADMIN_URL=https://admin.rentaldrivego.ma +NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=rentaldrivego.ma +DASHBOARD_URL=https://dashboard.rentaldrivego.ma + +# ── CORS ────────────────────────────────────────────────────────────────────── +# Comma-separated list of allowed browser origins. REQUIRED in production. +CORS_ORIGINS=https://rentaldrivego.ma,https://dashboard.rentaldrivego.ma,https://admin.rentaldrivego.ma + +# ── Auth ────────────────────────────────────────────────────────────────────── +JWT_SECRET=PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK +JWT_EXPIRY=8h +RENTER_JWT_EXPIRY=7d + +# ── Database ────────────────────────────────────────────────────────────────── +POSTGRES_PASSWORD=24DY@1u5FLCkNMeiO@p +NODE_ENV=production + +# ── Email (choose one: Resend API or SMTP) ──────────────────────────────────── +# Option A — Resend +RESEND_API_KEY=C8qPDuFwsv5l@KsGhL/V +EMAIL_FROM=noreply@rentaldrivego.ma +EMAIL_FROM_NAME=RentalDriveGo +# Option B — SMTP +# MAIL_HOST=smtp.rentaldrivego.ma +# MAIL_PORT=587 +# MAIL_USERNAME=smtp-user +# MAIL_PASSWORD=smtp-password +# MAIL_SCHEME=smtp +# MAIL_FROM_ADDRESS=noreply@rentaldrivego.ma +# MAIL_FROM_NAME=RentalDriveGo + +# ── Firebase push notifications (optional) ──────────────────────────────────── +# FIREBASE_PROJECT_ID=your-firebase-project-id +# FIREBASE_CLIENT_EMAIL=firebase-adminsdk@your-project.iam.gserviceaccount.com +# FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n" + +# ── Twilio SMS / WhatsApp (optional) ───────────────────────────────────────── +# TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +# TWILIO_AUTH_TOKEN=your_auth_token +# TWILIO_PHONE_NUMBER=+1234567890 +# TWILIO_WHATSAPP_NUMBER=+1234567890 diff --git a/.env.docker.test b/.env.docker.test new file mode 100644 index 0000000..5a91bd4 --- /dev/null +++ b/.env.docker.test @@ -0,0 +1,10 @@ +DATABASE_URL=postgresql://postgres:password@postgres:5432/rentaldrivego_test +REDIS_URL=redis://redis:6379 +API_PORT=4000 +API_URL=http://localhost:4000 +API_INTERNAL_URL=http://localhost:4000/api/v1 +NEXT_PUBLIC_API_URL=http://localhost:4000/api/v1 +JWT_SECRET=test-secret +JWT_EXPIRY=8h +RENTER_JWT_EXPIRY=7d +NODE_ENV=test diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..704be9d --- /dev/null +++ b/.env.example @@ -0,0 +1,92 @@ +# ═══════════════════════════════════════════════════════════════ +# RentalDriveGo — Environment Variables +# Copy to .env.local and fill in your values. +# ═══════════════════════════════════════════════════════════════ + +# ─── Database ────────────────────────────────────────────────── +DATABASE_URL="postgresql://postgres:password@localhost:5432/rentaldrivego" + +# ─── API ─────────────────────────────────────────────────────── +API_PORT=4000 +API_URL=http://localhost:4000 +NEXT_PUBLIC_API_URL=http://localhost:4000/api/v1 + +# ─── JWT (Renter auth + Admin auth) ─────────────────────────── +JWT_SECRET=your-super-secret-jwt-key-change-in-production +JWT_EXPIRY=8h +RENTER_JWT_EXPIRY=7d + +# ─── Clerk (Company employee auth) ──────────────────────────── +NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_... +CLERK_SECRET_KEY=sk_test_... +CLERK_WEBHOOK_SECRET=whsec_... +NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in +NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up +NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/dashboard +NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/onboarding + +# ─── AmanPay (Primary payment provider) ─────────────────────── +# RentalDriveGo's own AmanPay account (for collecting subscription fees) +AMANPAY_MERCHANT_ID=your-amanpay-merchant-id +AMANPAY_SECRET_KEY=your-amanpay-secret-key +AMANPAY_BASE_URL=https://api.amanpay.net +AMANPAY_WEBHOOK_SECRET=your-amanpay-webhook-secret + +# ─── PayPal (Secondary payment provider) ────────────────────── +# RentalDriveGo's own PayPal account (for collecting subscription fees) +PAYPAL_CLIENT_ID=your-paypal-client-id +PAYPAL_CLIENT_SECRET=your-paypal-client-secret +PAYPAL_BASE_URL=https://api-m.paypal.com +# Use https://api-m.sandbox.paypal.com for sandbox +NEXT_PUBLIC_PAYPAL_CLIENT_ID=your-paypal-client-id + +# ─── Cloudinary (Vehicle + brand photos) ────────────────────── +CLOUDINARY_CLOUD_NAME=your-cloud-name +CLOUDINARY_API_KEY=your-api-key +CLOUDINARY_API_SECRET=your-api-secret +NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME=your-cloud-name + +# ─── Resend (Email) ──────────────────────────────────────────── +RESEND_API_KEY=re_... +EMAIL_FROM=noreply@rentaldrivego.com +EMAIL_FROM_NAME=RentalDriveGo + +# ─── Twilio (SMS + WhatsApp) ─────────────────────────────────── +TWILIO_ACCOUNT_SID=AC... +TWILIO_AUTH_TOKEN=your-twilio-auth-token +TWILIO_PHONE_NUMBER=+1234567890 +TWILIO_WHATSAPP_NUMBER=whatsapp:+14155238886 + +# ─── Firebase (Push notifications) ─────────────────────────── +FIREBASE_PROJECT_ID=your-project-id +FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n" +FIREBASE_CLIENT_EMAIL=firebase-adminsdk@your-project.iam.gserviceaccount.com +NEXT_PUBLIC_FIREBASE_API_KEY=your-firebase-api-key +NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=your-project.firebaseapp.com +NEXT_PUBLIC_FIREBASE_PROJECT_ID=your-project-id +NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=123456789 +NEXT_PUBLIC_FIREBASE_APP_ID=1:123456789:web:abc123 + +# The Node API uses Resend for email delivery. +# MAIL_* SMTP variables are intentionally omitted here. + +# ─── Redis (Real-time / Socket.io) ──────────────────────────── +REDIS_URL=redis://localhost:6379 + +# ─── App URLs ────────────────────────────────────────────────── +NEXT_PUBLIC_MARKETING_URL=http://localhost:3000 +NEXT_PUBLIC_DASHBOARD_URL=http://localhost:3001 +NEXT_PUBLIC_ADMIN_URL=http://localhost:3002 +NEXT_PUBLIC_MARKETPLACE_URL=http://localhost:3000/explore +# Public site is subdomain-based; use this for local dev: +NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=localhost:3003 +DASHBOARD_URL=http://localhost:3001 + +# ─── Admin seed (first SUPER_ADMIN created on db:seed) ──────── +ADMIN_SEED_EMAIL=admin@rentaldrivego.com +ADMIN_SEED_PASSWORD=changeme123 +ADMIN_SEED_FIRST_NAME=Super +ADMIN_SEED_LAST_NAME=Admin + +# ─── Misc ────────────────────────────────────────────────────── +NODE_ENV=development diff --git a/.env_traefik b/.env_traefik new file mode 100644 index 0000000..94c0f4d --- /dev/null +++ b/.env_traefik @@ -0,0 +1,3 @@ +ACME_EMAIL=rentaldrivego@gmail.com +TRAEFIK_DOMAIN=rentaldrivego.ma +TRAEFIK_DOMAIN_WWW=www.rentaldrivego.ma \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..15a7330 --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# Dependencies +node_modules/ +.pnp +.pnp.js + +# Build outputs +dist/ +.next/ +out/ + +# Environment variables +.env +.env.local +.env.*.local + +# Turbo +.turbo + +# Prisma +packages/database/generated/ + +# Misc +.DS_Store +*.pem +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Vercel +.vercel + +# TypeScript +*.tsbuildinfo diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..e735082 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,28 @@ +stages: + - build + - deploy + +variables: + DOCKER_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA + +build: + stage: build + image: docker:24 + services: + - docker:dind + script: + - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY + - docker build -t $DOCKER_IMAGE . + - docker push $DOCKER_IMAGE + only: + - main + +deploy: + stage: deploy + before_script: + - apk add --no-cache openssh-client + script: + - ssh $VPS_USER@$VPS_IP "docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY" + - ssh $VPS_USER@$VPS_IP "docker pull $DOCKER_IMAGE" + - ssh $VPS_USER@$VPS_IP "docker stop myapp || true" + - ssh $VPS_USER@$VPS_IP "docker run -d --name myapp -p 80:3000 $DOCKER_IMAGE" \ No newline at end of file diff --git a/DOCKER.md b/DOCKER.md new file mode 100644 index 0000000..b3ddfa9 --- /dev/null +++ b/DOCKER.md @@ -0,0 +1,248 @@ +## Docker Environments + +Three Docker environments are available: + +- `Dockerfile.dev` with `docker-compose.dev.yml` +- `Dockerfile.test` with `docker-compose.test.yml` +- `Dockerfile.production` with `docker-compose.production.yml` +- `docker-compose.pgmanage.yml` for a standalone pgManage container + +### Development + +Use the full dev stack for local work with hot reload and bundled Postgres and Redis: + +```bash +docker compose -f docker-compose.dev.yml --profile full up --build +``` + +Services: + +- marketplace: `http://localhost:3000` +- dashboard: `http://localhost:3001` +- admin: `http://localhost:3002` +- public-site: `http://localhost:3003` +- api: `http://localhost:4000` +- pgAdmin: `http://localhost:5050` + +Each dev app now runs in its own container and can be started independently with a profile tag: + +```bash +docker compose -f docker-compose.dev.yml --profile api up --build +docker compose -f docker-compose.dev.yml --profile marketplace up --build +docker compose -f docker-compose.dev.yml --profile dashboard up --build +docker compose -f docker-compose.dev.yml --profile admin up --build +docker compose -f docker-compose.dev.yml --profile public-site up --build +docker compose -f docker-compose.dev.yml --profile tools up --build +``` + +Notes: + +- `api` starts `postgres`, `redis`, and `migrate` automatically through dependencies. +- frontend profiles also start `api` and its dependencies automatically. +- `tools` starts only `pgadmin` plus its required `postgres` dependency. + +On startup, Docker now waits for Postgres to become healthy, runs a one-shot `migrate` service, and only then starts the selected app container. For development, that bootstrap runs `db:generate` every time, but `db:deploy` and `db:seed` only the first time for a persisted dev database, so your local data survives rebuilds and normal restarts. + +Default dev platform administrator: + +- email: `admin@rentaldrivego.com` +- password: `changeme123` + +If you intentionally want a fresh dev bootstrap: + +```bash +docker compose -f docker-compose.dev.yml down -v +``` + +If you want to keep the database and only apply new schema changes manually: + +```bash +docker compose -f docker-compose.dev.yml run --rm migrate sh -c "npm run db:deploy" +``` + +pgAdmin dev login: + +- email: `admin@rentaldrivego.local` +- email: `admin@rentaldrivego.dev` +- password: `admin` + +pgAdmin opens with the dev Postgres server pre-registered as `RentalDriveGo Dev DB`. + +pgAdmin Postgres connection: + +- host: `postgres` +- port: `5432` +- database: `rentaldrivego` +- username: `postgres` +- password: `password` + +### Standalone pgManage + +If you want a standalone Postgres management UI without starting the full development stack: + +```bash +docker compose -f docker-compose.pgmanage.yml up -d +``` + +It publishes `http://localhost:8000` with a standard Docker port mapping and persists its data in the named Docker volume `pgmanage_data`. +From inside the container, connect to the local Postgres service through `host.docker.internal:5432`. + +### Test + +Use the test stack to run repeatable containerized verification: + +```bash +docker compose -f docker-compose.test.yml up --build --abort-on-container-exit +``` + +The test container runs: + +- `npm run db:deploy` +- `npm run db:generate` +- `npm run type-check` +- `npm run build` + +### Production + +The production stack runs behind **Traefik** (reverse proxy + automatic HTTPS via Let's Encrypt). All services communicate over a private Docker network (`internal`). Traefik reaches public-facing services via a separate `traefik-proxy` network. + +#### 1. Point DNS to your server + +Add an A record for every subdomain to your server's public IP before deploying so Let's Encrypt can issue certificates: + +| Subdomain | Service | +|---|---| +| `rentaldrivego.ma` | marketplace + public site | +| `api.rentaldrivego.ma` | API | +| `dashboard.rentaldrivego.ma` | dashboard | +| `admin.rentaldrivego.ma` | admin panel | +| `pgmanage.rentaldrivego.ma` | pgManage (DB admin) | + +#### 2. Install Docker and clone the repo + +```bash +# Install Docker (if not already installed) +curl -fsSL https://get.docker.com | sh + +git clone rentaldrivego +cd rentaldrivego +``` + +#### 3. Create the shared Traefik network + +Only needs to be done once per server. If it already exists this is a no-op. + +```bash +docker network create traefik-proxy +``` + +#### 4. Configure environment variables + +```bash +cp .env.docker.production.example .env.docker.production +``` + +Open `.env.docker.production` and fill in every value. The minimum required secrets are: + +| Variable | What to set | +|---|---| +| `POSTGRES_PASSWORD` | Strong random password | +| `JWT_SECRET` | Long random string (e.g. `openssl rand -hex 64`) | +| `ACME_EMAIL` | Your email for Let's Encrypt notifications | +| `RESEND_API_KEY` | Resend API key (or configure SMTP vars instead) | + +All domain vars are pre-filled with `rentaldrivego.ma` subdomains and do not need changing unless you use a different domain. + +#### 5. Start Traefik + +Traefik must be running before the app stack so it can wire up routes at startup. + +```bash +docker compose -f traefik.yaml up -d +``` + +#### 6. Build and start the app stack + +```bash +docker compose -f docker-compose.production.yml up --build -d +``` + +Docker will: +1. Build the monorepo image +2. Run database migrations (`migrate` service) +3. Start all app services (api, marketplace, dashboard, admin, public-site, pgmanage) + +Traefik automatically picks up the containers and provisions TLS certificates. Services are live at their `https://` URLs within ~30 seconds. + +#### Updating after a code change + +Pull the latest code and rebuild only the changed service: + +```bash +git pull +docker compose -f docker-compose.production.yml up --build -d --no-deps +# e.g. to redeploy only the API: +docker compose -f docker-compose.production.yml up --build -d --no-deps api +``` + +To rebuild everything: + +```bash +docker compose -f docker-compose.production.yml up --build -d +``` + +#### Apply database migrations without downtime + +```bash +docker compose -f docker-compose.production.yml run --rm migrate +``` + +#### View logs + +```bash +# All services +docker compose -f docker-compose.production.yml logs -f + +# Single service +docker compose -f docker-compose.production.yml logs -f api +``` + +#### Stop the stack + +```bash +# Stop containers but keep volumes (data is preserved) +docker compose -f docker-compose.production.yml down + +# Stop and delete all data (destructive — irreversible) +docker compose -f docker-compose.production.yml down -v +``` + +#### pgManage (DB admin UI) + +pgManage is available at `https://pgmanage.rentaldrivego.ma`. To connect to the production database, add a connection inside pgManage with: + +- **Host:** `localhost` +- **Port:** `5432` +- **Database:** `rentaldrivego` +- **Username:** `postgres` +- **Password:** value of `POSTGRES_PASSWORD` from `.env.docker.production` + +### Notes + +- The production image builds the whole monorepo once, then each service overrides its runtime command. +- The dev compose file bind-mounts the repo and keeps `node_modules` in a named volume. +- `API_INTERNAL_URL` is used for server-side container-to-container calls, while `NEXT_PUBLIC_API_URL` is used by the browser. +- The Dockerfiles activate the repo's pinned `npm@10.5.0` with `corepack` before install so container builds do not depend on the npm version bundled with the base image. +- The dev compose stack stores Postgres data in `postgres_dev_data` and the bootstrap marker in `postgres_bootstrap_state`, so `up --build` does not reseed an existing local database. +- If you need database schema updates inside Docker, run: + +```bash +docker compose -f docker-compose.dev.yml run --rm migrate +``` + +If a cached base image still fails during `npm ci`, refresh it and rebuild without cache: + +```bash +docker pull node:20-bookworm +docker compose -f docker-compose.dev.yml build --no-cache dashboard +``` diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 0000000..287ad82 --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,15 @@ +FROM node:20-bookworm + +WORKDIR /app + +RUN npm install -g npm@10.5.0 --no-fund --no-audit + +COPY package.json package-lock.json turbo.json tsconfig.base.json ./ +COPY apps ./apps +COPY packages ./packages + +RUN npm ci --no-fund --no-audit + +EXPOSE 3000 3001 3002 3003 4000 + +CMD ["sh", "-c", "npm run db:generate && npm run dev"] diff --git a/Dockerfile.production b/Dockerfile.production new file mode 100644 index 0000000..4b1bee3 --- /dev/null +++ b/Dockerfile.production @@ -0,0 +1,30 @@ +FROM node:20-bookworm AS builder + +WORKDIR /app + +RUN corepack enable && corepack prepare npm@10.5.0 --activate + +COPY package.json package-lock.json turbo.json tsconfig.base.json ./ +COPY apps ./apps +COPY packages ./packages + +RUN npm install +RUN npm run db:generate +RUN npm run build + +FROM node:20-bookworm AS runner + +WORKDIR /app + +ENV NODE_ENV=production + +RUN corepack enable && corepack prepare npm@10.5.0 --activate + +COPY --from=builder /app/package.json /app/package-lock.json /app/turbo.json /app/tsconfig.base.json ./ +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/apps ./apps +COPY --from=builder /app/packages ./packages + +EXPOSE 3000 3001 3002 3003 4000 + +CMD ["npm", "run", "start", "--workspace", "@rentaldrivego/api"] diff --git a/Dockerfile.test b/Dockerfile.test new file mode 100644 index 0000000..2b313a6 --- /dev/null +++ b/Dockerfile.test @@ -0,0 +1,15 @@ +FROM node:20-bookworm + +WORKDIR /app + +RUN corepack enable && corepack prepare npm@10.5.0 --activate + +COPY package.json package-lock.json turbo.json tsconfig.base.json ./ +COPY apps ./apps +COPY packages ./packages + +RUN npm install + +ENV NODE_ENV=test + +CMD ["sh", "-c", "npm run db:generate && npm run type-check && npm run build"] diff --git a/README.md b/README.md deleted file mode 100644 index 6c458b7..0000000 --- a/README.md +++ /dev/null @@ -1,93 +0,0 @@ -# Car_Management_System - - - -## Getting started - -To make it easy for you to get started with GitLab, here's a list of recommended next steps. - -Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)! - -## Add your files - -* [Create](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files -* [Add files using the command line](https://docs.gitlab.com/topics/git/add_files/#add-files-to-a-git-repository) or push an existing Git repository with the following command: - -``` -cd existing_repo -git remote add origin https://gitlab.rentaldrivego.ma/rental_car/car_management_system.git -git branch -M main -git push -uf origin main -``` - -## Integrate with your tools - -* [Set up project integrations](https://gitlab.rentaldrivego.ma/rental_car/car_management_system/-/settings/integrations) - -## Collaborate with your team - -* [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/) -* [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html) -* [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically) -* [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/) -* [Set auto-merge](https://docs.gitlab.com/user/project/merge_requests/auto_merge/) - -## Test and Deploy - -Use the built-in continuous integration in GitLab. - -* [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/) -* [Analyze your code for known vulnerabilities with Static Application Security Testing (SAST)](https://docs.gitlab.com/ee/user/application_security/sast/) -* [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html) -* [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/ee/user/clusters/agent/) -* [Set up protected environments](https://docs.gitlab.com/ee/ci/environments/protected_environments.html) - -*** - -# Editing this README - -When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thanks to [makeareadme.com](https://www.makeareadme.com/) for this template. - -## Suggestions for a good README - -Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information. - -## Name -Choose a self-explaining name for your project. - -## Description -Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors. - -## Badges -On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge. - -## Visuals -Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method. - -## Installation -Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection. - -## Usage -Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README. - -## Support -Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc. - -## Roadmap -If you have ideas for releases in the future, it is a good idea to list them in the README. - -## Contributing -State if you are open to contributions and what your requirements are for accepting them. - -For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self. - -You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser. - -## Authors and acknowledgment -Show your appreciation to those who have contributed to the project. - -## License -For open source projects, say how it is licensed. - -## Project status -If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers. diff --git a/apps/admin/next-env.d.ts b/apps/admin/next-env.d.ts new file mode 100644 index 0000000..4f11a03 --- /dev/null +++ b/apps/admin/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/apps/admin/next.config.js b/apps/admin/next.config.js new file mode 100644 index 0000000..46bba73 --- /dev/null +++ b/apps/admin/next.config.js @@ -0,0 +1,31 @@ +/** @type {import('next').NextConfig} */ +const apiOrigin = (process.env.API_INTERNAL_URL ?? process.env.API_URL ?? 'http://localhost:4000').replace(/\/api\/v1\/?$/, '') +const apiUrl = new URL(apiOrigin) + +const nextConfig = { + images: { + remotePatterns: [ + { + protocol: 'https', + hostname: 'res.cloudinary.com', + }, + { + protocol: apiUrl.protocol.replace(':', ''), + hostname: apiUrl.hostname, + ...(apiUrl.port ? { port: apiUrl.port } : {}), + pathname: '/storage/**', + }, + ], + }, + transpilePackages: ['@rentaldrivego/types'], + async rewrites() { + return [ + { + source: '/api/:path*', + destination: `${apiOrigin}/api/:path*`, + }, + ] + }, +} + +module.exports = nextConfig diff --git a/apps/admin/package.json b/apps/admin/package.json new file mode 100644 index 0000000..c125d36 --- /dev/null +++ b/apps/admin/package.json @@ -0,0 +1,30 @@ +{ + "name": "@rentaldrivego/admin", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "next dev -p 3002", + "build": "next build", + "start": "next start -p 3002", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@rentaldrivego/types": "*", + "next": "14.2.3", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "tailwindcss": "^3.4.3", + "autoprefixer": "^10.4.19", + "postcss": "^8.4.38", + "zod": "^3.23.0", + "dayjs": "^1.11.11", + "recharts": "^2.12.7", + "lucide-react": "^0.376.0" + }, + "devDependencies": { + "@types/node": "^20.12.0", + "@types/react": "^18.3.1", + "@types/react-dom": "^18.3.0", + "typescript": "^5.4.0" + } +} diff --git a/apps/admin/postcss.config.js b/apps/admin/postcss.config.js new file mode 100644 index 0000000..33ad091 --- /dev/null +++ b/apps/admin/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/apps/admin/src/app/auth-redirect/page.tsx b/apps/admin/src/app/auth-redirect/page.tsx new file mode 100644 index 0000000..43fb837 --- /dev/null +++ b/apps/admin/src/app/auth-redirect/page.tsx @@ -0,0 +1,29 @@ +'use client' + +import { useEffect } from 'react' +import { useRouter } from 'next/navigation' + +export default function AuthRedirectPage() { + const router = useRouter() + + useEffect(() => { + const hash = window.location.hash + const params = new URLSearchParams(hash.replace(/^#/, '')) + const token = params.get('token') + const next = params.get('next') || '/dashboard' + + if (token) { + localStorage.setItem('admin_token', token) + // Clear the token from the URL + window.history.replaceState(null, '', window.location.pathname) + } + + router.replace(next) + }, [router]) + + return ( +
+

Redirecting to admin dashboard…

+
+ ) +} diff --git a/apps/admin/src/app/dashboard/admin-users/page.tsx b/apps/admin/src/app/dashboard/admin-users/page.tsx new file mode 100644 index 0000000..389cc54 --- /dev/null +++ b/apps/admin/src/app/dashboard/admin-users/page.tsx @@ -0,0 +1,211 @@ +'use client' + +import { useEffect, useState } from 'react' + +const API_BASE = '/api/v1' + +interface AdminUser { + id: string + firstName: string + lastName: string + email: string + role: string + isActive: boolean + createdAt: string + permissions?: { id: string; resource: string; actions: string[] }[] +} + +const ROLES = ['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE', 'VIEWER'] + +export default function AdminUsersPage() { + const [admins, setAdmins] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [showModal, setShowModal] = useState(false) + const [form, setForm] = useState({ firstName: '', lastName: '', email: '', password: '', role: 'SUPPORT' }) + const [creating, setCreating] = useState(false) + + function getToken() { return localStorage.getItem('admin_token') ?? '' } + + async function fetchAdmins() { + try { + const res = await fetch(`${API_BASE}/admin/admins`, { + headers: { Authorization: `Bearer ${getToken()}` }, + cache: 'no-store', + }) + const json = await res.json() + if (!res.ok) throw new Error(json?.message ?? 'Failed') + setAdmins(json.data ?? []) + } catch (err: any) { + setError(err.message) + } finally { + setLoading(false) + } + } + + useEffect(() => { fetchAdmins() }, []) + + async function createAdmin(e: React.FormEvent) { + e.preventDefault() + setCreating(true) + setError(null) + try { + const res = await fetch(`${API_BASE}/admin/admins`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` }, + body: JSON.stringify(form), + }) + const json = await res.json() + if (!res.ok) throw new Error(json?.message ?? 'Failed to create') + setShowModal(false) + setForm({ firstName: '', lastName: '', email: '', password: '', role: 'SUPPORT' }) + await fetchAdmins() + } catch (err: any) { + setError(err.message) + } finally { + setCreating(false) + } + } + + const ROLE_COLORS: Record = { + SUPER_ADMIN: 'text-emerald-400 bg-emerald-950/40', + ADMIN: 'text-sky-400 bg-sky-950/40', + SUPPORT: 'text-amber-400 bg-amber-950/40', + FINANCE: 'text-violet-400 bg-violet-950/40', + VIEWER: 'text-zinc-400 bg-zinc-800', + } + + return ( +
+
+
+

Platform

+

Admin Users

+
+ +
+ + {error &&
{error}
} + +
+
+ + + + + + + + + + + + + {loading ? ( + + ) : admins.length === 0 ? ( + + ) : admins.map((a) => ( + + + + + + + + + ))} + +
NameEmailRolePermissionsStatusJoined
Loading…
No admin users found
{a.firstName} {a.lastName}{a.email} + + {a.role} + + + {a.permissions && a.permissions.length > 0 + ? a.permissions.map((permission) => permission.resource).join(', ') + : 'Role-based only'} + + + {a.isActive ? 'Active' : 'Inactive'} + + {new Date(a.createdAt).toLocaleDateString()}
+
+
+ + {showModal && ( +
+
+
+

New admin user

+ +
+
+
+
+ + setForm({ ...form, firstName: e.target.value })} + /> +
+
+ + setForm({ ...form, lastName: e.target.value })} + /> +
+
+
+ + setForm({ ...form, email: e.target.value })} + /> +
+
+ + setForm({ ...form, password: e.target.value })} + /> +
+
+ + +
+
+ + +
+
+
+
+ )} +
+ ) +} diff --git a/apps/admin/src/app/dashboard/audit-logs/page.tsx b/apps/admin/src/app/dashboard/audit-logs/page.tsx new file mode 100644 index 0000000..c6cff74 --- /dev/null +++ b/apps/admin/src/app/dashboard/audit-logs/page.tsx @@ -0,0 +1,100 @@ +'use client' + +import { useEffect, useState } from 'react' + +const API_BASE = '/api/v1' + +interface AuditLog { + id: string + action: string + resource: string + resourceId: string | null + createdAt: string + adminUser: { email: string; firstName: string; lastName: string } | null +} + +const ACTION_COLORS: Record = { + CREATE: 'text-emerald-400 bg-emerald-950/40', + UPDATE: 'text-sky-400 bg-sky-950/40', + DELETE: 'text-red-400 bg-red-950/40', + SUSPEND: 'text-amber-400 bg-amber-950/40', + ACTIVATE: 'text-emerald-400 bg-emerald-950/40', + LOGIN: 'text-zinc-400 bg-zinc-800', +} + +export default function AdminAuditLogsPage() { + const [logs, setLogs] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [filter, setFilter] = useState('') + + useEffect(() => { + const token = localStorage.getItem('admin_token') ?? '' + fetch(`${API_BASE}/admin/audit-logs`, { + headers: { Authorization: `Bearer ${token}` }, + cache: 'no-store', + }) + .then((r) => r.json()) + .then((json) => setLogs(json.data ?? [])) + .catch((err) => setError(err.message)) + .finally(() => setLoading(false)) + }, []) + + const filtered = filter + ? logs.filter((l) => l.action.toLowerCase().includes(filter.toLowerCase()) || l.resource.toLowerCase().includes(filter.toLowerCase())) + : logs + + return ( +
+
+
+

Platform

+

Audit Logs

+
+ setFilter(e.target.value)} + /> +
+ + {error &&
{error}
} + +
+
+ + + + + + + + + + + + {loading ? ( + + ) : filtered.length === 0 ? ( + + ) : filtered.map((log) => ( + + + + + + + + ))} + +
ActionEntityEntity IDAdminTime
Loading…
No logs found
+ + {log.action} + + {log.resource}{log.resourceId ? `${log.resourceId.slice(0, 12)}…` : '—'}{log.adminUser ? `${log.adminUser.firstName} ${log.adminUser.lastName}` : '—'}{new Date(log.createdAt).toLocaleString()}
+
+
+
+ ) +} diff --git a/apps/admin/src/app/dashboard/billing/page.tsx b/apps/admin/src/app/dashboard/billing/page.tsx new file mode 100644 index 0000000..1191ff6 --- /dev/null +++ b/apps/admin/src/app/dashboard/billing/page.tsx @@ -0,0 +1,472 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useAdminI18n } from '@/components/I18nProvider' + +const API_BASE = '/api/v1' + +interface Company { + id: string + name: string + email: string + slug: string + status: string +} + +interface Invoice { + id: string + amount: number + currency: string + status: string + paymentProvider: string + amanpayTransactionId?: string | null + paypalCaptureId?: string | null + paidAt: string | null + createdAt: string +} + +function buildInvoiceNumber(invoiceId: string, createdAt: string) { + const year = new Date(createdAt).getFullYear() + const short = invoiceId.slice(-6).toUpperCase() + return `INV-${year}-${short}` +} + +interface Subscription { + id: string + companyId: string + company: Company + plan: string + billingPeriod: string + status: string + currency: string + currentPeriodEnd: string | null + cancelAtPeriodEnd: boolean + invoices: Invoice[] + _count: { invoices: number } + createdAt: string +} + +interface Stats { + mrr: number + activeCount: number + trialingCount: number + pastDueCount: number + cancelledCount: number +} + +const SUB_STATUS_COLORS: Record = { + ACTIVE: 'text-emerald-400 bg-emerald-900/30', + TRIALING: 'text-sky-400 bg-sky-900/30', + PAST_DUE: 'text-amber-400 bg-amber-900/30', + CANCELLED: 'text-zinc-400 bg-zinc-800', + UNPAID: 'text-red-400 bg-red-900/30', +} + +const INVOICE_STATUS_COLORS: Record = { + PAID: 'text-emerald-400 bg-emerald-900/30', + PENDING: 'text-amber-400 bg-amber-900/30', + FAILED: 'text-red-400 bg-red-900/30', + REFUNDED: 'text-zinc-400 bg-zinc-800', +} + +const PLAN_COLORS: Record = { + STARTER: 'text-zinc-300', + GROWTH: 'text-sky-400', + PRO: 'text-violet-400', +} + +function fmt(amount: number, currency: string) { + return `${(amount / 100).toFixed(2)} ${currency}` +} + +function fmtDate(iso: string | null) { + if (!iso) return '—' + return new Date(iso).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }) +} + +export default function AdminBillingPage() { + const { language } = useAdminI18n() + + const copy = { + en: { + title: 'Billing', + eyebrow: 'Platform', + mrr: 'MRR (MAD)', + active: 'Active', + trialing: 'Trialing', + pastDue: 'Past Due', + cancelled: 'Cancelled', + company: 'Company', + plan: 'Plan', + period: 'Period', + status: 'Status', + currency: 'Currency', + nextRenewal: 'Next Renewal', + invoices: 'Invoices', + actions: 'Actions', + viewInvoices: 'Invoices', + loading: 'Loading…', + empty: 'No subscriptions found', + filterAll: 'All', + filterActive: 'Active', + filterTrialing: 'Trialing', + filterPastDue: 'Past Due', + filterCancelled: 'Cancelled', + closeModal: 'Close', + invoicesFor: 'Invoices for', + noInvoices: 'No invoices found', + amount: 'Amount', + invoiceStatus: 'Status', + provider: 'Provider', + paidAt: 'Paid At', + date: 'Date', + invoiceNo: 'Invoice #', + download: 'PDF', + cancelAtEnd: 'Cancels at period end', + }, + fr: { + title: 'Facturation', + eyebrow: 'Plateforme', + mrr: 'MRR (MAD)', + active: 'Actifs', + trialing: 'Essai', + pastDue: 'En retard', + cancelled: 'Annulés', + company: 'Entreprise', + plan: 'Plan', + period: 'Période', + status: 'Statut', + currency: 'Devise', + nextRenewal: 'Prochain renouvellement', + invoices: 'Factures', + actions: 'Actions', + viewInvoices: 'Factures', + loading: 'Chargement…', + empty: 'Aucun abonnement trouvé', + filterAll: 'Tous', + filterActive: 'Actifs', + filterTrialing: 'Essai', + filterPastDue: 'En retard', + filterCancelled: 'Annulés', + closeModal: 'Fermer', + invoicesFor: 'Factures pour', + noInvoices: 'Aucune facture trouvée', + amount: 'Montant', + invoiceStatus: 'Statut', + provider: 'Fournisseur', + paidAt: 'Payé le', + date: 'Date', + invoiceNo: 'N° Facture', + download: 'PDF', + cancelAtEnd: 'Annulé en fin de période', + }, + ar: { + title: 'الفوترة', + eyebrow: 'المنصة', + mrr: 'الإيراد الشهري (MAD)', + active: 'نشط', + trialing: 'تجريبي', + pastDue: 'متأخر', + cancelled: 'ملغى', + company: 'الشركة', + plan: 'الخطة', + period: 'الفترة', + status: 'الحالة', + currency: 'العملة', + nextRenewal: 'التجديد القادم', + invoices: 'الفواتير', + actions: 'الإجراءات', + viewInvoices: 'الفواتير', + loading: 'جارٍ التحميل…', + empty: 'لا توجد اشتراكات', + filterAll: 'الكل', + filterActive: 'نشط', + filterTrialing: 'تجريبي', + filterPastDue: 'متأخر', + filterCancelled: 'ملغى', + closeModal: 'إغلاق', + invoicesFor: 'فواتير', + noInvoices: 'لا توجد فواتير', + amount: 'المبلغ', + invoiceStatus: 'الحالة', + provider: 'المزود', + paidAt: 'تاريخ الدفع', + date: 'التاريخ', + invoiceNo: 'رقم الفاتورة', + download: 'PDF', + cancelAtEnd: 'يُلغى في نهاية الفترة', + }, + }[language] + + const [subscriptions, setSubscriptions] = useState([]) + const [stats, setStats] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [statusFilter, setStatusFilter] = useState('') + + const [invoiceModal, setInvoiceModal] = useState<{ companyId: string; companyName: string } | null>(null) + const [invoices, setInvoices] = useState([]) + const [invoicesLoading, setInvoicesLoading] = useState(false) + const [invoicesError, setInvoicesError] = useState(null) + + async function fetchBilling(status?: string) { + setLoading(true) + setError(null) + const token = localStorage.getItem('admin_token') + try { + const params = new URLSearchParams({ pageSize: '50' }) + if (status) params.set('status', status) + const res = await fetch(`${API_BASE}/admin/billing?${params}`, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + cache: 'no-store', + }) + const json = await res.json() + if (!res.ok) throw new Error(json?.message ?? 'Failed to fetch') + setSubscriptions(json.data ?? []) + setStats(json.stats ?? null) + } catch (err: any) { + setError(err.message) + } finally { + setLoading(false) + } + } + + useEffect(() => { fetchBilling(statusFilter || undefined) }, [statusFilter]) + + async function downloadInvoicePdf(invoiceId: string, createdAt: string) { + const token = localStorage.getItem('admin_token') + try { + const res = await fetch(`${API_BASE}/admin/billing/invoices/${invoiceId}/pdf`, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }) + if (!res.ok) throw new Error('Failed to generate PDF') + const blob = await res.blob() + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `${buildInvoiceNumber(invoiceId, createdAt)}.pdf` + a.click() + URL.revokeObjectURL(url) + } catch { + // silent — no toast system available here + } + } + + async function openInvoices(companyId: string, companyName: string) { + setInvoiceModal({ companyId, companyName }) + setInvoices([]) + setInvoicesError(null) + setInvoicesLoading(true) + const token = localStorage.getItem('admin_token') + try { + const res = await fetch(`${API_BASE}/admin/billing/${companyId}/invoices?pageSize=50`, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }) + const json = await res.json() + if (!res.ok) throw new Error(json?.message ?? `Error ${res.status}`) + setInvoices(json.data ?? []) + } catch (err: any) { + setInvoicesError(err.message) + } finally { + setInvoicesLoading(false) + } + } + + const filters = [ + { key: '', label: copy.filterAll }, + { key: 'ACTIVE', label: copy.filterActive }, + { key: 'TRIALING', label: copy.filterTrialing }, + { key: 'PAST_DUE', label: copy.filterPastDue }, + { key: 'CANCELLED', label: copy.filterCancelled }, + ] + + return ( +
+
+

{copy.eyebrow}

+

{copy.title}

+
+ + {stats && ( +
+
+

{copy.mrr}

+

{(stats.mrr / 100).toFixed(0)}

+
+
+

{copy.active}

+

{stats.activeCount}

+
+
+

{copy.trialing}

+

{stats.trialingCount}

+
+
+

{copy.pastDue}

+

{stats.pastDueCount}

+
+
+

{copy.cancelled}

+

{stats.cancelledCount}

+
+
+ )} + +
+ {filters.map((f) => ( + + ))} +
+ + {error &&
{error}
} + +
+
+ + + + + + + + + + + + + + {loading ? ( + + ) : subscriptions.length === 0 ? ( + + ) : subscriptions.map((sub) => ( + + + + + + + + + + + ))} + +
{copy.company}{copy.plan}{copy.period}{copy.status}{copy.currency}{copy.nextRenewal}{copy.invoices} +
{copy.loading}
{copy.empty}
+

{sub.company.name}

+

{sub.company.email}

+
+ {sub.plan} + {sub.billingPeriod} +
+ + {sub.status} + + {sub.cancelAtPeriodEnd && ( + {copy.cancelAtEnd} + )} +
+
{sub.currency}{fmtDate(sub.currentPeriodEnd)}{sub._count.invoices} + +
+
+
+ + {invoiceModal && ( +
+
+
+
+

{copy.invoicesFor}

+

{invoiceModal.companyName}

+
+ +
+
+ {invoicesLoading ? ( +
{copy.loading}
+ ) : invoicesError ? ( +
{invoicesError}
+ ) : invoices.length === 0 ? ( +
{copy.noInvoices}
+ ) : ( + + + + + + + + + + + + + {invoices.map((inv) => ( + + + + + + + + + + ))} + +
{copy.invoiceNo}{copy.date}{copy.amount}{copy.invoiceStatus}{copy.provider}{copy.paidAt} +
{buildInvoiceNumber(inv.id, inv.createdAt)}{fmtDate(inv.createdAt)}{fmt(inv.amount, inv.currency)} + + {inv.status} + + {inv.paymentProvider}{fmtDate(inv.paidAt)} + +
+ )} +
+
+ +
+
+
+ )} +
+ ) +} diff --git a/apps/admin/src/app/dashboard/companies/[id]/HomepageLayoutEditor.tsx b/apps/admin/src/app/dashboard/companies/[id]/HomepageLayoutEditor.tsx new file mode 100644 index 0000000..ec37300 --- /dev/null +++ b/apps/admin/src/app/dashboard/companies/[id]/HomepageLayoutEditor.tsx @@ -0,0 +1,232 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' +import { + PUBLIC_HOMEPAGE_GRID_COLUMNS, + PUBLIC_HOMEPAGE_GRID_ROWS, + getPublicHomepageSectionLimits, + type PublicHomepageLayout, + type PublicHomepageLayoutItem, + type PublicHomepageSectionType, +} from '@rentaldrivego/types' + +type Props = { + layout: PublicHomepageLayout + availableSections: PublicHomepageSectionType[] + onChange: (layout: PublicHomepageLayout) => void + onAddSection: (type: PublicHomepageSectionType) => void + onRemoveSection: (type: PublicHomepageSectionType) => void +} + +type Interaction = { + itemId: string + mode: 'move' | 'resize' + startX: number + startY: number + origin: PublicHomepageLayoutItem +} + +const ROW_HEIGHT = 52 + +const SECTION_META: Record = { + hero: { label: 'Hero', tint: 'from-sky-500 to-cyan-400', description: 'Headline, CTA buttons, and brand media.' }, + offers: { label: 'Offers', tint: 'from-emerald-500 to-lime-400', description: 'Promotions and campaign cards.' }, + vehicles: { label: 'Vehicles', tint: 'from-amber-500 to-orange-400', description: 'Published fleet grid.' }, + pricing: { label: 'Pricing', tint: 'from-fuchsia-500 to-rose-400', description: 'Plans and pricing content.' }, +} + +function clamp(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), max) +} + +export function HomepageLayoutEditor({ layout, availableSections, onChange, onAddSection, onRemoveSection }: Props) { + const canvasRef = useRef(null) + const [interaction, setInteraction] = useState(null) + + useEffect(() => { + if (!interaction) return + const snap = interaction + + function handlePointerMove(event: PointerEvent) { + const canvas = canvasRef.current + if (!canvas) return + const rect = canvas.getBoundingClientRect() + const colWidth = rect.width / PUBLIC_HOMEPAGE_GRID_COLUMNS + const deltaCols = Math.round((event.clientX - snap.startX) / colWidth) + const deltaRows = Math.round((event.clientY - snap.startY) / ROW_HEIGHT) + const limits = getPublicHomepageSectionLimits(snap.origin.type) + + onChange({ + items: layout.items.map((item) => { + if (item.id !== snap.itemId) return item + if (snap.mode === 'move') { + return { + ...item, + x: clamp(snap.origin.x + deltaCols, 1, PUBLIC_HOMEPAGE_GRID_COLUMNS - snap.origin.w + 1), + y: clamp(snap.origin.y + deltaRows, 1, PUBLIC_HOMEPAGE_GRID_ROWS - snap.origin.h + 1), + } + } + + const nextW = clamp( + snap.origin.w + deltaCols, + limits.minW, + Math.min(limits.maxW, PUBLIC_HOMEPAGE_GRID_COLUMNS - snap.origin.x + 1), + ) + const nextH = clamp( + snap.origin.h + deltaRows, + limits.minH, + Math.min(limits.maxH, PUBLIC_HOMEPAGE_GRID_ROWS - snap.origin.y + 1), + ) + + return { + ...item, + w: nextW, + h: nextH, + } + }), + }) + } + + function handlePointerUp() { + setInteraction(null) + } + + window.addEventListener('pointermove', handlePointerMove) + window.addEventListener('pointerup', handlePointerUp) + return () => { + window.removeEventListener('pointermove', handlePointerMove) + window.removeEventListener('pointerup', handlePointerUp) + } + }, [interaction, layout.items, onChange]) + + return ( +
+
+
+

Homepage layout

+

Add sections, drag them anywhere on the grid, and resize them from the bottom-right handle.

+
+
+ {availableSections.map((type) => ( + + ))} +
+
+ +
+ {layout.items.map((item) => { + const meta = SECTION_META[item.type] + return ( +
+ + +
+

{meta.description}

+
+ x{item.x} y{item.y} + +
+
+ + +
+ ) + })} +
+ +
+ {layout.items.map((item) => { + const meta = SECTION_META[item.type] + return ( +
+
+
+

{meta.label}

+

{meta.description}

+
+ +
+

Desktop drag canvas is available on larger screens.

+
+ ) + })} +
+
+ ) +} diff --git a/apps/admin/src/app/dashboard/companies/[id]/page.tsx b/apps/admin/src/app/dashboard/companies/[id]/page.tsx new file mode 100644 index 0000000..54fb5e4 --- /dev/null +++ b/apps/admin/src/app/dashboard/companies/[id]/page.tsx @@ -0,0 +1,1507 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useParams, useRouter } from 'next/navigation' +import Link from 'next/link' +import { + clonePublicHomepageLayout, + clonePublicSitePageSections, + resolvePublicHomepageLayout, + type PublicHomepageLayout, + type PublicHomepageSectionType, + type PublicSitePageSections, +} from '@rentaldrivego/types' +import { HomepageLayoutEditor } from './HomepageLayoutEditor' + +const API_BASE = '/api/v1' + +interface CompanyDetail { + id: string + name: string + slug: string + email: string + phone: string | null + status: string + subscriptionPaymentRef: string | null + createdAt: string + subscription: { + plan: string + billingPeriod: string + status: string + currency: string + trialStartAt: string | null + trialEndAt: string | null + currentPeriodStart: string | null + currentPeriodEnd: string | null + cancelledAt: string | null + cancelAtPeriodEnd: boolean + } | null + brand: { + displayName: string + tagline: string | null + subdomain: string + customDomain: string | null + publicEmail: string | null + publicPhone: string | null + publicAddress: string | null + publicCity: string | null + publicCountry: string | null + websiteUrl: string | null + whatsappNumber: string | null + defaultLocale: string + defaultCurrency: string + isListedOnMarketplace: boolean + homePageConfig: { + heroTitle: string | null + heroDescription: string | null + viewOffersLabel: string | null + viewPricingLabel: string | null + contactCompanyLabel: string | null + activeOffersTitle: string | null + seeAllOffersLabel: string | null + noActiveOffersLabel: string | null + publishedVehiclesTitle: string | null + viewVehicleLabel: string | null + pricingEyebrow: string | null + pricingTitle: string | null + pricingDescription: string | null + showOffers: boolean + showVehicles: boolean + showPricing: boolean + layout?: PublicHomepageLayout | null + } | null + menuConfig: { + aboutLabel: string | null + vehiclesLabel: string | null + offersLabel: string | null + pricingLabel: string | null + blogLabel: string | null + contactLabel: string | null + bookCarLabel: string | null + siteNavigationLabel: string | null + showAbout: boolean + showVehicles: boolean + showOffers: boolean + showPricing: boolean + showBlog: boolean + showContact: boolean + pageSections?: PublicSitePageSections | null + } | null + } | null + contractSettings: { + legalName: string | null + registrationNumber: string | null + taxId: string | null + terms: string + fuelPolicyType: string + lateFeePerHour: number | null + taxRate: number | null + signatureRequired: boolean + showTax: boolean + } | null + accountingSettings: { + reportingPeriod: string + fiscalYearStart: number + currency: string + accountantEmail: string | null + accountantName: string | null + autoSendReport: boolean + reportFormat: string + } | null + employees?: Array<{ id: string }> + vehicles?: Array<{ id: string }> + customers?: Array<{ id: string }> + reservations?: Array<{ id: string }> + _count?: { employees?: number; vehicles?: number; customers?: number; reservations?: number } +} + +interface AuditLog { + id: string + action: string + resource: string + resourceId: string | null + createdAt: string + adminUser: { email: string } | null +} + +interface FormState { + company: { + name: string + slug: string + email: string + phone: string + subscriptionPaymentRef: string + } + subscription: { + plan: string + billingPeriod: string + status: string + currency: string + trialStartAt: string + trialEndAt: string + currentPeriodStart: string + currentPeriodEnd: string + cancelledAt: string + cancelAtPeriodEnd: boolean + } + brand: { + displayName: string + tagline: string + subdomain: string + customDomain: string + publicEmail: string + publicPhone: string + publicAddress: string + publicCity: string + publicCountry: string + websiteUrl: string + whatsappNumber: string + defaultLocale: string + defaultCurrency: string + isListedOnMarketplace: boolean + homePageConfig: { + heroTitle: string + heroDescription: string + viewOffersLabel: string + viewPricingLabel: string + contactCompanyLabel: string + activeOffersTitle: string + seeAllOffersLabel: string + noActiveOffersLabel: string + publishedVehiclesTitle: string + viewVehicleLabel: string + pricingEyebrow: string + pricingTitle: string + pricingDescription: string + showOffers: boolean + showVehicles: boolean + showPricing: boolean + layout: PublicHomepageLayout + } + menuConfig: { + aboutLabel: string + vehiclesLabel: string + offersLabel: string + pricingLabel: string + blogLabel: string + contactLabel: string + bookCarLabel: string + siteNavigationLabel: string + showAbout: boolean + showVehicles: boolean + showOffers: boolean + showPricing: boolean + showBlog: boolean + showContact: boolean + pageSections: PublicSitePageSections + } + } + contractSettings: { + legalName: string + registrationNumber: string + taxId: string + terms: string + fuelPolicyType: string + lateFeePerHour: string + taxRate: string + signatureRequired: boolean + showTax: boolean + } + accountingSettings: { + reportingPeriod: string + fiscalYearStart: string + currency: string + accountantEmail: string + accountantName: string + autoSendReport: boolean + reportFormat: string + } +} + +type CompanyHomePageConfig = NonNullable['homePageConfig'] + +const STATUS_COLORS: Record = { + ACTIVE: 'text-emerald-400', + TRIALING: 'text-sky-400', + SUSPENDED: 'text-red-400', + PENDING: 'text-amber-400', + CANCELLED: 'text-zinc-400', + PAST_DUE: 'text-orange-400', +} + +const PREVIEW_PRICING_PLANS = [ + { name: 'Starter', price: 'MAD 499', cadence: '/month' }, + { name: 'Growth', price: 'MAD 999', cadence: '/month', highlighted: true }, + { name: 'Pro', price: 'Custom', cadence: '' }, +] + +const HOMEPAGE_SECTION_ORDER: PublicHomepageSectionType[] = ['hero', 'offers', 'vehicles', 'pricing'] + +const PAGE_SECTION_GROUPS: Array<{ + page: keyof PublicSitePageSections + label: string + sections: Array<{ key: string; label: string }> +}> = [ + { page: 'home', label: 'Homepage', sections: [{ key: 'hero', label: 'Hero' }, { key: 'offers', label: 'Offers' }, { key: 'vehicles', label: 'Vehicles' }, { key: 'pricing', label: 'Pricing' }] }, + { page: 'about', label: 'About', sections: [{ key: 'hero', label: 'Hero' }, { key: 'highlights', label: 'Highlights' }, { key: 'details', label: 'Details' }] }, + { page: 'offers', label: 'Offers', sections: [{ key: 'header', label: 'Header' }, { key: 'grid', label: 'Offers grid' }] }, + { page: 'pricing', label: 'Pricing', sections: [{ key: 'hero', label: 'Hero' }, { key: 'plans', label: 'Plans' }, { key: 'payments', label: 'Payments' }, { key: 'faq', label: 'FAQ' }] }, + { page: 'vehicles', label: 'Vehicles', sections: [{ key: 'header', label: 'Header' }, { key: 'filters', label: 'Filters' }, { key: 'grid', label: 'Vehicle grid' }] }, + { page: 'vehicleDetail', label: 'Vehicle detail', sections: [{ key: 'gallery', label: 'Gallery' }, { key: 'summary', label: 'Summary card' }] }, + { page: 'blog', label: 'Blog', sections: [{ key: 'hero', label: 'Hero' }, { key: 'posts', label: 'Posts' }] }, + { page: 'contact', label: 'Contact', sections: [{ key: 'content', label: 'Content card' }] }, + { page: 'booking', label: 'Booking', sections: [{ key: 'content', label: 'Booking form' }] }, + { page: 'bookingConfirmation', label: 'Booking confirmation', sections: [{ key: 'hero', label: 'Confirmation header' }, { key: 'summary', label: 'Booking summary' }, { key: 'actions', label: 'Action buttons' }] }, +] + +const INPUT_CLASS = 'mt-1 w-full rounded-xl border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-emerald-500' +const LABEL_CLASS = 'text-xs font-medium uppercase tracking-wide text-zinc-500' + +function getToken() { + return localStorage.getItem('admin_token') ?? '' +} + +function toDateInput(value: string | null | undefined) { + return value ? new Date(value).toISOString().slice(0, 10) : '' +} + +function toNullable(value: string) { + const trimmed = value.trim() + return trimmed ? trimmed : null +} + +function resolvePreviewText(value: string, fallback: string) { + const trimmed = value.trim() + return trimmed ? trimmed : fallback +} + +function resolveLegacyHomepageVisibility( + config: CompanyHomePageConfig | null | undefined, + type: Exclude, +) { + if (type === 'offers') return config?.showOffers ?? true + if (type === 'vehicles') return config?.showVehicles ?? true + return config?.showPricing ?? true +} + +function buildHomepageLayout(company: CompanyDetail): PublicHomepageLayout { + const pageSections = mergePageSectionsConfig(company.brand?.menuConfig?.pageSections) + const visibleTypes = HOMEPAGE_SECTION_ORDER.filter((type) => { + if (!pageSections.home[type]) return false + if (type === 'hero') return true + return resolveLegacyHomepageVisibility(company.brand?.homePageConfig ?? null, type) + }) + + return resolvePublicHomepageLayout(company.brand?.homePageConfig?.layout, visibleTypes) +} + +function mergePageSectionsConfig(config?: PublicSitePageSections | null): PublicSitePageSections { + const defaults = clonePublicSitePageSections() + return { + ...defaults, + ...(config ?? {}), + home: { ...defaults.home, ...(config?.home ?? {}) }, + about: { ...defaults.about, ...(config?.about ?? {}) }, + offers: { ...defaults.offers, ...(config?.offers ?? {}) }, + pricing: { ...defaults.pricing, ...(config?.pricing ?? {}) }, + vehicles: { ...defaults.vehicles, ...(config?.vehicles ?? {}) }, + vehicleDetail: { ...defaults.vehicleDetail, ...(config?.vehicleDetail ?? {}) }, + blog: { ...defaults.blog, ...(config?.blog ?? {}) }, + contact: { ...defaults.contact, ...(config?.contact ?? {}) }, + booking: { ...defaults.booking, ...(config?.booking ?? {}) }, + bookingConfirmation: { + ...defaults.bookingConfirmation, + ...(config?.bookingConfirmation ?? {}), + }, + } +} + +function createFormState(company: CompanyDetail): FormState { + return { + company: { + name: company.name ?? '', + slug: company.slug ?? '', + email: company.email ?? '', + phone: company.phone ?? '', + subscriptionPaymentRef: company.subscriptionPaymentRef ?? '', + }, + subscription: { + plan: company.subscription?.plan ?? 'STARTER', + billingPeriod: company.subscription?.billingPeriod ?? 'MONTHLY', + status: company.subscription?.status ?? 'TRIALING', + currency: company.subscription?.currency ?? 'MAD', + trialStartAt: toDateInput(company.subscription?.trialStartAt), + trialEndAt: toDateInput(company.subscription?.trialEndAt), + currentPeriodStart: toDateInput(company.subscription?.currentPeriodStart), + currentPeriodEnd: toDateInput(company.subscription?.currentPeriodEnd), + cancelledAt: toDateInput(company.subscription?.cancelledAt), + cancelAtPeriodEnd: company.subscription?.cancelAtPeriodEnd ?? false, + }, + brand: { + displayName: company.brand?.displayName ?? company.name ?? '', + tagline: company.brand?.tagline ?? '', + subdomain: company.brand?.subdomain ?? company.slug ?? '', + customDomain: company.brand?.customDomain ?? '', + publicEmail: company.brand?.publicEmail ?? '', + publicPhone: company.brand?.publicPhone ?? '', + publicAddress: company.brand?.publicAddress ?? '', + publicCity: company.brand?.publicCity ?? '', + publicCountry: company.brand?.publicCountry ?? '', + websiteUrl: company.brand?.websiteUrl ?? '', + whatsappNumber: company.brand?.whatsappNumber ?? '', + defaultLocale: company.brand?.defaultLocale ?? 'en', + defaultCurrency: company.brand?.defaultCurrency ?? 'MAD', + isListedOnMarketplace: company.brand?.isListedOnMarketplace ?? true, + homePageConfig: { + heroTitle: company.brand?.homePageConfig?.heroTitle ?? '', + heroDescription: company.brand?.homePageConfig?.heroDescription ?? '', + viewOffersLabel: company.brand?.homePageConfig?.viewOffersLabel ?? '', + viewPricingLabel: company.brand?.homePageConfig?.viewPricingLabel ?? '', + contactCompanyLabel: company.brand?.homePageConfig?.contactCompanyLabel ?? '', + activeOffersTitle: company.brand?.homePageConfig?.activeOffersTitle ?? '', + seeAllOffersLabel: company.brand?.homePageConfig?.seeAllOffersLabel ?? '', + noActiveOffersLabel: company.brand?.homePageConfig?.noActiveOffersLabel ?? '', + publishedVehiclesTitle: company.brand?.homePageConfig?.publishedVehiclesTitle ?? '', + viewVehicleLabel: company.brand?.homePageConfig?.viewVehicleLabel ?? '', + pricingEyebrow: company.brand?.homePageConfig?.pricingEyebrow ?? '', + pricingTitle: company.brand?.homePageConfig?.pricingTitle ?? '', + pricingDescription: company.brand?.homePageConfig?.pricingDescription ?? '', + showOffers: company.brand?.homePageConfig?.showOffers ?? true, + showVehicles: company.brand?.homePageConfig?.showVehicles ?? true, + showPricing: company.brand?.homePageConfig?.showPricing ?? true, + layout: buildHomepageLayout(company), + }, + menuConfig: { + aboutLabel: company.brand?.menuConfig?.aboutLabel ?? '', + vehiclesLabel: company.brand?.menuConfig?.vehiclesLabel ?? '', + offersLabel: company.brand?.menuConfig?.offersLabel ?? '', + pricingLabel: company.brand?.menuConfig?.pricingLabel ?? '', + blogLabel: company.brand?.menuConfig?.blogLabel ?? '', + contactLabel: company.brand?.menuConfig?.contactLabel ?? '', + bookCarLabel: company.brand?.menuConfig?.bookCarLabel ?? '', + siteNavigationLabel: company.brand?.menuConfig?.siteNavigationLabel ?? '', + showAbout: company.brand?.menuConfig?.showAbout ?? true, + showVehicles: company.brand?.menuConfig?.showVehicles ?? true, + showOffers: company.brand?.menuConfig?.showOffers ?? true, + showPricing: company.brand?.menuConfig?.showPricing ?? true, + showBlog: company.brand?.menuConfig?.showBlog ?? true, + showContact: company.brand?.menuConfig?.showContact ?? true, + pageSections: mergePageSectionsConfig(company.brand?.menuConfig?.pageSections), + }, + }, + contractSettings: { + legalName: company.contractSettings?.legalName ?? '', + registrationNumber: company.contractSettings?.registrationNumber ?? '', + taxId: company.contractSettings?.taxId ?? '', + terms: company.contractSettings?.terms ?? '', + fuelPolicyType: company.contractSettings?.fuelPolicyType ?? 'FULL_TO_FULL', + lateFeePerHour: company.contractSettings?.lateFeePerHour?.toString() ?? '', + taxRate: company.contractSettings?.taxRate?.toString() ?? '', + signatureRequired: company.contractSettings?.signatureRequired ?? true, + showTax: company.contractSettings?.showTax ?? false, + }, + accountingSettings: { + reportingPeriod: company.accountingSettings?.reportingPeriod ?? 'MONTHLY', + fiscalYearStart: company.accountingSettings?.fiscalYearStart?.toString() ?? '1', + currency: company.accountingSettings?.currency ?? 'MAD', + accountantEmail: company.accountingSettings?.accountantEmail ?? '', + accountantName: company.accountingSettings?.accountantName ?? '', + autoSendReport: company.accountingSettings?.autoSendReport ?? false, + reportFormat: company.accountingSettings?.reportFormat ?? 'PDF', + }, + } +} + +export default function AdminCompanyDetailPage() { + const { id } = useParams<{ id: string }>() + const router = useRouter() + const [company, setCompany] = useState(null) + const [form, setForm] = useState(null) + const [auditLogs, setAuditLogs] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [actioning, setActioning] = useState(false) + const [saving, setSaving] = useState(false) + const [savedMessage, setSavedMessage] = useState(null) + const [deleteConfirm, setDeleteConfirm] = useState(false) + + async function fetchData() { + const headers = { Authorization: `Bearer ${getToken()}` } + try { + const [cRes, aRes] = await Promise.all([ + fetch(`${API_BASE}/admin/companies/${id}`, { headers, cache: 'no-store' }), + fetch(`${API_BASE}/admin/audit-logs?entityId=${id}&pageSize=10`, { headers, cache: 'no-store' }), + ]) + const cJson = await cRes.json() + const aJson = await aRes.json() + if (!cRes.ok) throw new Error(cJson?.message ?? 'Not found') + setCompany(cJson.data) + setForm(createFormState(cJson.data)) + setAuditLogs(aJson.data ?? []) + setError(null) + } catch (err: any) { + setError(err.message) + } finally { + setLoading(false) + } + } + + useEffect(() => { fetchData() }, [id]) + + function updateSection(section: K, patch: Partial) { + setForm((current) => current ? { ...current, [section]: { ...current[section], ...patch } } : current) + } + + function updateBrandConfig( + section: K, + patch: Partial, + ) { + setForm((current) => current ? { + ...current, + brand: { + ...current.brand, + [section]: { + ...current.brand[section], + ...patch, + }, + }, + } : current) + } + + function updateHomepageLayout(layout: PublicHomepageLayout) { + setForm((current) => current ? { + ...current, + brand: { + ...current.brand, + homePageConfig: { + ...current.brand.homePageConfig, + layout, + }, + }, + } : current) + } + + function addHomepageSection(type: PublicHomepageSectionType) { + setForm((current) => { + if (!current) return current + if (current.brand.homePageConfig.layout.items.some((item) => item.type === type)) return current + const fallback = clonePublicHomepageLayout().items.find((item) => item.type === type) + if (!fallback) return current + + return { + ...current, + brand: { + ...current.brand, + homePageConfig: { + ...current.brand.homePageConfig, + showOffers: type === 'offers' ? true : current.brand.homePageConfig.showOffers, + showVehicles: type === 'vehicles' ? true : current.brand.homePageConfig.showVehicles, + showPricing: type === 'pricing' ? true : current.brand.homePageConfig.showPricing, + layout: { + items: [...current.brand.homePageConfig.layout.items, fallback], + }, + }, + menuConfig: { + ...current.brand.menuConfig, + pageSections: { + ...current.brand.menuConfig.pageSections, + home: { + ...current.brand.menuConfig.pageSections.home, + [type]: true, + }, + }, + }, + }, + } + }) + } + + function removeHomepageSection(type: PublicHomepageSectionType) { + setForm((current) => current ? { + ...current, + brand: { + ...current.brand, + homePageConfig: { + ...current.brand.homePageConfig, + showOffers: type === 'offers' ? false : current.brand.homePageConfig.showOffers, + showVehicles: type === 'vehicles' ? false : current.brand.homePageConfig.showVehicles, + showPricing: type === 'pricing' ? false : current.brand.homePageConfig.showPricing, + layout: { + items: current.brand.homePageConfig.layout.items.filter((item) => item.type !== type), + }, + }, + menuConfig: { + ...current.brand.menuConfig, + pageSections: { + ...current.brand.menuConfig.pageSections, + home: { + ...current.brand.menuConfig.pageSections.home, + [type]: false, + }, + }, + }, + }, + } : current) + } + + function updatePageSection< + P extends keyof PublicSitePageSections, + S extends keyof PublicSitePageSections[P] + >(page: P, section: S, value: boolean) { + setForm((current) => { + if (!current) return current + + const next = { + ...current, + brand: { + ...current.brand, + menuConfig: { + ...current.brand.menuConfig, + pageSections: { + ...current.brand.menuConfig.pageSections, + [page]: { + ...current.brand.menuConfig.pageSections[page], + [section]: value, + }, + }, + }, + }, + } + + if (page !== 'home') return next + + const homepageSection = section as PublicHomepageSectionType + const nextItems = value + ? next.brand.homePageConfig.layout.items.some((item) => item.type === homepageSection) + ? next.brand.homePageConfig.layout.items + : [...next.brand.homePageConfig.layout.items, clonePublicHomepageLayout().items.find((item) => item.type === homepageSection)!] + : next.brand.homePageConfig.layout.items.filter((item) => item.type !== homepageSection) + + return { + ...next, + brand: { + ...next.brand, + homePageConfig: { + ...next.brand.homePageConfig, + showOffers: homepageSection === 'offers' ? value : next.brand.homePageConfig.showOffers, + showVehicles: homepageSection === 'vehicles' ? value : next.brand.homePageConfig.showVehicles, + showPricing: homepageSection === 'pricing' ? value : next.brand.homePageConfig.showPricing, + layout: { items: nextItems }, + }, + }, + } + }) + } + + function getPageSectionValue< + P extends keyof PublicSitePageSections, + S extends keyof PublicSitePageSections[P] + >(page: P, section: S) { + return form?.brand.menuConfig.pageSections[page][section] ?? false + } + + async function saveChanges() { + if (!form) return + setSaving(true) + setSavedMessage(null) + setError(null) + try { + const res = await fetch(`${API_BASE}/admin/companies/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` }, + body: JSON.stringify({ + company: { + name: form.company.name, + slug: form.company.slug, + email: form.company.email, + phone: toNullable(form.company.phone), + subscriptionPaymentRef: toNullable(form.company.subscriptionPaymentRef), + }, + subscription: { + plan: form.subscription.plan, + billingPeriod: form.subscription.billingPeriod, + status: form.subscription.status, + currency: form.subscription.currency, + trialStartAt: toNullable(form.subscription.trialStartAt), + trialEndAt: toNullable(form.subscription.trialEndAt), + currentPeriodStart: toNullable(form.subscription.currentPeriodStart), + currentPeriodEnd: toNullable(form.subscription.currentPeriodEnd), + cancelledAt: toNullable(form.subscription.cancelledAt), + cancelAtPeriodEnd: form.subscription.cancelAtPeriodEnd, + }, + brand: { + displayName: form.brand.displayName, + tagline: toNullable(form.brand.tagline), + subdomain: form.brand.subdomain, + customDomain: toNullable(form.brand.customDomain), + publicEmail: toNullable(form.brand.publicEmail), + publicPhone: toNullable(form.brand.publicPhone), + publicAddress: toNullable(form.brand.publicAddress), + publicCity: toNullable(form.brand.publicCity), + publicCountry: toNullable(form.brand.publicCountry), + websiteUrl: toNullable(form.brand.websiteUrl), + whatsappNumber: toNullable(form.brand.whatsappNumber), + defaultLocale: form.brand.defaultLocale, + defaultCurrency: form.brand.defaultCurrency, + isListedOnMarketplace: form.brand.isListedOnMarketplace, + homePageConfig: { + heroTitle: toNullable(form.brand.homePageConfig.heroTitle), + heroDescription: toNullable(form.brand.homePageConfig.heroDescription), + viewOffersLabel: toNullable(form.brand.homePageConfig.viewOffersLabel), + viewPricingLabel: toNullable(form.brand.homePageConfig.viewPricingLabel), + contactCompanyLabel: toNullable(form.brand.homePageConfig.contactCompanyLabel), + activeOffersTitle: toNullable(form.brand.homePageConfig.activeOffersTitle), + seeAllOffersLabel: toNullable(form.brand.homePageConfig.seeAllOffersLabel), + noActiveOffersLabel: toNullable(form.brand.homePageConfig.noActiveOffersLabel), + publishedVehiclesTitle: toNullable(form.brand.homePageConfig.publishedVehiclesTitle), + viewVehicleLabel: toNullable(form.brand.homePageConfig.viewVehicleLabel), + pricingEyebrow: toNullable(form.brand.homePageConfig.pricingEyebrow), + pricingTitle: toNullable(form.brand.homePageConfig.pricingTitle), + pricingDescription: toNullable(form.brand.homePageConfig.pricingDescription), + showOffers: form.brand.homePageConfig.showOffers, + showVehicles: form.brand.homePageConfig.showVehicles, + showPricing: form.brand.homePageConfig.showPricing, + layout: form.brand.homePageConfig.layout, + }, + menuConfig: { + aboutLabel: toNullable(form.brand.menuConfig.aboutLabel), + vehiclesLabel: toNullable(form.brand.menuConfig.vehiclesLabel), + offersLabel: toNullable(form.brand.menuConfig.offersLabel), + pricingLabel: toNullable(form.brand.menuConfig.pricingLabel), + blogLabel: toNullable(form.brand.menuConfig.blogLabel), + contactLabel: toNullable(form.brand.menuConfig.contactLabel), + bookCarLabel: toNullable(form.brand.menuConfig.bookCarLabel), + siteNavigationLabel: toNullable(form.brand.menuConfig.siteNavigationLabel), + showAbout: form.brand.menuConfig.showAbout, + showVehicles: form.brand.menuConfig.showVehicles, + showOffers: form.brand.menuConfig.showOffers, + showPricing: form.brand.menuConfig.showPricing, + showBlog: form.brand.menuConfig.showBlog, + showContact: form.brand.menuConfig.showContact, + pageSections: form.brand.menuConfig.pageSections, + }, + }, + contractSettings: { + legalName: toNullable(form.contractSettings.legalName), + registrationNumber: toNullable(form.contractSettings.registrationNumber), + taxId: toNullable(form.contractSettings.taxId), + terms: form.contractSettings.terms, + fuelPolicyType: form.contractSettings.fuelPolicyType, + lateFeePerHour: form.contractSettings.lateFeePerHour ? Number(form.contractSettings.lateFeePerHour) : null, + taxRate: form.contractSettings.taxRate ? Number(form.contractSettings.taxRate) : null, + signatureRequired: form.contractSettings.signatureRequired, + showTax: form.contractSettings.showTax, + }, + accountingSettings: { + reportingPeriod: form.accountingSettings.reportingPeriod, + fiscalYearStart: Number(form.accountingSettings.fiscalYearStart || 1), + currency: form.accountingSettings.currency, + accountantEmail: toNullable(form.accountingSettings.accountantEmail), + accountantName: toNullable(form.accountingSettings.accountantName), + autoSendReport: form.accountingSettings.autoSendReport, + reportFormat: form.accountingSettings.reportFormat, + }, + }), + }) + const json = await res.json() + if (!res.ok) throw new Error(json?.message ?? 'Save failed') + setCompany(json.data) + setForm(createFormState(json.data)) + setSavedMessage('Company settings updated.') + await fetchData() + } catch (err: any) { + setError(err.message) + } finally { + setSaving(false) + } + } + + async function changeStatus(status: string) { + setActioning(true) + setError(null) + try { + const res = await fetch(`${API_BASE}/admin/companies/${id}/status`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` }, + body: JSON.stringify({ status }), + }) + const json = await res.json() + if (!res.ok) throw new Error(json?.message ?? 'Action failed') + await fetchData() + } catch (err: any) { + setError(err.message) + } finally { + setActioning(false) + } + } + + async function deleteCompany() { + setActioning(true) + try { + const res = await fetch(`${API_BASE}/admin/companies/${id}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${getToken()}` }, + }) + const json = await res.json() + if (!res.ok) throw new Error(json?.message ?? 'Delete failed') + router.push('/dashboard/companies') + } catch (err: any) { + setError(err.message) + setActioning(false) + } + } + + if (loading) return
Loading…
+ if (error && !company) return
{error}
+ if (!company || !form) return null + + const counts = { + employees: company._count?.employees ?? company.employees?.length ?? 0, + vehicles: company._count?.vehicles ?? company.vehicles?.length ?? 0, + customers: company._count?.customers ?? company.customers?.length ?? 0, + reservations: company._count?.reservations ?? company.reservations?.length ?? 0, + } + const previewCompanyName = form.brand.displayName.trim() || form.company.name.trim() || company.name + const previewTagline = resolvePreviewText( + form.brand.tagline, + 'This company uses RentalDriveGo to publish vehicles, run bookings, and accept direct rental payments.', + ) + const previewHeroTitle = resolvePreviewText(form.brand.homePageConfig.heroTitle, previewTagline) + const previewHeroDescription = resolvePreviewText( + form.brand.homePageConfig.heroDescription, + 'Manage direct bookings, highlight active offers, and turn your fleet into a modern rental storefront.', + ) + const previewOffersLabel = resolvePreviewText(form.brand.homePageConfig.viewOffersLabel, 'View offers') + const previewPricingLabel = resolvePreviewText(form.brand.homePageConfig.viewPricingLabel, 'View pricing') + const previewContactLabel = resolvePreviewText(form.brand.homePageConfig.contactCompanyLabel, 'Contact company') + const previewOffersTitle = resolvePreviewText(form.brand.homePageConfig.activeOffersTitle, 'Active offers') + const previewSeeAllOffersLabel = resolvePreviewText(form.brand.homePageConfig.seeAllOffersLabel, 'See all offers') + const previewNoOffersLabel = resolvePreviewText(form.brand.homePageConfig.noActiveOffersLabel, 'No active offers right now') + const previewVehiclesTitle = resolvePreviewText(form.brand.homePageConfig.publishedVehiclesTitle, 'Published vehicles') + const previewViewVehicleLabel = resolvePreviewText(form.brand.homePageConfig.viewVehicleLabel, 'View vehicle') + const previewPricingEyebrow = resolvePreviewText(form.brand.homePageConfig.pricingEyebrow, 'Pricing') + const previewPricingTitle = resolvePreviewText(form.brand.homePageConfig.pricingTitle, 'Plans for every fleet size') + const previewPricingDescription = resolvePreviewText( + form.brand.homePageConfig.pricingDescription, + 'Showcase transparent pricing and make it easy for renters to understand the value of booking direct.', + ) + const previewBookCarLabel = resolvePreviewText(form.brand.menuConfig.bookCarLabel, 'Book car') + const previewFooterLabel = resolvePreviewText(form.brand.menuConfig.siteNavigationLabel, 'Site navigation') + const homepageLayout = resolvePublicHomepageLayout( + form.brand.homePageConfig.layout, + form.brand.homePageConfig.layout.items.map((item) => item.type), + ) + const availableHomepageSections = HOMEPAGE_SECTION_ORDER.filter( + (type) => !homepageLayout.items.some((item) => item.type === type), + ) + const previewMenuItems = [ + { label: resolvePreviewText(form.brand.menuConfig.aboutLabel, 'About'), visible: form.brand.menuConfig.showAbout }, + { label: resolvePreviewText(form.brand.menuConfig.vehiclesLabel, 'Vehicles'), visible: form.brand.menuConfig.showVehicles }, + { label: resolvePreviewText(form.brand.menuConfig.offersLabel, 'Offers'), visible: form.brand.menuConfig.showOffers }, + { label: resolvePreviewText(form.brand.menuConfig.pricingLabel, 'Pricing'), visible: form.brand.menuConfig.showPricing }, + { label: resolvePreviewText(form.brand.menuConfig.blogLabel, 'Blog'), visible: form.brand.menuConfig.showBlog }, + { label: resolvePreviewText(form.brand.menuConfig.contactLabel, 'Contact'), visible: form.brand.menuConfig.showContact }, + ].filter((item) => item.visible) + + function renderHomepagePreviewSection(type: PublicHomepageSectionType) { + if (type === 'hero') { + return ( +
+
+

{previewCompanyName}

+

{previewHeroTitle}

+

{previewHeroDescription}

+
+ {previewOffersLabel} + {previewPricingLabel} + {previewContactLabel} +
+
+
+ Hero image preview area. +
+ Published brand media will render here on the public site. +
+
+ ) + } + + if (type === 'offers') { + return ( +
+
+

{previewOffersTitle}

+ {previewSeeAllOffersLabel} +
+
+
+

Sample offer

+
Weekend escape
+

15%

+
+
+ {previewNoOffersLabel} +
+
+
+ ) + } + + if (type === 'vehicles') { + return ( +
+
+

{previewVehiclesTitle}

+

{counts.vehicles} published vehicles

+
+
+ {[1, 2].map((index) => ( +
+
+
+
Vehicle {index}
+

Category

+
+

MAD 850/day

+ {previewViewVehicleLabel} +
+
+
+ ))} +
+
+ ) + } + + return ( +
+
+
+

{previewPricingEyebrow}

+

{previewPricingTitle}

+

{previewPricingDescription}

+
+ + {previewPricingLabel} + +
+ +
+ {PREVIEW_PRICING_PLANS.slice(0, 2).map((plan) => ( +
+
+
+
{plan.name}
+

Pricing copy preview for the public homepage.

+
+ {plan.highlighted ? ( + + Popular + + ) : null} +
+
+ {plan.price} + {plan.cadence ? {plan.cadence} : null} +
+
+ ))} +
+
+ ) + } + + return ( +
+
+ ← Companies + +
+ +
+
+

{company.name}

+

{company.slug}

+
+ {company.status} +
+ + {error &&
{error}
} + {savedMessage &&
{savedMessage}
} + +
+ {[ + { label: 'Employees', value: counts.employees }, + { label: 'Vehicles', value: counts.vehicles }, + { label: 'Customers', value: counts.customers }, + { label: 'Reservations', value: counts.reservations }, + ].map((kpi) => ( +
+

{kpi.label}

+

{kpi.value}

+
+ ))} +
+ +
+
+

Company

+
+ + + + + +
+
+ +
+

Subscription

+
+ + + + + + + + + + +
+
+ +
+

Brand and public profile

+
+ + + + + + + + + + + + + + +
+
+ +
+
+
+

Public site homepage and menu

+

Blank text fields fall back to the default localized copy on the public site.

+
+
+ +
+
+

Homepage content

+
+ +