4 Commits

Author SHA1 Message Date
root f16e1ab26b new merge for develop
Build & Deploy / Build & Push Docker Image (push) Failing after 1s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / Type Check (all packages) (push) Failing after 10s
Test / API Unit Tests (push) Has been skipped
Test / Homepage Unit Tests (push) Has been skipped
Test / Carplace Unit Tests (push) Has been skipped
Test / Admin Unit Tests (push) Has been skipped
Test / Dashboard Unit Tests (push) Has been skipped
Test / API Integration Tests (push) Has been skipped
2026-07-04 01:09:15 -04:00
root f8d8f050f8 Fix Gitea CI external action dependency
Build & Deploy / Build & Push Docker Image (push) Failing after 1s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / Type Check (all packages) (push) Failing after 10s
Test / API Unit Tests (push) Has been skipped
Test / Homepage Unit Tests (push) Has been skipped
Test / Carplace Unit Tests (push) Has been skipped
Test / Admin Unit Tests (push) Has been skipped
Test / Dashboard Unit Tests (push) Has been skipped
Test / API Integration Tests (push) Has been skipped
2026-07-04 00:52:34 -04:00
root 831cd8c7af ci: remove GitHub action dependencies from Gitea workflows
Build & Deploy / Build & Push Docker Image (push) Failing after 3m14s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / Type Check (all packages) (push) Failing after 10s
Test / API Unit Tests (push) Has been skipped
Test / Homepage Unit Tests (push) Has been skipped
Test / Carplace Unit Tests (push) Has been skipped
Test / Admin Unit Tests (push) Has been skipped
Test / Dashboard Unit Tests (push) Has been skipped
Test / API Integration Tests (push) Has been skipped
2026-07-04 00:25:11 -04:00
root 2a79ac8e63 fix traefik file
Build & Deploy / Build & Push Docker Image (push) Failing after 11s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / Type Check (all packages) (push) Failing after 10s
Test / API Unit Tests (push) Has been skipped
Test / Homepage Unit Tests (push) Has been skipped
Test / Carplace Unit Tests (push) Has been skipped
Test / Admin Unit Tests (push) Has been skipped
Test / Dashboard Unit Tests (push) Has been skipped
Test / API Integration Tests (push) Has been skipped
2026-07-03 01:07:31 -04:00
119 changed files with 7528 additions and 608 deletions
+1 -2
View File
@@ -31,7 +31,7 @@ NEXT_PUBLIC_API_URL=https://api.example.com/api/v1
# Frontend public URLs
NEXT_PUBLIC_HOMEPAGE_URL=https://example.com
NEXT_PUBLIC_CARPLACE_URL=https://example.com
NEXT_PUBLIC_STOREFRONT_URL=https://example.com
NEXT_PUBLIC_DASHBOARD_URL=https://example.com/dashboard
NEXT_PUBLIC_ADMIN_URL=https://example.com/admin
NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=example.com
@@ -45,7 +45,6 @@ CORS_ORIGINS=https://example.com,https://www.example.com
JWT_SECRET=placeholder
JWT_EXPIRY=8h
RENTER_JWT_EXPIRY=7d
SESSION_COOKIE_DOMAIN=.example.com
NODE_ENV=production
# File storage
+1 -4
View File
@@ -21,9 +21,6 @@ NEXT_PUBLIC_API_URL=http://localhost:4000/api/v1
JWT_SECRET=8bf96de20297c2c295a60e4040e937a7eb8ec7d7d2b83e79a1fc98463322a97c
JWT_EXPIRY=8h
RENTER_JWT_EXPIRY=7d
# Optional in local development. Required in production when auth spans sibling subdomains.
# Example: SESSION_COOKIE_DOMAIN=.rentaldrivego.ma
SESSION_COOKIE_DOMAIN=
# ─── Clerk (Company employee auth) ────────────────────────────
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
@@ -91,7 +88,7 @@ REDIS_URL=redis://localhost:6379
NEXT_PUBLIC_DASHBOARD_URL=http://localhost:3000/dashboard
NEXT_PUBLIC_ADMIN_URL=http://localhost:3000/admin
NEXT_PUBLIC_CARPLACE_URL=http://localhost:3000/carplace
NEXT_PUBLIC_STOREFRONT_URL=http://localhost:3000/storefront
NEXT_PUBLIC_HOMEPAGE_URL=http://localhost:3000
# Public site is subdomain-based; use this for local dev:
NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=localhost:3003
+171 -268
View File
@@ -1,8 +1,8 @@
name: Build & Push
name: Build & Deploy
on:
push:
branches: [fix_branch]
branches: [develop]
workflow_dispatch:
concurrency:
@@ -15,7 +15,6 @@ env:
GIT_SSL_NO_VERIFY: "true"
REGISTRY_HOST: 192.168.3.80
DEPLOY_REGISTRY_HOST: 10.0.0.4
DEPLOY_SSH_HOST: 10.0.0.1
DOCKERFILE_PATH: Dockerfile.production
DOCKER_PLATFORM: linux/amd64
DEPLOY_ROOT: /opt/rentaldrivego
@@ -28,45 +27,56 @@ jobs:
image_repository: ${{ steps.image-meta.outputs.repository }}
docker_image: ${{ steps.image-meta.outputs.full }}
steps:
- name: Checkout repository
- name: Check out repository
shell: bash
env:
GITEA_SERVER_URL: ${{ gitea.server_url }}
GITEA_REPOSITORY: ${{ gitea.repository }}
GITEA_SHA: ${{ gitea.sha }}
CHECKOUT_TOKEN: ${{ gitea.token }}
run: |
set -euo pipefail
if ! command -v git >/dev/null 2>&1; then
echo "::error::git must be available in the runner image; this workflow cannot install git while runner DNS is unavailable."
exit 1
fi
WORKSPACE="${GITHUB_WORKSPACE:-$PWD}"
mkdir -p "$WORKSPACE"
cd "$WORKSPACE"
if [ -z "$(find . -mindepth 1 -maxdepth 1 -print -quit)" ]; then
REPOSITORY_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
SERVER_URL="${GITEA_SERVER_URL:-${GITHUB_SERVER_URL:-}}"
REPOSITORY="${GITEA_REPOSITORY:-${GITHUB_REPOSITORY:-}}"
SHA="${GITEA_SHA:-${GITHUB_SHA:-}}"
if [ -z "$SERVER_URL" ] || [ -z "$REPOSITORY" ] || [ -z "$SHA" ]; then
echo "::error::Missing repository checkout context"
exit 1
fi
REPOSITORY_URL="${SERVER_URL}/${REPOSITORY}.git"
if [ ! -d .git ]; then
git init
git init .
git remote add origin "$REPOSITORY_URL"
if [ -n "${{ secrets.GITHUB_TOKEN }}" ]; then
AUTH_HEADER="$(printf '%s:%s' "${{ gitea.actor }}" "${{ secrets.GITHUB_TOKEN }}" | base64 | tr -d '\n')"
git -c http.extraheader="Authorization: Basic ${AUTH_HEADER}" \
fetch --depth 1 origin "${{ gitea.ref }}"
else
git fetch --depth 1 origin "${{ gitea.ref }}"
fi
git checkout --detach "${{ gitea.sha }}" || git checkout --detach FETCH_HEAD
fi
git config --global --add safe.directory "$WORKSPACE"
if [ -n "${CHECKOUT_TOKEN:-}" ]; then
git -c "http.extraHeader=Authorization: token ${CHECKOUT_TOKEN}" fetch --no-tags --depth=1 origin "$SHA"
else
git fetch --no-tags --depth=1 origin "$SHA"
git rev-parse --short HEAD
- name: Ensure Docker CLI is available
run: |
if ! command -v docker >/dev/null 2>&1; then
echo "::error::Docker CLI is not available in this Gitea runner image."
echo "::error::Configure the runner label used by this job to an image that already includes Docker CLI, for example ubuntu-latest:docker://catthehacker/ubuntu:act-latest."
echo "::error::The current runner maps ubuntu-latest to node:20-bookworm, and this job cannot install docker.io because the job container has no external DNS."
exit 1
fi
git checkout --force FETCH_HEAD
docker --version
docker info >/dev/null
- name: Set up Docker Buildx
shell: bash
run: |
set -euo pipefail
docker buildx version
printf '[registry."%s"]\n insecure = true\n' "$REGISTRY_HOST" > /tmp/buildkitd.toml
if ! docker buildx inspect rentaldrivego-builder >/dev/null 2>&1; then
docker buildx create \
--name rentaldrivego-builder \
--driver docker-container \
--config /tmp/buildkitd.toml \
--use
else
docker buildx use rentaldrivego-builder
fi
docker buildx inspect --bootstrap
- name: Docker image metadata
id: image-meta
@@ -74,23 +84,16 @@ jobs:
REPO="${{ gitea.repository }}"
TAG="${{ gitea.sha }}"
echo "repository=${REPO}" >> "$GITHUB_OUTPUT"
echo "full=${DEPLOY_REGISTRY_HOST}/${REPO}:${TAG}" >> "$GITHUB_OUTPUT"
echo "latest=${DEPLOY_REGISTRY_HOST}/${REPO}:latest" >> "$GITHUB_OUTPUT"
echo "full=${REGISTRY_HOST}/${REPO}:${TAG}" >> "$GITHUB_OUTPUT"
echo "latest=${REGISTRY_HOST}/${REPO}:latest" >> "$GITHUB_OUTPUT"
- name: Check Docker registry credentials
id: registry-check
env:
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
REGISTRY_USERNAME="${REGISTRY_USERNAME:-${REGISTRY_USER:-}}"
REGISTRY_PASSWORD="${REGISTRY_PASSWORD:-${REGISTRY_TOKEN:-}}"
if [ -n "$REGISTRY_USERNAME" ] && [ -n "$REGISTRY_PASSWORD" ]; then
if [ -n "${{ secrets.REGISTRY_USERNAME }}" ] && [ -n "${{ secrets.REGISTRY_PASSWORD }}" ]; then
echo "available=true" >> "$GITHUB_OUTPUT"
else
echo "::warning::Registry credentials secrets not set — configure REGISTRY_USERNAME/REGISTRY_PASSWORD or REGISTRY_USER/REGISTRY_TOKEN; push will be skipped"
echo "::warning::REGISTRY_USERNAME or REGISTRY_PASSWORD secrets not set — push will be skipped"
echo "available=false" >> "$GITHUB_OUTPUT"
fi
@@ -104,258 +107,158 @@ jobs:
NEXT_PUBLIC_ADMIN_URL: ${{ secrets.NEXT_PUBLIC_ADMIN_URL }}
SITE_ORIGIN: ${{ secrets.SITE_ORIGIN }}
run: |
validate_url() {
name="$1"
value="$2"
for variable in \
NEXT_PUBLIC_API_URL \
NEXT_PUBLIC_HOMEPAGE_URL \
NEXT_PUBLIC_CARPLACE_URL \
NEXT_PUBLIC_DASHBOARD_URL \
NEXT_PUBLIC_ADMIN_URL \
SITE_ORIGIN
do
value="${!variable}"
if [ -z "$value" ]; then
echo "::error::$name is missing — add it to Gitea Actions secrets"
echo "::error::$variable is missing — add it to Gitea Actions secrets"
exit 1
fi
case "$value" in
http://*|https://*) ;;
*)
echo "::error::$name must be an absolute URL (got: $value)"
echo "::error::$variable must be an absolute URL (got: $value)"
exit 1
;;
esac
}
done
if [ -z "$NEXT_PUBLIC_CARPLACE_URL" ] && [ -n "$SITE_ORIGIN" ]; then
NEXT_PUBLIC_CARPLACE_URL="${SITE_ORIGIN%/}/carplace"
fi
- name: Log in to Gitea Container Registry
if: steps.registry-check.outputs.available == 'true'
shell: bash
run: |
printf '%s' "${{ secrets.REGISTRY_PASSWORD }}" | \
docker login "$REGISTRY_HOST" \
--username "${{ secrets.REGISTRY_USERNAME }}" \
--password-stdin
validate_url NEXT_PUBLIC_API_URL "$NEXT_PUBLIC_API_URL"
validate_url NEXT_PUBLIC_HOMEPAGE_URL "$NEXT_PUBLIC_HOMEPAGE_URL"
validate_url NEXT_PUBLIC_CARPLACE_URL "$NEXT_PUBLIC_CARPLACE_URL"
validate_url NEXT_PUBLIC_DASHBOARD_URL "$NEXT_PUBLIC_DASHBOARD_URL"
validate_url NEXT_PUBLIC_ADMIN_URL "$NEXT_PUBLIC_ADMIN_URL"
validate_url SITE_ORIGIN "$SITE_ORIGIN"
- name: Check remote build credentials
id: check-build-host
- name: Build and push
shell: bash
env:
VPS_HOST: ${{ secrets.VPS_IP }}
VPS_SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
VPS_SSH_KEY_B64: ${{ secrets.VPS_SSH_KEY_B64 }}
BUILDKIT_NO_CLIENT_TOKEN: "true"
run: |
VPS_HOST="${VPS_HOST:-$DEPLOY_SSH_HOST}"
if [ -z "$VPS_SSH_KEY" ] && [ -z "$VPS_SSH_KEY_B64" ]; then
echo "::error::VPS_SSH_KEY_B64 or VPS_SSH_KEY is required to build on the remote Docker host"
exit 1
fi
if [ -z "$VPS_HOST" ] || \
[ -z "${{ secrets.VPS_USER }}" ]; then
echo "::error::VPS_USER and either VPS_IP or DEPLOY_SSH_HOST are required to build on the remote Docker host"
exit 1
set -euo pipefail
OUTPUT_ARG="--load"
if [ "${{ steps.registry-check.outputs.available }}" = "true" ]; then
OUTPUT_ARG="--push"
fi
- name: Check SSH tools
docker buildx build \
--file "$DOCKERFILE_PATH" \
--platform "$DOCKER_PLATFORM" \
--tag "${{ steps.image-meta.outputs.full }}" \
--tag "${{ steps.image-meta.outputs.latest }}" \
--build-arg API_INTERNAL_URL=http://api:4000/api/v1 \
--build-arg DASHBOARD_INTERNAL_URL=http://dashboard:3001 \
--build-arg ADMIN_INTERNAL_URL=http://admin:3002 \
--build-arg NEXT_PUBLIC_API_URL="${{ secrets.NEXT_PUBLIC_API_URL }}" \
--build-arg NEXT_PUBLIC_HOMEPAGE_URL="${{ secrets.NEXT_PUBLIC_HOMEPAGE_URL }}" \
--build-arg NEXT_PUBLIC_CARPLACE_URL="${{ secrets.NEXT_PUBLIC_CARPLACE_URL }}" \
--build-arg NEXT_PUBLIC_DASHBOARD_URL="${{ secrets.NEXT_PUBLIC_DASHBOARD_URL }}" \
--build-arg NEXT_PUBLIC_ADMIN_URL="${{ secrets.NEXT_PUBLIC_ADMIN_URL }}" \
--build-arg SITE_ORIGIN="${{ secrets.SITE_ORIGIN }}" \
"$OUTPUT_ARG" \
.
deploy:
name: Deploy to VPS
runs-on: ubuntu-latest
needs: [build-image]
env:
DOCKER_IMAGE: ${{ needs.build-image.outputs.docker_image }}
IMAGE_REPOSITORY: ${{ needs.build-image.outputs.image_repository }}
steps:
- name: Check out repository
shell: bash
run: |
if ! command -v ssh >/dev/null 2>&1 || ! command -v ssh-keygen >/dev/null 2>&1 || ! command -v tar >/dev/null 2>&1; then
echo "::error::ssh, ssh-keygen, and tar must be available in the runner image; this workflow cannot install packages while runner DNS is unavailable."
set -euo pipefail
if [ -z "$(find . -mindepth 1 -maxdepth 1 -print -quit)" ]; then
REPOSITORY_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
git init .
git remote add origin "$REPOSITORY_URL"
if [ -n "${{ secrets.GITHUB_TOKEN }}" ]; then
AUTH_HEADER="$(printf '%s:%s' "${{ gitea.actor }}" "${{ secrets.GITHUB_TOKEN }}" | base64 | tr -d '\n')"
git -c http.extraheader="Authorization: Basic ${AUTH_HEADER}" \
fetch --depth 1 origin "${{ gitea.ref }}"
else
git fetch --depth 1 origin "${{ gitea.ref }}"
fi
git checkout --detach "${{ gitea.sha }}" || git checkout --detach FETCH_HEAD
fi
git rev-parse --short HEAD
- name: Check deploy credentials
id: check-creds
run: |
if [ -z "${{ secrets.VPS_SSH_KEY }}" ] || \
[ -z "${{ secrets.VPS_IP }}" ] || \
[ -z "${{ secrets.VPS_USER }}" ]; then
echo "VPS secrets not fully configured — skipping deploy"
echo "skip=true" >> "$GITHUB_OUTPUT"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- name: Install SSH client
if: steps.check-creds.outputs.skip == 'false'
run: |
if ! command -v ssh >/dev/null 2>&1 || ! command -v scp >/dev/null 2>&1; then
echo "::error::OpenSSH client tools are not available in this Gitea runner image."
echo "::error::Use a runner image that already includes ssh and scp; installing packages from apt is not reliable because the job container has no external DNS."
exit 1
fi
ssh -V
- name: Set up SSH key
env:
VPS_HOST: ${{ secrets.VPS_IP }}
VPS_SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
VPS_SSH_KEY_B64: ${{ secrets.VPS_SSH_KEY_B64 }}
if: steps.check-creds.outputs.skip == 'false'
run: |
VPS_HOST="${VPS_HOST:-$DEPLOY_SSH_HOST}"
if [ -z "$VPS_HOST" ]; then
echo "::error::VPS_IP secret or DEPLOY_SSH_HOST is required"
exit 1
fi
echo "Using SSH host $VPS_HOST"
mkdir -p ~/.ssh && chmod 700 ~/.ssh
if [ -n "$VPS_SSH_KEY_B64" ]; then
if ! printf '%s' "$VPS_SSH_KEY_B64" | tr -d '[:space:]' | base64 -d > ~/.ssh/id_rsa 2>/tmp/vps_ssh_key_decode.err; then
if [ -n "$VPS_SSH_KEY" ]; then
echo "::warning::VPS_SSH_KEY_B64 is not valid base64; using VPS_SSH_KEY instead"
printf '%b\n' "$VPS_SSH_KEY" | tr -d '\r' > ~/.ssh/id_rsa
else
echo "::error::VPS_SSH_KEY_B64 is not valid base64. Store a base64-encoded private key there, or set VPS_SSH_KEY to the raw private key."
exit 1
fi
fi
else
printf '%b\n' "$VPS_SSH_KEY" | tr -d '\r' > ~/.ssh/id_rsa
fi
echo "${{ secrets.VPS_SSH_KEY }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
key_header="$(head -n 1 ~/.ssh/id_rsa || true)"
key_size="$(wc -c < ~/.ssh/id_rsa | tr -d ' ')"
case "$key_header" in
"-----BEGIN "*PRIVATE*" KEY-----") ;;
ssh-*|"ecdsa-"*|"sk-"*)
echo "::error::Decoded SSH key looks like a public key, not a private key. Encode the private key file, not the .pub file."
exit 1
;;
*)
echo "::error::Decoded SSH key does not start with a private key header. Decoded byte count: $key_size."
exit 1
;;
esac
if ! keygen_error="$(ssh-keygen -y -f ~/.ssh/id_rsa 2>&1 >/dev/null)"; then
echo "::error::SSH private key is not readable by ssh-keygen: $keygen_error. Use an unencrypted private key or configure a CI-specific deploy key."
exit 1
fi
ssh-keygen -y -f ~/.ssh/id_rsa > ~/.ssh/id_rsa.pub
key_fingerprint="$(ssh-keygen -lf ~/.ssh/id_rsa.pub | awk '{print $2}')"
echo "Loaded deploy key fingerprint: $key_fingerprint"
echo "Deploy public key: $(cat ~/.ssh/id_rsa.pub)"
touch ~/.ssh/known_hosts
ssh-keyscan -H "$VPS_HOST" >> ~/.ssh/known_hosts 2>/dev/null || true
ssh-keyscan -H "${{ secrets.VPS_IP }}" >> ~/.ssh/known_hosts 2>/dev/null
chmod 644 ~/.ssh/known_hosts
- name: Test SSH authentication
env:
VPS_HOST: ${{ secrets.VPS_IP }}
- name: Sync deployment assets
if: steps.check-creds.outputs.skip == 'false'
run: |
VPS_HOST="${VPS_HOST:-$DEPLOY_SSH_HOST}"
if [ -z "$VPS_HOST" ]; then
echo "::error::VPS_IP secret or DEPLOY_SSH_HOST is required"
exit 1
fi
SSH_OPTIONS="-i $HOME/.ssh/id_rsa -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=$HOME/.ssh/known_hosts"
key_fingerprint="$(ssh-keygen -lf "$HOME/.ssh/id_rsa.pub" | awk '{print $2}')"
echo "Testing SSH authentication to $VPS_HOST with deploy key fingerprint $key_fingerprint"
if ! ssh $SSH_OPTIONS "${{ secrets.VPS_USER }}@$VPS_HOST" "printf 'ssh authenticated as '; whoami"; then
echo "::error::SSH authentication failed. Verify VPS_USER matches the account that has deploy key fingerprint $key_fingerprint in ~/.ssh/authorized_keys on $VPS_HOST."
exit 1
fi
ssh "${{ secrets.VPS_USER }}@${{ secrets.VPS_IP }}" \
"mkdir -p '$DEPLOY_ROOT/scripts' '$DEPLOY_ROOT/docker/pgmanage' '$DEPLOY_ROOT/docker/registry/auth' '$DEPLOY_ROOT/dynamic'"
scp docker-compose.production.yml \
docker-compose.portainer.production.yml \
docker-compose.registry.production.yml \
docker-compose.registry.local.yml \
traefik.yaml \
"${{ secrets.VPS_USER }}@${{ secrets.VPS_IP }}:$DEPLOY_ROOT/"
scp scripts/docker-prod-common.sh \
scripts/docker-prod-deploy.sh \
scripts/docker-prod-up-registry.sh \
scripts/docker-registry-local-up.sh \
"${{ secrets.VPS_USER }}@${{ secrets.VPS_IP }}:$DEPLOY_ROOT/scripts/"
scp docker/pgmanage/override.py \
"${{ secrets.VPS_USER }}@${{ secrets.VPS_IP }}:$DEPLOY_ROOT/docker/pgmanage/"
- name: Sync build context to VPS
env:
REMOTE_BUILD_DIR: ${{ env.DEPLOY_ROOT }}/build-context
VPS_HOST: ${{ secrets.VPS_IP }}
- name: Run deploy script on VPS
if: steps.check-creds.outputs.skip == 'false'
run: |
VPS_HOST="${VPS_HOST:-$DEPLOY_SSH_HOST}"
if [ -z "$VPS_HOST" ]; then
echo "::error::VPS_IP secret or DEPLOY_SSH_HOST is required"
exit 1
fi
SSH_OPTIONS="-i $HOME/.ssh/id_rsa -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=$HOME/.ssh/known_hosts"
ssh $SSH_OPTIONS "${{ secrets.VPS_USER }}@$VPS_HOST" \
"rm -rf '$REMOTE_BUILD_DIR' && mkdir -p '$REMOTE_BUILD_DIR'"
tar \
--exclude='.git' \
--exclude='node_modules' \
--exclude='.next' \
--exclude='dist' \
--exclude='coverage' \
-czf - . | ssh $SSH_OPTIONS "${{ secrets.VPS_USER }}@$VPS_HOST" \
"tar -xzf - -C '$REMOTE_BUILD_DIR'"
- name: Build and push on VPS
env:
PUSH_IMAGE: ${{ steps.registry-check.outputs.available == 'true' }}
IMAGE_FULL: ${{ steps.image-meta.outputs.full }}
IMAGE_LATEST: ${{ steps.image-meta.outputs.latest }}
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}
NEXT_PUBLIC_HOMEPAGE_URL: ${{ secrets.NEXT_PUBLIC_HOMEPAGE_URL }}
NEXT_PUBLIC_CARPLACE_URL: ${{ secrets.NEXT_PUBLIC_CARPLACE_URL }}
NEXT_PUBLIC_DASHBOARD_URL: ${{ secrets.NEXT_PUBLIC_DASHBOARD_URL }}
NEXT_PUBLIC_ADMIN_URL: ${{ secrets.NEXT_PUBLIC_ADMIN_URL }}
SITE_ORIGIN: ${{ secrets.SITE_ORIGIN }}
REMOTE_BUILD_DIR: ${{ env.DEPLOY_ROOT }}/build-context
VPS_HOST: ${{ secrets.VPS_IP }}
run: |
VPS_HOST="${VPS_HOST:-$DEPLOY_SSH_HOST}"
if [ -z "$VPS_HOST" ]; then
echo "::error::VPS_IP secret or DEPLOY_SSH_HOST is required"
exit 1
fi
SSH_OPTIONS="-i $HOME/.ssh/id_rsa -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=$HOME/.ssh/known_hosts"
REGISTRY_USERNAME="${REGISTRY_USERNAME:-${REGISTRY_USER:-}}"
REGISTRY_PASSWORD="${REGISTRY_PASSWORD:-${REGISTRY_TOKEN:-}}"
REGISTRY_PASSWORD_B64="$(printf '%s' "$REGISTRY_PASSWORD" | base64 | tr -d '\n')"
if [ -z "$NEXT_PUBLIC_CARPLACE_URL" ] && [ -n "$SITE_ORIGIN" ]; then
NEXT_PUBLIC_CARPLACE_URL="${SITE_ORIGIN%/}/carplace"
fi
ssh $SSH_OPTIONS "${{ secrets.VPS_USER }}@$VPS_HOST" "
REGISTRY_PASSWORD_B64="$(printf '%s' "${{ secrets.REGISTRY_PASSWORD }}" | base64 | tr -d '\n')"
ssh "${{ secrets.VPS_USER }}@${{ secrets.VPS_IP }}" "
set -e
cd '$REMOTE_BUILD_DIR'
if ! command -v docker >/dev/null 2>&1; then
echo 'Docker must be installed on the VPS build host' >&2
exit 1
fi
if [ '$PUSH_IMAGE' = 'true' ]; then
printf '%s' '$REGISTRY_PASSWORD_B64' | base64 -d | docker login '$DEPLOY_REGISTRY_HOST' \
--username '$REGISTRY_USERNAME' \
--password-stdin
fi
docker build \
--file '$DOCKERFILE_PATH' \
--platform '$DOCKER_PLATFORM' \
--tag '$IMAGE_FULL' \
--tag '$IMAGE_LATEST' \
--build-arg API_INTERNAL_URL=http://api:4000/api/v1 \
--build-arg DASHBOARD_INTERNAL_URL=http://dashboard:3001 \
--build-arg ADMIN_INTERNAL_URL=http://admin:3002 \
--build-arg NEXT_PUBLIC_API_URL='$NEXT_PUBLIC_API_URL' \
--build-arg NEXT_PUBLIC_HOMEPAGE_URL='$NEXT_PUBLIC_HOMEPAGE_URL' \
--build-arg NEXT_PUBLIC_CARPLACE_URL='$NEXT_PUBLIC_CARPLACE_URL' \
--build-arg NEXT_PUBLIC_DASHBOARD_URL='$NEXT_PUBLIC_DASHBOARD_URL' \
--build-arg NEXT_PUBLIC_ADMIN_URL='$NEXT_PUBLIC_ADMIN_URL' \
--build-arg SITE_ORIGIN='$SITE_ORIGIN' \
.
if [ '$PUSH_IMAGE' = 'true' ]; then
docker push '$IMAGE_FULL'
docker push '$IMAGE_LATEST'
fi
"
- name: Deploy production stack on VPS
env:
IMAGE_REPOSITORY: ${{ steps.image-meta.outputs.repository }}
IMAGE_TAG: ${{ gitea.sha }}
ENV_DOCKER_PRODUCTION: ${{ secrets.ENV_DOCKER_PRODUCTION }}
ENV_DOCKER_PRODUCTION_B64: ${{ secrets.ENV_DOCKER_PRODUCTION_B64 }}
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
REMOTE_BUILD_DIR: ${{ env.DEPLOY_ROOT }}/build-context
VPS_HOST: ${{ secrets.VPS_IP }}
run: |
VPS_HOST="${VPS_HOST:-$DEPLOY_SSH_HOST}"
if [ -z "$VPS_HOST" ]; then
echo "::error::VPS_IP secret or DEPLOY_SSH_HOST is required"
exit 1
fi
SSH_OPTIONS="-i $HOME/.ssh/id_rsa -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=$HOME/.ssh/known_hosts"
ENV_DOCKER_PRODUCTION_B64_CLEAN="$(printf '%s' "$ENV_DOCKER_PRODUCTION_B64" | tr -d '[:space:]')"
ENV_DOCKER_PRODUCTION_RAW_B64="$(printf '%s' "$ENV_DOCKER_PRODUCTION" | base64 | tr -d '\n')"
REGISTRY_USERNAME="${REGISTRY_USERNAME:-${REGISTRY_USER:-}}"
REGISTRY_PASSWORD="${REGISTRY_PASSWORD:-${REGISTRY_TOKEN:-}}"
REGISTRY_PASSWORD_B64="$(printf '%s' "$REGISTRY_PASSWORD" | base64 | tr -d '\n')"
ssh $SSH_OPTIONS "${{ secrets.VPS_USER }}@$VPS_HOST" "
set -e
mkdir -p '$DEPLOY_ROOT' '$REMOTE_BUILD_DIR'
if [ -n '$ENV_DOCKER_PRODUCTION_B64_CLEAN' ]; then
printf '%s' '$ENV_DOCKER_PRODUCTION_B64_CLEAN' | base64 -d > '$DEPLOY_ROOT/.env.docker.production'
chmod 600 '$DEPLOY_ROOT/.env.docker.production'
elif [ -n '$ENV_DOCKER_PRODUCTION_RAW_B64' ]; then
printf '%s' '$ENV_DOCKER_PRODUCTION_RAW_B64' | base64 -d > '$DEPLOY_ROOT/.env.docker.production'
chmod 600 '$DEPLOY_ROOT/.env.docker.production'
elif [ ! -f '$DEPLOY_ROOT/.env.docker.production' ]; then
echo 'ENV_DOCKER_PRODUCTION or ENV_DOCKER_PRODUCTION_B64 is not set, and $DEPLOY_ROOT/.env.docker.production does not exist on the VPS' >&2
exit 1
else
echo 'Using existing $DEPLOY_ROOT/.env.docker.production on the VPS'
fi
cp '$DEPLOY_ROOT/.env.docker.production' '$REMOTE_BUILD_DIR/.env.docker.production'
chmod 600 '$REMOTE_BUILD_DIR/.env.docker.production'
cd '$REMOTE_BUILD_DIR'
export APP_IMAGE='$DEPLOY_REGISTRY_HOST/$IMAGE_REPOSITORY'
export IMAGE_TAG='$IMAGE_TAG'
export REGISTRY_HOST='$DEPLOY_REGISTRY_HOST'
export REGISTRY_USER='$REGISTRY_USERNAME'
export REGISTRY_PASSWORD=\"\$(printf '%s' '$REGISTRY_PASSWORD_B64' | base64 -d)\"
cd '$DEPLOY_ROOT'
APP_IMAGE='$DEPLOY_REGISTRY_HOST/$IMAGE_REPOSITORY' \
APP_VERSION='${{ gitea.sha }}' \
IMAGE_TAG='${{ gitea.sha }}' \
REGISTRY_HOST='$DEPLOY_REGISTRY_HOST' \
REGISTRY_USER='${{ secrets.REGISTRY_USERNAME }}' \
REGISTRY_PASSWORD=\$(printf '%s' '$REGISTRY_PASSWORD_B64' | base64 -d) \
bash scripts/docker-prod-deploy.sh
"
+147 -28
View File
@@ -24,10 +24,27 @@ jobs:
name: Type Check (all packages)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Check out repository
shell: bash
run: |
set -euo pipefail
if [ -z "$(find . -mindepth 1 -maxdepth 1 -print -quit)" ]; then
REPOSITORY_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
git init .
git remote add origin "$REPOSITORY_URL"
if [ -n "${{ secrets.GITHUB_TOKEN }}" ]; then
AUTH_HEADER="$(printf '%s:%s' "${{ gitea.actor }}" "${{ secrets.GITHUB_TOKEN }}" | base64 | tr -d '\n')"
git -c http.extraheader="Authorization: Basic ${AUTH_HEADER}" fetch --depth 1 origin "${{ gitea.ref }}"
else
git fetch --depth 1 origin "${{ gitea.ref }}"
fi
git checkout --detach "${{ gitea.sha }}" || git checkout --detach FETCH_HEAD
fi
- name: Check Node runtime
run: |
node --version
npm --version
- run: corepack enable && corepack prepare npm@10.5.0 --activate
- name: Install dependencies
run: |
@@ -62,10 +79,27 @@ jobs:
runs-on: ubuntu-latest
needs: type-check
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Check out repository
shell: bash
run: |
set -euo pipefail
if [ -z "$(find . -mindepth 1 -maxdepth 1 -print -quit)" ]; then
REPOSITORY_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
git init .
git remote add origin "$REPOSITORY_URL"
if [ -n "${{ secrets.GITHUB_TOKEN }}" ]; then
AUTH_HEADER="$(printf '%s:%s' "${{ gitea.actor }}" "${{ secrets.GITHUB_TOKEN }}" | base64 | tr -d '\n')"
git -c http.extraheader="Authorization: Basic ${AUTH_HEADER}" fetch --depth 1 origin "${{ gitea.ref }}"
else
git fetch --depth 1 origin "${{ gitea.ref }}"
fi
git checkout --detach "${{ gitea.sha }}" || git checkout --detach FETCH_HEAD
fi
- name: Check Node runtime
run: |
node --version
npm --version
- run: corepack enable && corepack prepare npm@10.5.0 --activate
- name: Install dependencies
run: |
@@ -100,10 +134,27 @@ jobs:
runs-on: ubuntu-latest
needs: type-check
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Check out repository
shell: bash
run: |
set -euo pipefail
if [ -z "$(find . -mindepth 1 -maxdepth 1 -print -quit)" ]; then
REPOSITORY_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
git init .
git remote add origin "$REPOSITORY_URL"
if [ -n "${{ secrets.GITHUB_TOKEN }}" ]; then
AUTH_HEADER="$(printf '%s:%s' "${{ gitea.actor }}" "${{ secrets.GITHUB_TOKEN }}" | base64 | tr -d '\n')"
git -c http.extraheader="Authorization: Basic ${AUTH_HEADER}" fetch --depth 1 origin "${{ gitea.ref }}"
else
git fetch --depth 1 origin "${{ gitea.ref }}"
fi
git checkout --detach "${{ gitea.sha }}" || git checkout --detach FETCH_HEAD
fi
- name: Check Node runtime
run: |
node --version
npm --version
- run: corepack enable && corepack prepare npm@10.5.0 --activate
- name: Install dependencies
run: |
@@ -138,10 +189,27 @@ jobs:
runs-on: ubuntu-latest
needs: type-check
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Check out repository
shell: bash
run: |
set -euo pipefail
if [ -z "$(find . -mindepth 1 -maxdepth 1 -print -quit)" ]; then
REPOSITORY_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
git init .
git remote add origin "$REPOSITORY_URL"
if [ -n "${{ secrets.GITHUB_TOKEN }}" ]; then
AUTH_HEADER="$(printf '%s:%s' "${{ gitea.actor }}" "${{ secrets.GITHUB_TOKEN }}" | base64 | tr -d '\n')"
git -c http.extraheader="Authorization: Basic ${AUTH_HEADER}" fetch --depth 1 origin "${{ gitea.ref }}"
else
git fetch --depth 1 origin "${{ gitea.ref }}"
fi
git checkout --detach "${{ gitea.sha }}" || git checkout --detach FETCH_HEAD
fi
- name: Check Node runtime
run: |
node --version
npm --version
- run: corepack enable && corepack prepare npm@10.5.0 --activate
- name: Install dependencies
run: |
@@ -176,10 +244,27 @@ jobs:
runs-on: ubuntu-latest
needs: type-check
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Check out repository
shell: bash
run: |
set -euo pipefail
if [ -z "$(find . -mindepth 1 -maxdepth 1 -print -quit)" ]; then
REPOSITORY_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
git init .
git remote add origin "$REPOSITORY_URL"
if [ -n "${{ secrets.GITHUB_TOKEN }}" ]; then
AUTH_HEADER="$(printf '%s:%s' "${{ gitea.actor }}" "${{ secrets.GITHUB_TOKEN }}" | base64 | tr -d '\n')"
git -c http.extraheader="Authorization: Basic ${AUTH_HEADER}" fetch --depth 1 origin "${{ gitea.ref }}"
else
git fetch --depth 1 origin "${{ gitea.ref }}"
fi
git checkout --detach "${{ gitea.sha }}" || git checkout --detach FETCH_HEAD
fi
- name: Check Node runtime
run: |
node --version
npm --version
- run: corepack enable && corepack prepare npm@10.5.0 --activate
- name: Install dependencies
run: |
@@ -213,10 +298,27 @@ jobs:
runs-on: ubuntu-latest
needs: type-check
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Check out repository
shell: bash
run: |
set -euo pipefail
if [ -z "$(find . -mindepth 1 -maxdepth 1 -print -quit)" ]; then
REPOSITORY_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
git init .
git remote add origin "$REPOSITORY_URL"
if [ -n "${{ secrets.GITHUB_TOKEN }}" ]; then
AUTH_HEADER="$(printf '%s:%s' "${{ gitea.actor }}" "${{ secrets.GITHUB_TOKEN }}" | base64 | tr -d '\n')"
git -c http.extraheader="Authorization: Basic ${AUTH_HEADER}" fetch --depth 1 origin "${{ gitea.ref }}"
else
git fetch --depth 1 origin "${{ gitea.ref }}"
fi
git checkout --detach "${{ gitea.sha }}" || git checkout --detach FETCH_HEAD
fi
- name: Check Node runtime
run: |
node --version
npm --version
- run: corepack enable && corepack prepare npm@10.5.0 --activate
- name: Install dependencies
run: |
@@ -277,10 +379,27 @@ jobs:
JWT_EXPIRY: 8h
FILE_STORAGE_ROOT: /tmp/rentaldrivego-test-storage
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Check out repository
shell: bash
run: |
set -euo pipefail
if [ -z "$(find . -mindepth 1 -maxdepth 1 -print -quit)" ]; then
REPOSITORY_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
git init .
git remote add origin "$REPOSITORY_URL"
if [ -n "${{ secrets.GITHUB_TOKEN }}" ]; then
AUTH_HEADER="$(printf '%s:%s' "${{ gitea.actor }}" "${{ secrets.GITHUB_TOKEN }}" | base64 | tr -d '\n')"
git -c http.extraheader="Authorization: Basic ${AUTH_HEADER}" fetch --depth 1 origin "${{ gitea.ref }}"
else
git fetch --depth 1 origin "${{ gitea.ref }}"
fi
git checkout --detach "${{ gitea.sha }}" || git checkout --detach FETCH_HEAD
fi
- name: Check Node runtime
run: |
node --version
npm --version
- run: corepack enable && corepack prepare npm@10.5.0 --activate
- name: Install dependencies
run: |
+2 -2
View File
@@ -28,14 +28,14 @@ api_tests:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
carplace_tests:
storefront_tests:
stage: test
image: node:20-bookworm
before_script:
- npm ci
- npm run build --workspace @rentaldrivego/types
script:
- npm run test:carplace
- npm run test:storefront
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
+2 -2
View File
@@ -7,7 +7,7 @@ The Phase 16 visual system has been applied at source level to the legacy applic
- `homepage`: retained as the authoritative Phase 16 marketing implementation already present in the legacy archive.
- `admin`: migrated to the Phase 16 token palette and component treatment; navigation was rebuilt as a responsive, accessible application shell.
- `dashboard`: migrated to the same semantic surfaces, typography, borders, shadows, focus treatment, dark theme, and blue/orange action hierarchy.
- `carplace`: migrated to the same public-site surfaces, conversion treatment, cards, forms, dark theme, and brand naming.
- `storefront`: migrated to the same public-site surfaces, conversion treatment, cards, forms, dark theme, and brand naming.
- `api`: intentionally unchanged. A visual migration should not casually rewrite business logic because that is how weekends disappear.
## Design rules applied
@@ -29,7 +29,7 @@ The supplied `RentalDriveGo_Phase16_Evidence_Package_v1.0.zip` is an evidence an
- `admin/src/styles/phase16-tokens.css`
- `dashboard/src/styles/phase16-tokens.css`
- `carplace/src/styles/phase16-tokens.css`
- `storefront/src/styles/phase16-tokens.css`
- `scripts/validate-phase16-design.mjs`
- `DESIGN_MIGRATION_REPORT.md`
- `PHASE16_DESIGN_MIGRATION_MANIFEST.json`
+3 -3
View File
@@ -7,14 +7,14 @@
3. Added `PublicPageLayout` as the reusable composition layer for authentication and onboarding pages.
4. Updated sign-in, create-account, forgot-password, reset-password, verify-email, onboarding, and invitation pages to use the public component API.
5. Preserved legacy component paths through compatibility exports.
6. Moved carplace navbar/footer assembly out of the Next.js route layout and into reusable public components.
6. Moved storefront navbar/footer assembly out of the Next.js route layout and into reusable public components.
7. Added active-page semantics to dashboard sign-in and create-account navbar actions.
8. Added focused tests and implementation documentation.
## Deliberately unchanged
- Authentication requests and API endpoints
- Redirect behavior between carplace, dashboard, and admin applications
- Redirect behavior between storefront, dashboard, and admin applications
- Language and theme persistence
- Embedded workspace behavior
- Authenticated dashboard and admin navigation
@@ -25,7 +25,7 @@
- Verified all relative and `@/` local imports resolve.
- Verified every `use client` directive remains the first statement.
- Verified sign-in and create-account import and render `PublicPageLayout`.
- Verified the carplace route layout no longer assembles navbar/footer inline.
- Verified the storefront route layout no longer assembles navbar/footer inline.
- Verified embedded password-recovery navigation preserves `embedded=1`.
## Validation limitation
+7 -7
View File
@@ -11,7 +11,7 @@
"homepage": "retained as embedded Phase 16 source of truth",
"admin": "migrated",
"dashboard": "migrated",
"carplace": "migrated",
"storefront": "migrated",
"api": "unchanged"
},
"validation": {
@@ -37,37 +37,37 @@
"sha256": "7a35fad2750bccc09c24f24c1d57594048b040f24d4cde7639fa1a2ce3d63e64"
},
{
"path": "carplace/src/app/layout.tsx",
"path": "storefront/src/app/layout.tsx",
"status": "modified",
"bytes": 1833,
"sha256": "6fed4ef50a886e1c551b6456b46c5fc785b02a4be988aef06fa95c28443bbd27"
},
{
"path": "carplace/src/app/globals.css",
"path": "storefront/src/app/globals.css",
"status": "modified",
"bytes": 7525,
"sha256": "49e03206558248eb5de2b9a8463be6e0b43e01df518bc0b625fe2953a18185c6"
},
{
"path": "carplace/src/components/CarplaceFooter.tsx",
"path": "storefront/src/components/StorefrontFooter.tsx",
"status": "modified",
"bytes": 4632,
"sha256": "169d04dcf0976d2637ef5371ca295e9c362bd089d8d6ec313b4e8b4131d5bdd3"
},
{
"path": "carplace/src/components/CarplaceHeader.tsx",
"path": "storefront/src/components/StorefrontHeader.tsx",
"status": "modified",
"bytes": 10438,
"sha256": "25afc57ff9893f255eddf19580e84b510e6032764daaa5727e2e8d669dca4213"
},
{
"path": "carplace/src/styles/phase16-tokens.css",
"path": "storefront/src/styles/phase16-tokens.css",
"status": "added",
"bytes": 4214,
"sha256": "0e7fb35fb2833ff564c1188e39735349a8df901938fa87f2dc1b931dd4f04cd0"
},
{
"path": "carplace/src/app/(public)/explore/[slug]/vehicles/[id]/page.tsx",
"path": "storefront/src/app/(public)/explore/[slug]/vehicles/[id]/page.tsx",
"status": "modified",
"bytes": 13979,
"sha256": "9edf8da13e50cabcb2a126b300c71cb0b7a096a31d11d364e37dc09eeb625d3b"
+1 -1
View File
@@ -7,7 +7,7 @@
],
"includedApplications": [
"homepage",
"carplace",
"storefront",
"dashboard",
"admin",
"api"
+1 -1
View File
@@ -13,7 +13,7 @@ The dashboard and admin applications now use the same visual language as the Pha
The previous navbar/footer refactor archive omitted the `homepage` and `api` applications. This package was rebuilt from the complete Phase 16 archive, then overlaid with the public navbar/footer refactor before the operational UI migration. The final repository contains:
- `homepage`
- `carplace`
- `storefront`
- `dashboard`
- `admin`
- `api`
+2 -2
View File
@@ -17,9 +17,9 @@
| Light/dark and Arabic RTL token mappings present | Passed |
| Reduced-motion and forced-colors rules present | Passed |
| Shared sign-in/create-account public layout retained | Passed |
| Shared carplace navbar/footer components retained | Passed |
| Shared storefront navbar/footer components retained | Passed |
| Retired `RentalDriveGo` brand in dashboard/admin source | 0 matches |
| Complete app directories retained | `homepage`, `carplace`, `dashboard`, `admin`, `api` |
| Complete app directories retained | `homepage`, `storefront`, `dashboard`, `admin`, `api` |
## Reproducible design gate
@@ -60,15 +60,15 @@ Changed files:
- `apps/api/src/app.ts`
- `apps/dashboard/src/middleware.ts`
- `apps/carplace/src/middleware.ts`
- `apps/storefront/src/middleware.ts`
- `apps/admin/src/middleware.ts`
- `apps/dashboard/src/middleware.test.ts`
- `apps/carplace/src/middleware.test.ts`
- `apps/storefront/src/middleware.test.ts`
What changed:
- API now rejects requests containing `x-middleware-subrequest` before route handling.
- Dashboard, carplace, and admin Next middleware now reject the same header at the app layer.
- Dashboard, storefront, and admin Next middleware now reject the same header at the app layer.
- Added/updated middleware tests for the rejection path.
Security effect:
@@ -21,9 +21,6 @@ export default function CarplaceSearchForm({ cities, copy, initial = {}, compact
const [dropoffMode, setDropoffMode] = useState<'same' | 'different'>(initial.dropoffMode === 'different' ? 'different' : 'same')
const [dateError, setDateError] = useState<string | null>(null)
const today = new Date().toISOString().slice(0, 10)
const fieldsGridClass = compact ? 'mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-4' : 'mt-4 grid gap-3 md:grid-cols-2'
const secondaryControlsClass = compact ? 'mt-3 flex flex-col gap-3 lg:flex-row lg:flex-wrap lg:items-end' : 'mt-3 grid gap-3'
const submitButtonClass = compact ? 'carplace-primary-button justify-center lg:ms-auto' : 'carplace-primary-button justify-center sm:w-max'
function validateDates(event: FormEvent<HTMLFormElement>) {
const data = new FormData(event.currentTarget)
@@ -40,7 +37,7 @@ export default function CarplaceSearchForm({ cities, copy, initial = {}, compact
return (
<form action={carplaceHref('/search')} method="get" onSubmit={validateDates} className={compact ? 'carplace-search carplace-search-compact' : 'carplace-search'}>
{!compact ? <h2 className="text-xl font-black text-blue-950 dark:text-white">{copy.search.title}</h2> : null}
<div className={fieldsGridClass}>
<div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<SearchField icon={MapPin} label={copy.search.pickup}>
<input list="carplace-cities" name="pickupLocation" defaultValue={initial.pickupLocation ?? ''} placeholder={copy.search.locationPlaceholder} required className="carplace-input" />
</SearchField>
@@ -52,20 +49,20 @@ export default function CarplaceSearchForm({ cities, copy, initial = {}, compact
)}
</SearchField>
<SearchField icon={CalendarDays} label={copy.search.pickupDate}>
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_7rem]">
<div className="grid grid-cols-[1fr_7rem] gap-2">
<input type="date" name="pickupDate" min={today} defaultValue={initial.pickupDate ?? ''} required className="carplace-input min-w-0" />
<input type="time" name="pickupTime" defaultValue={initial.pickupTime ?? '10:00'} required className="carplace-input min-w-0" aria-label={copy.search.pickupTime} />
</div>
</SearchField>
<SearchField icon={CalendarDays} label={copy.search.returnDate}>
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_7rem]">
<div className="grid grid-cols-[1fr_7rem] gap-2">
<input type="date" name="returnDate" min={today} defaultValue={initial.returnDate ?? ''} required className="carplace-input min-w-0" />
<input type="time" name="returnTime" defaultValue={initial.returnTime ?? '10:00'} required className="carplace-input min-w-0" aria-label={copy.search.returnTime} />
</div>
</SearchField>
</div>
<div className={secondaryControlsClass}>
<div className="mt-3 flex flex-col gap-3 lg:flex-row lg:items-end">
<div className="flex flex-wrap gap-2" role="group" aria-label={copy.search.returnLocation}>
<label className={`carplace-choice ${dropoffMode === 'same' ? 'carplace-choice-active' : ''}`}>
<input type="radio" name="dropoffMode" value="same" checked={dropoffMode === 'same'} onChange={() => setDropoffMode('same')} className="sr-only" />
@@ -77,7 +74,7 @@ export default function CarplaceSearchForm({ cities, copy, initial = {}, compact
</label>
</div>
<div className="grid min-w-0 flex-1 gap-3 sm:grid-cols-2 lg:max-w-xl">
<div className="grid flex-1 gap-3 sm:grid-cols-2 lg:max-w-xl">
<label className="grid gap-1 text-xs font-bold text-stone-600 dark:text-slate-300">
{copy.search.age}
<select name="driverAge" defaultValue={initial.driverAge ?? '25'} className="carplace-input">
@@ -90,7 +87,7 @@ export default function CarplaceSearchForm({ cities, copy, initial = {}, compact
</label>
</div>
<button type="submit" className={submitButtonClass}>
<button type="submit" className="carplace-primary-button lg:ms-auto">
<Search className="h-4 w-4" />
{copy.actions.search}
</button>
+12 -10
View File
@@ -4,18 +4,20 @@ import TopBar from '@/components/layout/TopBar'
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<div className="fleet-dashboard-shell flex min-h-screen transition-colors print:block print:min-h-0 print:bg-white">
<div className="print:hidden">
<Sidebar />
</div>
<div className="ms-0 flex min-h-screen min-w-0 flex-1 flex-col lg:ms-64 print:ms-0 print:min-h-0 print:block">
<DashboardAccessGuard>
<div className="fleet-dashboard-shell flex min-h-screen transition-colors print:block print:min-h-0 print:bg-white">
<div className="print:hidden">
<TopBar />
<Sidebar />
</div>
<div className="ms-0 flex min-h-screen min-w-0 flex-1 flex-col lg:ms-64 print:ms-0 print:min-h-0 print:block">
<div className="print:hidden">
<TopBar />
</div>
<main className="flex-1 overflow-y-auto p-6 print:p-0 print:overflow-visible">
{children}
</main>
</div>
<main className="flex-1 overflow-y-auto p-6 print:p-0 print:overflow-visible">
<DashboardAccessGuard>{children}</DashboardAccessGuard>
</main>
</div>
</div>
</DashboardAccessGuard>
)
}
+4 -4
View File
@@ -122,7 +122,7 @@ services:
restart: unless-stopped
labels:
- traefik.enable=true
- traefik.http.routers.api.rule=Host(`${API_DOMAIN}`) && !HeaderRegexp(`x-middleware-subrequest`, `.+`)
- traefik.http.routers.api.rule=Host(`${API_DOMAIN}`) && !HeadersRegexp(`x-middleware-subrequest`, `.+`)
- traefik.http.routers.api.entrypoints=websecure
- traefik.http.routers.api.tls=true
- traefik.http.routers.api.tls.certresolver=letsencrypt
@@ -169,7 +169,7 @@ services:
restart: unless-stopped
labels:
- traefik.enable=true
- traefik.http.routers.homepage.rule=(Host(`${PUBLIC_SITE_DOMAIN}`) || Host(`www.${PUBLIC_SITE_DOMAIN}`)) && !HeaderRegexp(`x-middleware-subrequest`, `.+`)
- traefik.http.routers.homepage.rule=(Host(`${PUBLIC_SITE_DOMAIN}`) || Host(`www.${PUBLIC_SITE_DOMAIN}`)) && !HeadersRegexp(`x-middleware-subrequest`, `.+`)
- traefik.http.routers.homepage.entrypoints=websecure
- traefik.http.routers.homepage.tls=true
- traefik.http.routers.homepage.tls.certresolver=letsencrypt
@@ -255,7 +255,7 @@ services:
restart: unless-stopped
labels:
- traefik.enable=true
- traefik.http.routers.dashboard.rule=(Host(`${PUBLIC_SITE_DOMAIN}`) || Host(`www.${PUBLIC_SITE_DOMAIN}`)) && PathPrefix(`/dashboard`) && !HeaderRegexp(`x-middleware-subrequest`, `.+`)
- traefik.http.routers.dashboard.rule=(Host(`${PUBLIC_SITE_DOMAIN}`) || Host(`www.${PUBLIC_SITE_DOMAIN}`)) && PathPrefix(`/dashboard`) && !HeadersRegexp(`x-middleware-subrequest`, `.+`)
- traefik.http.routers.dashboard.entrypoints=websecure
- traefik.http.routers.dashboard.tls=true
- traefik.http.routers.dashboard.tls.certresolver=letsencrypt
@@ -298,7 +298,7 @@ services:
restart: unless-stopped
labels:
- traefik.enable=true
- traefik.http.routers.admin.rule=(Host(`${PUBLIC_SITE_DOMAIN}`) || Host(`www.${PUBLIC_SITE_DOMAIN}`)) && PathPrefix(`/admin`) && !HeaderRegexp(`x-middleware-subrequest`, `.+`)
- traefik.http.routers.admin.rule=(Host(`${PUBLIC_SITE_DOMAIN}`) || Host(`www.${PUBLIC_SITE_DOMAIN}`)) && PathPrefix(`/admin`) && !HeadersRegexp(`x-middleware-subrequest`, `.+`)
- traefik.http.routers.admin.entrypoints=websecure
- traefik.http.routers.admin.tls=true
- traefik.http.routers.admin.tls.certresolver=letsencrypt
+2 -2
View File
@@ -28,7 +28,7 @@ This reduces cognitive load, reduces privacy anxiety, and makes mobile completio
Replace the current booking form with the revised version in:
`apps/carplace/src/components/BookingForm.tsx`
`apps/storefront/src/components/BookingForm.tsx`
Acceptance criteria:
@@ -50,7 +50,7 @@ Recommended behavior:
Backend candidate:
- Add or expose `GET /carplace/:slug/vehicles/:id/availability?startDate=&endDate=`.
- Add or expose `GET /storefront/:slug/vehicles/:id/availability?startDate=&endDate=`.
- Reuse the existing `getVehicleAvailabilitySummary` service instead of inventing yet another inconsistent truth source, because apparently systems enjoy lying to themselves.
### Phase 3: Booking Status Transparency
+8 -116
View File
@@ -151,7 +151,6 @@ docker network create traefik-proxy
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 |
@@ -162,14 +161,6 @@ Open `.env.docker.production` and fill in every value. The minimum required secr
| `RESEND_API_KEY` | Resend API key (or configure SMTP vars instead) |
| `PGMANAGE_DOMAIN` | Hostname for pgManage, e.g. `pgmanage.rentaldrivego.ma` |
For Gitea Actions deploys, either store the completed production env file as the raw `ENV_DOCKER_PRODUCTION` secret or as the base64-encoded `ENV_DOCKER_PRODUCTION_B64` secret:
```bash
base64 < .env.docker.production | tr -d '\n'
```
Paste that single-line output into `ENV_DOCKER_PRODUCTION_B64` when using the base64 option. During deploy, the workflow writes the env file to `/opt/rentaldrivego/.env.docker.production` on the VPS with `600` permissions before running `scripts/docker-prod-deploy.sh`. If neither production env secret is set, the workflow reuses `/opt/rentaldrivego/.env.docker.production` when it already exists on the VPS.
Production now derives `DATABASE_URL` inside the app container from `POSTGRES_HOST`, `POSTGRES_PORT`, `POSTGRES_DB`, `POSTGRES_USER`, and `POSTGRES_PASSWORD` when `DATABASE_URL_FROM_POSTGRES=true`. That avoids Prisma auth failures when the database password contains reserved URL characters such as `@`, `:`, or `/`.
The example file uses `rentaldrivego.ma` for the carplace and public site. The dashboard and admin panel are routed under that same host at `/dashboard` and `/admin`.
@@ -220,99 +211,24 @@ REGISTRY_USER=<registry-user>
REGISTRY_PASSWORD=<registry-password>
```
For the deploy job, add these Gitea Actions secrets as well:
For the deploy job, add these GitLab CI variables as well:
```text
VPS_IP=<server-ip-or-hostname>
VPS_USER=<ssh-user>
VPS_SSH_KEY=<deployment-private-key>
SSH_PRIVATE_KEY=<deployment-private-key>
```
`VPS_IP` overrides the workflow's default `DEPLOY_SSH_HOST`. If `VPS_IP` is not set, the workflow uses `DEPLOY_SSH_HOST` from `.gitea/workflows/build-and-deploy.yml`.
`SSH_PRIVATE_KEY` must be an unencrypted private key that OpenSSH can read in a non-interactive job. The pipeline accepts any of these formats:
`VPS_SSH_KEY` must be an unencrypted private key that OpenSSH can read in a non-interactive job. The workflow also accepts `VPS_SSH_KEY_B64` when you prefer storing a base64-encoded private key as a single line.
If both are set, `VPS_SSH_KEY_B64` is used first; when it is not valid base64, the workflow falls back to `VPS_SSH_KEY`.
Supported private key formats:
- Standard multiline key pasted directly into `VPS_SSH_KEY`
- Single-line key with literal `\n` newline escapes in `VPS_SSH_KEY`
- Base64-encoded private key stored as a single line in `VPS_SSH_KEY_B64`
- GitLab `File` variable containing the private key
- Standard multiline key pasted directly into the variable value
- Single-line key with literal `\n` newline escapes
- Base64-encoded private key stored as a single line
If your key is passphrase-protected, generate a dedicated deploy key without a passphrase for CI instead of reusing an interactive workstation key.
To create a new dedicated deploy key, run this on your workstation or another trusted admin machine, not inside Gitea:
```bash
ssh-keygen -t ed25519 -f ./rentaldrivego_gitea_deploy -C "gitea-actions-rentaldrivego" -N ""
ssh-keygen -lf ./rentaldrivego_gitea_deploy.pub
base64 < ./rentaldrivego_gitea_deploy | tr -d '\n'
```
Use the generated files like this:
- `rentaldrivego_gitea_deploy`: private key. Store this in Gitea as either raw `VPS_SSH_KEY` or base64-encoded `VPS_SSH_KEY_B64`.
- `rentaldrivego_gitea_deploy.pub`: public key. Install this on the VPS in `~/.ssh/authorized_keys` for the account configured as `VPS_USER`.
Do not put the `.pub` file or an `authorized_keys` line into `VPS_SSH_KEY` or `VPS_SSH_KEY_B64`. Gitea needs the private key; the VPS needs the public key.
Before saving the Gitea secret, verify the private key locally:
```bash
head -n 1 ./rentaldrivego_gitea_deploy
wc -c ./rentaldrivego_gitea_deploy
ssh-keygen -y -f ./rentaldrivego_gitea_deploy > ./rentaldrivego_gitea_deploy.pub
```
The first line must look like this:
```text
-----BEGIN OPENSSH PRIVATE KEY-----
```
The private key is usually hundreds of bytes. If the workflow reports a decoded byte count such as `79`, the Gitea secret is not the full private key. It is usually a public key, a fingerprint, or a truncated value.
To install the public key on the VPS, SSH into the server as the same account configured in `VPS_USER`, then run:
```bash
mkdir -p ~/.ssh
chmod 700 ~/.ssh
printf '%s\n' '<contents-of-rentaldrivego_gitea_deploy.pub>' >> ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys
ssh-keygen -lf ~/.ssh/authorized_keys
```
If you are logged in as `root` but `VPS_USER` is a different account such as `deploy`, install the public key under that user's home directory instead:
```bash
install -d -m 700 -o deploy -g deploy /home/deploy/.ssh
printf '%s\n' '<contents-of-rentaldrivego_gitea_deploy.pub>' >> /home/deploy/.ssh/authorized_keys
chown deploy:deploy /home/deploy/.ssh/authorized_keys
chmod 600 /home/deploy/.ssh/authorized_keys
ssh-keygen -lf /home/deploy/.ssh/authorized_keys
```
If SSH authentication fails after the workflow loads the key, compare the logged deploy key fingerprint with the key installed on the server:
```bash
ssh-keygen -lf ~/.ssh/authorized_keys
```
The matching public key must be present in `~/.ssh/authorized_keys` for the account named by `VPS_USER`.
For example, a failed workflow that logs fingerprint `SHA256:mX9s/EluBlPKuKA+Vmc6HrbO6hpDLjBHtRi33mEOyhY` requires the matching public key line on the VPS.
To install the workflow's logged `Deploy public key` value for the selected SSH user:
```bash
mkdir -p ~/.ssh
chmod 700 ~/.ssh
printf '%s\n' '<deploy-public-key-from-workflow-log>' >> ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys
```
Run those commands while logged in as the same account configured in `VPS_USER`. If `VPS_USER` is `deploy`, install the key under `/home/deploy/.ssh/authorized_keys`; if it is `root`, install it under `/root/.ssh/authorized_keys`.
The production compose file reads `APP_IMAGE` and `APP_VERSION` for pull-based deploys. The Gitea deploy job injects those values automatically and syncs the deployment assets to the VPS before running the server-side deploy script. Production no longer depends on `git pull` during release.
The production compose file reads `APP_IMAGE` and `APP_VERSION` for pull-based deploys. The GitLab deploy job injects those values automatically and now syncs the deployment assets to the VPS before running the server-side deploy script. Production no longer depends on `git pull` during release.
The CI pipeline publishes and deploys from the GitLab default branch (`$CI_DEFAULT_BRANCH`). If you change your release branch, update the repository default branch in GitLab instead of hard-coding branch names in `.gitlab-ci.yml`.
@@ -415,30 +331,6 @@ Expected response:
HTTP/1.1 401 Unauthorized
```
If CI uses the Gitea container registry, `DEPLOY_REGISTRY_HOST` in `.gitea/workflows/build-and-deploy.yml` must be the address the VPS can use to reach Gitea. In the VPN setup, use the Gitea VPN IP `10.0.0.4`, not the LAN IP `192.168.3.80`. From the VPS build host, verify Docker can reach it:
```bash
curl -vk https://10.0.0.4/v2/
docker login 10.0.0.4
```
If `curl` times out, fix network routing or firewall access from the VPS to the Gitea server before rerunning CI. If Docker reports a certificate error, install the Gitea registry CA certificate on the VPS Docker host or configure Docker for that internal registry according to your environment.
When CI builds on the VPS and pushes directly to a separate plain-HTTP local registry instead of Gitea, `DEPLOY_REGISTRY_HOST` must include the registry port, for example `10.0.0.4:5000`. Because that local registry is plain HTTP, configure the VPS Docker daemon once:
```json
{
"insecure-registries": ["10.0.0.4:5000"]
}
```
If `/etc/docker/daemon.json` already exists, merge the `insecure-registries` value with the existing JSON instead of replacing the file. Then restart Docker:
```bash
sudo systemctl restart docker
docker login 10.0.0.4:5000
```
Then on the VPS restart Traefik:
```bash
+12 -12
View File
@@ -1,10 +1,10 @@
# 🚗 RentalDriveGo— Multi-Tenant Car Rental SaaS + Carplace
# 🚗 RentalDriveGo— Multi-Tenant Car Rental SaaS + Storefront
## Platform Model
**Rental Companies (B2B)** — pay RentalDriveGoa subscription (AmanPay or PayPal), get:
- A fully private dashboard (zero data overlap with any other company)
- Fleet management with vehicle photo upload (photos auto-appear on global carplace)
- Fleet management with vehicle photo upload (photos auto-appear on global storefront)
- Promotional offers management
- A white-label site at `company.RentalDriveGo.com` where renters book and pay
@@ -29,21 +29,21 @@
---
## 🌐 Carplace is Discovery Only — No Booking on RentalDriveGo.com/explore
## 🌐 Storefront is Discovery Only — No Booking on RentalDriveGo.com/explore
The global carplace shows all vehicles with photos from all companies. When a renter clicks "Book this vehicle", they are **redirected to the company's branded site** (`{slug}.RentalDriveGo.com/book?vehicleId=X&from=Y&to=Z&ref=carplace`). The booking form is pre-filled. All payment happens on the company site using the company's own payment accounts.
The global storefront shows all vehicles with photos from all companies. When a renter clicks "Book this vehicle", they are **redirected to the company's branded site** (`{slug}.RentalDriveGo.com/book?vehicleId=X&from=Y&to=Z&ref=storefront`). The booking form is pre-filled. All payment happens on the company site using the company's own payment accounts.
---
## 📸 Vehicle Photos → Carplace Visibility
## 📸 Vehicle Photos → Storefront Visibility
```
Company uploads photos in /dashboard/fleet
→ Stored in Cloudinary
→ Vehicle.photos[] holds the URLs
→ First photo = cover image on carplace cards
→ All photos shown in carplace vehicle gallery
→ isPublished=true makes vehicle appear on carplace
→ First photo = cover image on storefront cards
→ All photos shown in storefront vehicle gallery
→ isPublished=true makes vehicle appear on storefront
```
---
@@ -52,7 +52,7 @@ Company uploads photos in /dashboard/fleet
```
Layer 1A: RentalDriveGo.com — B2B marketing site
Layer 1B: RentalDriveGo.com/explore — B2C carplace (discovery + redirect only)
Layer 1B: RentalDriveGo.com/explore — B2C storefront (discovery + redirect only)
Layer 2: app.RentalDriveGo.com — Company dashboard (private, subscription-gated)
Layer 3: {slug}.RentalDriveGo.com — Company branded site (booking + payment here)
Layer 4: api.RentalDriveGo.com — REST API
@@ -66,9 +66,9 @@ Layer 4: api.RentalDriveGo.com — REST API
rental-car-site/
├── README.md
├── docs/
│ ├── ARCHITECTURE.md ← Payment providers, carplace redirect model, photo flow
│ ├── ARCHITECTURE.md ← Payment providers, storefront redirect model, photo flow
│ ├── DESIGN_SYSTEM.md
│ ├── PAGES.md ← All pages. Carplace = discovery only. Booking = company site.
│ ├── PAGES.md ← All pages. Storefront = discovery only. Booking = company site.
│ ├── FEATURES.md
│ └── DEPLOYMENT.md
└── skills/
@@ -80,7 +80,7 @@ rental-car-site/
│ ├── api-routes.md ← All routes
│ ├── payment-service.md ← AmanPay + PayPal implementation ← KEY FILE
│ ├── subscription-service.md ← Manual renewal, plan prices in MAD/USD/EUR
│ ├── subdomain-service.md ← Carplace redirect + company site
│ ├── subdomain-service.md ← Storefront redirect + company site
│ └── notification-service.md ← All 5 channels
└── rental-car-i18n/
```
@@ -9,13 +9,13 @@
## 1. Purpose
This plan replaces the legacy RentalDriveGo carplace with Carplace, a new renter-first marketplace experience and retains the production dashboard authentication repair as a separate priority workstream.
This plan replaces the legacy RentalDriveGo storefront with Carplace, a new renter-first marketplace experience and retains the production dashboard authentication repair as a separate priority workstream.
### 1.1 Product naming
**Carplace** is the public product name for the renter marketplace. The word “carplace” is retained only when referring to the legacy implementation or existing technical paths during migration.
**Carplace** is the public product name for the renter marketplace. The word “storefront” is retained only when referring to the legacy implementation or existing technical paths during migration.
The canonical public route will become `/carplace`. Existing `/carplace` URLs must remain functional through permanent path-preserving redirects so bookmarks, indexed pages, and external company links do not break. The deployable application may remain at `apps/carplace` during the first implementation phase to avoid an unnecessary repository-wide rename while product behavior is still changing.
The canonical public route will become `/carplace`. Existing `/storefront` URLs must remain functional through permanent path-preserving redirects so bookmarks, indexed pages, and external company links do not break. The deployable application may remain at `apps/storefront` during the first implementation phase to avoid an unnecessary repository-wide rename while product behavior is still changing.
**Carplace is trilingual from its first release. English (`en`), French (`fr`), and Arabic (`ar`) are equal, required product languages. No page, workflow, validation message, metadata field, or release phase may be shipped in only one or two languages.**
@@ -28,14 +28,14 @@ The new Carplace marketplace must:
4. Preserve existing public URLs during migration.
5. Use real platform data and avoid unsupported claims such as instant confirmation, final-price guarantees, or renter accounts that the backend does not currently provide.
6. Allow controlled company branding without letting every company turn the page into an unrelated website.
7. Be deployable beside the legacy carplace and switched over safely.
7. Be deployable beside the legacy storefront and switched over safely.
8. Preserve full feature and content parity across English, French, and Arabic, including proper Arabic RTL behavior.
---
## 2. Findings From the Current Code
### 2.1 The legacy carplace is a collection of pages, not a coherent product
### 2.1 The legacy storefront is a collection of pages, not a coherent product
The current application has working foundations:
@@ -55,10 +55,10 @@ However, the user experience is assembled from independent cards and utility cla
### 2.2 Carplace and the homepage need one visual foundation
The legacy carplace currently renders:
The legacy storefront currently renders:
- `CarplaceHeader`
- `CarplaceFooter`
- `StorefrontHeader`
- `StorefrontFooter`
The homepage renders:
@@ -108,13 +108,13 @@ unless the backend and company settings later support those guarantees.
### 2.5 Renter account authentication is disabled
The API currently rejects renter sign-up and sign-in. The legacy carplace contains renter sign-in, registration, dashboard, saved-company, notification, and profile pages, but the authentication service intentionally disables login and registration.
The API currently rejects renter sign-up and sign-in. The legacy storefront contains renter sign-in, registration, dashboard, saved-company, notification, and profile pages, but the authentication service intentionally disables login and registration.
Carplace must therefore launch as a guest-first experience. Renter-account links must remain hidden until the backend capability is deliberately enabled and tested.
### 2.6 The legacy editable homepage content model is mismatched
The current admin-managed carplace homepage schema contains operator-oriented content such as:
The current admin-managed storefront homepage schema contains operator-oriented content such as:
- Start trial
- Create workspace
@@ -217,7 +217,7 @@ Secondary conversions are:
- Search-engine metadata
- Accessibility baseline
- Funnel analytics
- Safe cutover from the legacy carplace
- Safe cutover from the legacy storefront
### 4.2 Explicitly excluded from the first launch
@@ -238,9 +238,9 @@ These exclusions are deliberate. A strong marketplace is not one with the larges
## 5. Recommended Technical Strategy
### 5.1 Rebuild inside the legacy carplace application
### 5.1 Rebuild inside the legacy storefront application
Keep `apps/carplace` as the deployable application.
Keep `apps/storefront` as the deployable application.
Do not create another standalone Next.js application. A second Carplace app would duplicate:
@@ -257,7 +257,7 @@ The new experience should be implemented as a clean feature area inside the exis
Recommended structure:
```text
apps/carplace/src/
apps/storefront/src/
app/
(public)/
page.tsx
@@ -299,9 +299,9 @@ Use a server-side release setting such as:
CARPLACE_EXPERIENCE_VERSION=v2
```
During migration, the legacy `/carplace/*` routes should continue serving V1 until their matching Carplace routes are ready. Once a Carplace route passes its release gates, redirect the corresponding legacy path to `/carplace/*`.
During migration, the legacy `/storefront/*` routes should continue serving V1 until their matching Carplace routes are ready. Once a Carplace route passes its release gates, redirect the corresponding legacy path to `/carplace/*`.
Do not leave two independently indexed public URL systems. `/carplace/*` becomes canonical, while `/carplace/*` exists only as a compatibility redirect. Redirects must preserve the resolved language, path, query parameters, selected dates, locations, offer codes, and campaign attribution.
Do not leave two independently indexed public URL systems. `/carplace/*` becomes canonical, while `/storefront/*` exists only as a compatibility redirect. Redirects must preserve the resolved language, path, query parameters, selected dates, locations, offer codes, and campaign attribution.
### 5.3 Introduce Carplace routes without breaking legacy URLs
@@ -314,7 +314,7 @@ Keep these routes working:
/carplace/[company-slug]/vehicles/[vehicle-id]
```
Existing company and vehicle links must not break during cutover. Add permanent, path-preserving redirects from `/carplace/*` to `/carplace/*`, including query parameters used for dates, locations, offers, and campaign attribution. The redirect must also migrate the legacy `carplace-language` preference into the shared `rentaldrivego-language` cookie when needed.
Existing company and vehicle links must not break during cutover. Add permanent, path-preserving redirects from `/storefront/*` to `/carplace/*`, including query parameters used for dates, locations, offers, and campaign attribution. The redirect must also migrate the legacy `storefront-language` preference into the shared `rentaldrivego-language` cookie when needed.
### 5.4 Separate API response types from page components
@@ -507,7 +507,7 @@ Move renters immediately toward a useful search while establishing trust.
### Homepage content model
Create a dedicated `CarplaceHomepageConfig`, separate from the existing operator-focused `CarplaceHomepageConfig`.
Create a dedicated `CarplaceHomepageConfig`, separate from the existing operator-focused `StorefrontHomepageConfig`.
Suggested fields:
@@ -893,7 +893,7 @@ POST /api/v1/carplace/reservations
GET /api/v1/carplace/requests/:token
```
During migration, existing `/api/v1/carplace/*` endpoints should remain as compatibility aliases or internal adapters until all deployed clients use Carplace contract.
During migration, existing `/api/v1/storefront/*` endpoints should remain as compatibility aliases or internal adapters until all deployed clients use Carplace contract.
### 9.3 Marketplace-home aggregation
@@ -1027,7 +1027,7 @@ Resolve language on the server in this order:
1. Explicit valid language selected by the user.
2. Existing `rentaldrivego-language` cookie.
3. Migrated legacy `carplace-language` cookie.
3. Migrated legacy `storefront-language` cookie.
4. Supported browser `Accept-Language` value.
5. English fallback.
@@ -1038,7 +1038,7 @@ The language selector must:
- Preserve the current pathname and query string.
- Preserve search dates, locations, filters, sorting, offer codes, and attribution parameters.
- Preserve completed reservation-form fields when switching language during the request flow.
- Never redirect a Carplace user back to the legacy `/carplace` route.
- Never redirect a Carplace user back to the legacy `/storefront` route.
### 11.3 Translation architecture
@@ -1117,7 +1117,7 @@ Reusable vehicle specifications, transmission labels, category names, policy lab
The initial canonical route remains `/carplace`, using server-resolved language from the shared cookie so the migration does not simultaneously change product name, path structure, and localization architecture.
All `/carplace/*` redirects must preserve language preference and query state. Locale-prefixed Carplace URLs may be introduced later only as a deliberate SEO migration with complete redirects, canonical tags, `hreflang`, sitemap updates, and analytics normalization. Do not create three competing URL systems during the first cutover.
All `/storefront/*` redirects must preserve language preference and query state. Locale-prefixed Carplace URLs may be introduced later only as a deliberate SEO migration with complete redirects, canonical tags, `hreflang`, sitemap updates, and analytics normalization. Do not create three competing URL systems during the first cutover.
---
@@ -103,7 +103,7 @@ packages/i18n/
└── index.ts
```
Dashboard, Admin, and any future carplace or mobile application must consume the same translation package. Independent translation copies are prohibited because they create terminology drift.
Dashboard, Admin, and any future storefront or mobile application must consume the same translation package. Independent translation copies are prohibited because they create terminology drift.
Example menu definition:
@@ -38,7 +38,7 @@ The target design must:
The system will seed seven settings menu items:
1. Company Profile
2. Branding and Carplace
2. Branding and Storefront
3. Payment Methods
4. Rental Policies
5. Insurance Policies
@@ -74,7 +74,7 @@ Protected section routes:
```text
/dashboard/settings?section=company
/dashboard/settings?section=carplace
/dashboard/settings?section=storefront
/dashboard/settings?section=payments
/dashboard/settings?section=rental-policies
/dashboard/settings?section=insurance
@@ -149,10 +149,10 @@ Legend:
| Company profile | Edit | Edit | Edit |
| Public contact information | Edit | Edit | Edit |
| Default language and currency | Edit | Edit | Edit |
| Carplace listing toggle | Edit | Edit | Edit |
| Storefront listing toggle | Edit | Edit | Edit |
| Basic logo and public identity | Edit | Edit | Edit |
| Custom brand colors | Locked | Edit | Edit |
| Hero image and advanced carplace branding | Locked | Edit | Edit |
| Hero image and advanced storefront branding | Locked | Edit | Edit |
| Renter payment providers | Locked | Edit | Edit |
| Basic fuel and damage policies | Edit | Edit | Edit |
| Automated additional-driver fees | Locked | Edit | Edit |
@@ -178,7 +178,7 @@ Recommended keys:
settings.company_profile
settings.public_contact
settings.locale_currency
settings.carplace_listing
settings.storefront_listing
settings.branding_basic
settings.branding_custom
settings.branding_hero
@@ -232,26 +232,26 @@ Rules:
- It remains available during subscription recovery.
- Use the shared Moroccan city selector where applicable.
- Validate email, phone, website, locale, and currency.
- Explain whether the selected language affects the carplace, contracts, or both.
- Explain whether the selected language affects the storefront, contracts, or both.
---
## 7.2 Branding and Carplace
## 7.2 Branding and Storefront
### STARTER
Available:
- Carplace listing
- Storefront listing
- Company logo
- Public identity preview
- Basic carplace information
- Basic storefront information
Locked:
- Custom primary and accent colors
- Hero image
- Advanced carplace appearance controls
- Advanced storefront appearance controls
### GROWTH and PRO
@@ -260,7 +260,7 @@ Available:
- All STARTER branding capabilities
- Custom brand colors
- Hero image
- Full carplace branding controls
- Full storefront branding controls
### UX behavior
@@ -675,7 +675,7 @@ Enforcement must cover:
- Reservation price calculation
- Insurance selection
- Additional-driver fee calculation
- Carplace branding response
- Storefront branding response
- Online payment processing
- Scheduled report jobs
@@ -60,15 +60,15 @@ Changed files:
- `apps/api/src/app.ts`
- `apps/dashboard/src/middleware.ts`
- `apps/carplace/src/middleware.ts`
- `apps/storefront/src/middleware.ts`
- `apps/admin/src/middleware.ts`
- `apps/dashboard/src/middleware.test.ts`
- `apps/carplace/src/middleware.test.ts`
- `apps/storefront/src/middleware.test.ts`
What changed:
- API now rejects requests containing `x-middleware-subrequest` before route handling.
- Dashboard, carplace, and admin Next middleware now reject the same header at the app layer.
- Dashboard, storefront, and admin Next middleware now reject the same header at the app layer.
- Added/updated middleware tests for the rejection path.
Security effect:
+9 -9
View File
@@ -15,7 +15,7 @@ Creating an account registers a new rental company on the platform. The flow has
## 1. Accessing the Sign-up Page
### From the carplace homepage (port 3000)
### From the storefront homepage (port 3000)
- Click **"Create Agency Space"** in the header navigation.
- Click **"Get Started"** on any pricing plan card (`/pricing` page).
@@ -26,7 +26,7 @@ Both actions navigate to:
http://localhost:3000/dashboard/sign-up
```
The carplace proxies this request to the dashboard app (port 3001), which renders the multi-step form.
The storefront proxies this request to the dashboard app (port 3001), which renders the multi-step form.
### URL parameters (optional)
@@ -111,7 +111,7 @@ Select one of three plans:
| Plan | Description |
|---|---|
| **STARTER** | Core fleet, offers, and bookings |
| **GROWTH** | Featured carplace placement and more room to scale |
| **GROWTH** | Featured storefront placement and more room to scale |
| **PRO** | Full white-label and premium controls |
Additional options:
@@ -213,7 +213,7 @@ Navigate to `/sign-in` (redirects to `/dashboard/sign-in`). Enter the owner emai
After first sign-in, the user is directed to the **onboarding** page (`/onboarding`) where they can:
1. **Step 1 — Company profile**: Set display name, tagline, brand color, and public location.
2. **Step 2 — Payments**: Configure AmanPay merchant ID / PayPal email and carplace listing preference.
2. **Step 2 — Payments**: Configure AmanPay merchant ID / PayPal email and storefront listing preference.
3. **Step 3 — Completion**: Redirect to the dashboard home.
### Subscription
@@ -228,7 +228,7 @@ New accounts start on a **30-day free trial** with the selected plan. The subscr
| File | Purpose |
|---|---|
| `apps/carplace/src/app/renter/sign-up/page.tsx` | Carplace redirect page to dashboard sign-up |
| `apps/storefront/src/app/renter/sign-up/page.tsx` | Storefront redirect page to dashboard sign-up |
| `apps/dashboard/src/app/sign-up/[[...sign-up]]/page.tsx` | Main multi-step sign-up form (4 steps, 937 lines) |
| `apps/dashboard/src/components/ui/BilingualInput.tsx` | Bilingual (FR/AR) input component |
| `apps/dashboard/src/components/layout/PublicShell.tsx` | Public layout wrapper for the sign-up page |
@@ -241,10 +241,10 @@ New accounts start on a **30-day free trial** with the selected plan. The subscr
| File | Location |
|---|---|
| `apps/carplace/src/components/CarplaceHeader.tsx` | "Create Agency Space" button |
| `apps/carplace/src/components/WorkspaceTabs.tsx` | "Owner sign in" link |
| `apps/carplace/src/app/(public)/pricing/PricingClient.tsx` | "Get Started" pricing CTA |
| `apps/carplace/src/app/(public)/HomeContent.tsx` | Homepage CTA |
| `apps/storefront/src/components/StorefrontHeader.tsx` | "Create Agency Space" button |
| `apps/storefront/src/components/WorkspaceTabs.tsx` | "Owner sign in" link |
| `apps/storefront/src/app/(public)/pricing/PricingClient.tsx` | "Get Started" pricing CTA |
| `apps/storefront/src/app/(public)/HomeContent.tsx` | Homepage CTA |
### Backend
+1 -1
View File
@@ -4,7 +4,7 @@ docker compose -f docker-compose.dev.yml up -d redis
# app services
docker compose -f docker-compose.dev.yml --profile api up --build
docker compose -f docker-compose.dev.yml --profile carplace up --build
docker compose -f docker-compose.dev.yml --profile storefront up --build
docker compose -f docker-compose.dev.yml --profile dashboard up --build
docker compose -f docker-compose.dev.yml --profile admin up --build
+10 -10
View File
@@ -44,7 +44,7 @@
## 3. What to Redesign (Inspired by isysolution.com)
### 3.1 Carplace — Public Pages
### 3.1 Storefront — Public Pages
#### Home Page (`/`)
Current issues to fix:
@@ -70,7 +70,7 @@ Stats Strip
How It Works
├── Step 1: Create your workspace
├── Step 2: Add your fleet
├── Step 3: Go live on carplace
├── Step 3: Go live on storefront
└── Step 4: Manage everything from dashboard
Services / Features Grid
@@ -154,8 +154,8 @@ Redesign:
## 4. Component Redesign Checklist
### Shared Components
- [ ] `CarplaceHeader` — add mobile hamburger menu, improve nav spacing
- [ ] `CarplaceFooter` — add logo, social links, newsletter input
- [ ] `StorefrontHeader` — add mobile hamburger menu, improve nav spacing
- [ ] `StorefrontFooter` — add logo, social links, newsletter input
- [ ] `BookingForm` — improve step layout, add progress indicator
- [ ] `WorkspaceTabs` — improve active tab styling
@@ -187,7 +187,7 @@ Redesign:
| Numbered step process | Home "How it works" |
| Stats/counters strip | Home page |
| Testimonials carousel | Home page |
| Mega footer with columns | Carplace footer |
| Mega footer with columns | Storefront footer |
| Sidebar with grouped nav | Dashboard |
| KPI metric cards | Dashboard home |
@@ -202,7 +202,7 @@ Redesign:
- Wide: `xl: 1280px`, `2xl: 1536px`
### Mobile Improvements Needed
- [ ] Hamburger menu for carplace header
- [ ] Hamburger menu for storefront header
- [ ] Collapsible sidebar for dashboard
- [ ] Stack pricing cards vertically
- [ ] Full-width booking form
@@ -228,7 +228,7 @@ Redesign:
## 8. Implementation Phases
### Phase 1 — Carplace Public Pages (Priority)
### Phase 1 — Storefront Public Pages (Priority)
1. Home page redesign
- Stats strip
- How it works section
@@ -254,15 +254,15 @@ Redesign:
## 9. Files to Modify
### Carplace
### Storefront
| File | Change |
|---|---|
| `src/app/(public)/HomeContent.tsx` | Add stats, how-it-works, testimonials sections |
| `src/app/(public)/features/page.tsx` | Alternating layout |
| `src/app/(public)/pricing/PricingPageContent.tsx` | Toggle + improved cards |
| `src/app/(public)/explore/ExploreVehicleGrid.tsx` | Improved vehicle cards |
| `src/components/CarplaceHeader.tsx` | Mobile menu |
| `src/components/CarplaceFooter.tsx` | Mega footer |
| `src/components/StorefrontHeader.tsx` | Mobile menu |
| `src/components/StorefrontFooter.tsx` | Mega footer |
| `src/app/globals.css` | No changes needed |
### Dashboard
+3 -3
View File
@@ -70,13 +70,13 @@ Replace the rigid 4-step form with an explicit состояние/state on the `
| `DRAFT` | Step 0 above | Browsing dashboard UI, exploring settings, reading docs |
| `IDENTIFIED` | Owner first/last name + company display name | Personalizing UI, inviting teammates |
| `LISTABLE` | Company contact info + address | Creating/saving a fleet item or offer as **draft** |
| `PUBLISHABLE` | Legal identity block (legal name, legal form, RC, ICE, IF, license) | Publishing a listing live on the carplace |
| `PUBLISHABLE` | Legal identity block (legal name, legal form, RC, ICE, IF, license) | Publishing a listing live on the storefront |
| `PAYABLE` | Payment provider config (AmanPay/PayPal) + responsible person | Accepting bookings/payments |
| `SUBSCRIBED` | Plan + billing period selection | Past the 30-day trial, billed account |
Each state is just a derived boolean/computed field (`companyState`) based on which fields are filled — not a separate workflow table. The UI reads this state to decide what to show/lock.
This also directly maps the **existing legal/compliance fields** (RC, ICE, IF, operating license) to the **one moment they actually matter**: right before something goes live on the carplace. Today they're collected on day 1, before the user has even decided to publish anything.
This also directly maps the **existing legal/compliance fields** (RC, ICE, IF, operating license) to the **one moment they actually matter**: right before something goes live on the storefront. Today they're collected on day 1, before the user has even decided to publish anything.
---
@@ -171,7 +171,7 @@ Confirmation email still sends immediately after Step 0, but its copy changes fr
| Risk | Mitigation |
|---|---|
| Users abandon before ever reaching `PUBLISHABLE`/`PAYABLE` | That's expected and fine — they were never going to finish the old form either. Track funnel by state instead of all-or-nothing. |
| Carplace ends up with unpublishable "ghost" companies | `DRAFT`/`IDENTIFIED` companies are simply never shown publicly — only `PUBLISHABLE`+ companies appear, so this is a non-issue by construction. |
| Storefront ends up with unpublishable "ghost" companies | `DRAFT`/`IDENTIFIED` companies are simply never shown publicly — only `PUBLISHABLE`+ companies appear, so this is a non-issue by construction. |
| Legal/compliance requirements might mandate some data before *any* account exists, in some jurisdictions | Confirm with legal/compliance which fields (if any) are truly required by regulation before allowing a transaction vs. before allowing signup at all. Likely only the `PAYABLE` gate has real regulatory weight. |
| Users get repeatedly interrupted by modals | Batch by group (Section 5), not by individual field; show the completion indicator so users see how close they are; never re-ask for data already given. |
| Existing accounts (already fully filled out under the old flow) | No migration needed — they'll simply compute as `SUBSCRIBED`/fully complete already. |
@@ -53,7 +53,7 @@ whenever new endpoints are added.
Integration tests exist, but the heaviest workflows still benefit from deeper coverage:
- subscription billing transitions
- carplace reservation intake
- storefront reservation intake
- public booking and payment initialization
- reservation inspection and close flows
- admin billing operations
+2 -2
View File
@@ -52,7 +52,7 @@ We do not use analytics cookies, advertising cookies, or third-party tracking co
| **Scope** | All pages (`path=/`) |
| **Third-party** | No |
**Purpose:** Stores your chosen display language so the interface appears in your preferred language on every visit across all parts of the platform (public carplace, dashboard, and admin panel). Supported values are English (`en`), French (`fr`), and Arabic (`ar`).
**Purpose:** Stores your chosen display language so the interface appears in your preferred language on every visit across all parts of the platform (public storefront, dashboard, and admin panel). Supported values are English (`en`), French (`fr`), and Arabic (`ar`).
**When it is set:** When you change the language using the language selector, or automatically on first visit based on your browser's language settings.
**Can you opt out?** You can block this cookie. If you do, the platform will fall back to a default language and you may need to select your language on every visit.
@@ -97,7 +97,7 @@ We do not use analytics cookies, advertising cookies, or third-party tracking co
| Field | Value |
|---|---|
| **Names** | `dashboard-language`, `carplace-language` |
| **Names** | `dashboard-language`, `storefront-language` |
| **Category** | Functional / preference |
| **Duration** | 1 year |
| **Scope** | All pages (`path=/`) |
+7 -7
View File
@@ -4,7 +4,7 @@ This document lists the features that are active in the current codebase.
Source of truth:
- `apps/carplace`
- `apps/storefront`
- `apps/dashboard`
- `apps/admin`
- `apps/api`
@@ -14,7 +14,7 @@ Source of truth:
The current workspace contains four active apps:
- `apps/carplace`
- `apps/storefront`
- `apps/dashboard`
- `apps/admin`
- `apps/api`
@@ -61,12 +61,12 @@ There is no separate frontend app for a white-label company public site in this
- Notification inbox and preferences
- Subscription management
### Carplace and public platform
### Storefront and public platform
- Public marketing homepage
- Public pricing page
- Public features page
- Public carplace/explore flow
- Public storefront/explore flow
- Explore company profile pages under `/explore/[slug]`
- Public review submission page via review token
- Public legal/app policy pages
@@ -120,7 +120,7 @@ There is no separate frontend app for a white-label company public site in this
- Billing operations
- Pricing configuration and promotions
- Notification review
- Carplace/site config management
- Storefront/site config management
## Present But Not Active Product Features
@@ -144,9 +144,9 @@ The following old design ideas should not be treated as active features unless c
- standalone about/contact page set
- Google renter auth
- automatic custom-domain provisioning
- Zapier/webhook carplace integrations beyond the current API/webhook handlers
- Zapier/webhook storefront integrations beyond the current API/webhook handlers
## Notes
- The database still contains legacy fields such as `Employee.clerkUserId`, but those fields are no longer evidence of active Clerk integration.
- Some renter-facing `/renter/*` pages exist in the carplace app, but the auth entrypoints they depend on are not active. They are therefore not listed as active product scope here.
- Some renter-facing `/renter/*` pages exist in the storefront app, but the auth entrypoints they depend on are not active. They are therefore not listed as active product scope here.
+9 -9
View File
@@ -4,15 +4,15 @@ This document lists the pages that are actually present in the current frontend
Source of truth:
- `apps/carplace/src/app`
- `apps/storefront/src/app`
- `apps/dashboard/src/app`
- `apps/admin/src/app`
## Carplace App
## Storefront App
App:
- `apps/carplace`
- `apps/storefront`
### Public routes
@@ -37,11 +37,11 @@ App:
- `/` is the public marketing home.
- `/pricing` consumes platform pricing from `/site/platform/pricing`.
- `/explore` is the public carplace search/discovery page.
- `/explore/[slug]` is the company carplace profile page.
- `/explore` is the public storefront search/discovery page.
- `/explore/[slug]` is the company storefront profile page.
- `/review` is the review submission page driven by a reservation review token.
### Not present in the current carplace app
### Not present in the current storefront app
- `/about`
- `/contact`
@@ -51,7 +51,7 @@ App:
### Renter routes present in code but not active product entrypoints
These routes exist in the carplace app:
These routes exist in the storefront app:
- `/renter/dashboard`
- `/renter/notifications`
@@ -114,7 +114,7 @@ Base path:
- `/dashboard` shows KPIs and booking-source analytics.
- `/dashboard/fleet*` manages vehicles, maintenance, and calendar blocks.
- `/dashboard/reservations*` manages the booking lifecycle and inspection workflows.
- `/dashboard/online-reservations` handles public/carplace booking intake.
- `/dashboard/online-reservations` handles public/storefront booking intake.
- `/dashboard/customers` is the company CRM view.
- `/dashboard/offers` manages promotions.
- `/dashboard/team` manages employees.
@@ -164,7 +164,7 @@ App:
- `/dashboard/billing` manages platform billing operations.
- `/dashboard/pricing` edits platform pricing, features, and promotions.
- `/dashboard/notifications` inspects platform notifications.
- `/dashboard/site-config` edits carplace/site configuration content.
- `/dashboard/site-config` edits storefront/site configuration content.
## Deliberately Not Listed
@@ -8,7 +8,7 @@
## Summary
A full security review of the API codebase identified 3 confirmed, high-confidence vulnerabilities. All 3 were fixed in the same session. A separate frontend scan (dashboard, admin, carplace, public-site) found no confirmed vulnerabilities above the reporting threshold.
A full security review of the API codebase identified 3 confirmed, high-confidence vulnerabilities. All 3 were fixed in the same session. A separate frontend scan (dashboard, admin, storefront, public-site) found no confirmed vulnerabilities above the reporting threshold.
| # | Severity | Category | Status |
|---|----------|----------|--------|
@@ -127,7 +127,7 @@ An authenticated employee from Company A could fetch the full maintenance histor
### Exploit Scenario
An employee of Company A calls `GET /api/v1/vehicles/<vehicleId_from_company_B>/maintenance`. Vehicle UUIDs can leak through carplace listings, shared bookings, or support interactions. The response contains the complete maintenance history of Company B's vehicle — including service records, mileage data, and operational schedules — across tenant boundaries.
An employee of Company A calls `GET /api/v1/vehicles/<vehicleId_from_company_B>/maintenance`. Vehicle UUIDs can leak through storefront listings, shared bookings, or support interactions. The response contains the complete maintenance history of Company B's vehicle — including service records, mileage data, and operational schedules — across tenant boundaries.
### Fix
@@ -156,7 +156,7 @@ An employee of Company A calls `GET /api/v1/vehicles/<vehicleId_from_company_B>/
## Frontend Scan Results
A full scan of all four frontend applications (dashboard, admin, carplace, public-site) was performed. No vulnerabilities above the reporting threshold (confidence ≥ 8/10) were confirmed.
A full scan of all four frontend applications (dashboard, admin, storefront, public-site) was performed. No vulnerabilities above the reporting threshold (confidence ≥ 8/10) were confirmed.
### Hardening Recommendations (not vulnerabilities)
@@ -164,7 +164,7 @@ Two items were identified as best-practice improvements:
1. **Admin auth-redirect `next` param** (`apps/admin/src/app/auth-redirect/page.tsx`) — Validate the `next` query parameter is a relative path (starts with `/`, does not contain `://`) before passing it to `router.replace`. The router itself does not perform cross-origin navigation, but defense-in-depth is recommended.
2. **`postMessage` wildcard origin + missing `frame-ancestors` CSP** (`apps/dashboard/src/app/sign-in/[[...sign-in]]/SignInPageClient.tsx`, `apps/dashboard/next.config.js`) — Replace `targetOrigin: '*'` with the carplace origin, and add `Content-Security-Policy: frame-ancestors 'self' <carplace-origin>` to prevent the sign-in page from being embeddable by arbitrary third-party domains.
2. **`postMessage` wildcard origin + missing `frame-ancestors` CSP** (`apps/dashboard/src/app/sign-in/[[...sign-in]]/SignInPageClient.tsx`, `apps/dashboard/next.config.js`) — Replace `targetOrigin: '*'` with the storefront origin, and add `Content-Security-Policy: frame-ancestors 'self' <storefront-origin>` to prevent the sign-in page from being embeddable by arbitrary third-party domains.
---
+8 -8
View File
@@ -68,7 +68,7 @@ This is the default for dashboard/company management routes.
### Subscription Guard
`requireSubscription` blocks company routes when the company status is `PENDING` or `SUSPENDED`. Public site and carplace routes are intentionally not behind this guard.
`requireSubscription` blocks company routes when the company status is `PENDING` or `SUSPENDED`. Public site and storefront routes are intentionally not behind this guard.
### Role-Based Access Control
@@ -90,7 +90,7 @@ Admin roles are hierarchical:
`requireRenterAuth` validates a Bearer token with `type === "renter"` and attaches `req.renterId`.
`optionalRenterAuth` is used on carplace routes so public reads still work without a token.
`optionalRenterAuth` is used on storefront routes so public reads still work without a token.
### API Key
@@ -136,7 +136,7 @@ The table below describes the current route groups mounted in `app.ts`.
| `/api/v1/auth/employee` | Public plus employee JWT | Employee login, password reset, profile, language | Dashboard/staff authentication path |
| `/api/v1/auth/renter` | Renter JWT for profile routes | Renter profile and push token | `signup` and `login` are currently disabled in this codebase |
| `/api/v1/admin` | Admin JWT | Platform operations | Includes company admin, billing, subscriptions, pricing, audit, and admin-user management |
| `/api/v1/carplace` | Public, optional renter JWT | Carplace discovery and reservation intake | Designed for discovery and lead capture across companies |
| `/api/v1/storefront` | Public, optional renter JWT | Storefront discovery and reservation intake | Designed for discovery and lead capture across companies |
| `/api/v1/site` | Public | White-label company site APIs | Drives each company-branded booking site |
| `/api/v1/subscriptions` | Mixed | Plan listing, webhooks, subscription lifecycle | Public, webhook, and authenticated sub-routers share the same prefix |
| `/api/v1/vehicles` | Employee JWT + tenant + subscription | Fleet management | Includes photos, status, calendar blocks, maintenance, and availability |
@@ -228,17 +228,17 @@ Customers are company-scoped CRM records, even when a renter identity also exist
This is intentionally separate from renter identity because one renter may interact with multiple companies while each company still needs its own operational customer record.
### Carplace and public site
### Storefront and public site
The public surface is split in two modules on purpose.
`carplace` is the cross-company discovery layer:
`storefront` is the cross-company discovery layer:
- featured/public offers
- carplace cities
- storefront cities
- listed companies
- vehicle search
- carplace reservation intake
- storefront reservation intake
- review submission by token
- company public pages under `/:slug`
@@ -318,7 +318,7 @@ The admin router is broad because it covers platform operations across multiple
- billing account and billing invoice operations
- pricing config, plan features, and promotions
- subscription overrides and extensions
- carplace homepage configuration
- storefront homepage configuration
This is the only route group allowed to work across tenants.
+1 -1
View File
@@ -243,7 +243,7 @@ Purpose:
- public contact and social metadata
- default locale and currency
- company-owned payment provider settings for renter payments
- carplace visibility and rating
- storefront visibility and rating
- homepage/menu JSON configuration
Important uniqueness:
+26
View File
@@ -466,6 +466,32 @@
"url": "https://github.com/sponsors/colinhacks"
}
},
"apps/storefront": {
"name": "@rentaldrivego/storefront",
"version": "1.0.0",
"extraneous": true,
"dependencies": {
"@rentaldrivego/types": "*",
"autoprefixer": "^10.4.19",
"firebase-admin": "^10.3.0",
"lucide-react": "^0.376.0",
"next": "^16.2.9",
"node-cron": "4.5.0",
"nodemailer": "9.0.1",
"postcss": "^8.4.38",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"tailwindcss": "^3.4.3",
"turbo": "2.10.0"
},
"devDependencies": {
"@types/node": "^20.12.0",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"typescript": "^5.4.0",
"vitest": "^2.1.0"
}
},
"node_modules/@adobe/css-tools": {
"version": "4.5.0",
"dev": true,
+1
View File
@@ -31,6 +31,7 @@
"docker:prod:start:api": "bash scripts/docker-prod-up-api.sh",
"docker:prod:start:homepage": "bash scripts/docker-prod-up-homepage.sh",
"docker:prod:start:carplace": "bash scripts/docker-prod-up-carplace.sh",
"docker:prod:start:storefront": "bash scripts/docker-prod-up-storefront.sh",
"docker:prod:start:dashboard": "bash scripts/docker-prod-up-dashboard.sh",
"docker:prod:start:admin": "bash scripts/docker-prod-up-admin.sh",
"docker:prod:start:frontends": "bash scripts/docker-prod-up-frontends.sh",
+18
View File
@@ -0,0 +1,18 @@
http:
routers:
mail-certificate:
rule: "Host(`mail.rentaldrivego.ma`)"
entryPoints:
- websecure
service: mail-certificate-placeholder
tls:
certResolver: letsencrypt
domains:
- main: "mail.rentaldrivego.ma"
services:
mail-certificate-placeholder:
loadBalancer:
servers:
# This router exists to request and renew the certificate.
# Mail protocols are not routed through this HTTP service.
- url: "http://127.0.0.1:65535"
+4 -10
View File
@@ -7,7 +7,6 @@ PROD_COMPOSE_FILE="${ROOT_DIR}/docker-compose.production.yml"
TRAEFIK_COMPOSE_FILE="${ROOT_DIR}/traefik.yaml"
ENV_FILE="${ROOT_DIR}/.env.docker.production"
PROJECT_NAME="${DOCKER_PROD_PROJECT_NAME:-rentaldrivego-prod}"
TRAEFIK_PROJECT_NAME="${DOCKER_TRAEFIK_PROJECT_NAME:-rentaldrivego}"
TRAEFIK_NETWORK="${DOCKER_PROD_TRAEFIK_NETWORK:-traefik-proxy}"
VOLUME_BACKUP_IMAGE="${DOCKER_VOLUME_BACKUP_IMAGE:-postgres:16-alpine}"
API_UPLOADS_VOLUME="${DOCKER_PROD_API_UPLOADS_VOLUME:-${PROJECT_NAME}_api_uploads}"
@@ -105,7 +104,9 @@ export_compose_env_from_file() {
done < "${ENV_FILE}"
if [[ -z "${NEXT_PUBLIC_CARPLACE_URL:-}" ]]; then
if [[ -n "${SITE_ORIGIN:-}" ]]; then
if [[ -n "${NEXT_PUBLIC_STOREFRONT_URL:-}" ]]; then
NEXT_PUBLIC_CARPLACE_URL="${NEXT_PUBLIC_STOREFRONT_URL%/}/carplace"
elif [[ -n "${SITE_ORIGIN:-}" ]]; then
NEXT_PUBLIC_CARPLACE_URL="${SITE_ORIGIN%/}/carplace"
fi
export NEXT_PUBLIC_CARPLACE_URL
@@ -250,7 +251,7 @@ prod_compose() {
traefik_compose() {
export_compose_env_from_file
docker compose -p "${TRAEFIK_PROJECT_NAME}" --env-file "${ENV_FILE}" -f "${TRAEFIK_COMPOSE_FILE}" "$@"
docker compose --env-file "${ENV_FILE}" -f "${TRAEFIK_COMPOSE_FILE}" "$@"
}
start_prod_services() {
@@ -454,13 +455,6 @@ wait_for_healthy() {
start_traefik() {
ensure_env_file
ensure_traefik_network
local existing_proxy
existing_proxy="$(docker ps --filter publish=80 --format '{{.ID}} {{.Image}} {{.Names}}' | awk 'tolower($0) ~ /traefik/ { print $1; exit }')"
if [[ -n "${existing_proxy}" ]]; then
echo "Using existing Traefik container ${existing_proxy}; skipping duplicate Traefik startup."
docker network connect "${TRAEFIK_NETWORK}" "${existing_proxy}" 2>/dev/null || true
return 0
fi
traefik_compose up -d
}
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
source "${ROOT_DIR}/scripts/docker-prod-common.sh"
start_prod_services carplace
wait_for_healthy carplace
echo "Carplace is up and healthy."
+48 -1
View File
@@ -1,6 +1,8 @@
services:
traefik:
image: traefik:latest
# Set TRAEFIK_VERSION in .env to the exact version you are running.
# Do not use "latest" in production.
image: traefik:${TRAEFIK_VERSION}
command:
- --api.dashboard=false
- --api.insecure=false
@@ -29,6 +31,51 @@ services:
- ./dynamic:/etc/traefik/dynamic:ro
networks:
- traefik-proxy
traefik-certs-dumper:
image: ldez/traefik-certs-dumper:v2.11.4
depends_on:
- traefik
restart: unless-stopped
# Wait for Traefik's ACME storage, then export certificates whenever
# acme.json changes.
entrypoint:
- /bin/sh
- -ec
command:
- |
until [ -s /data/acme.json ]; do
sleep 2
done
exec traefik-certs-dumper file \
--version v3 \
--watch \
--source /data/acme.json \
--dest /output \
--domain-subdir \
--crt-name fullchain \
--key-name privkey \
--crt-ext .pem \
--key-ext .pem
volumes:
# Existing Traefik ACME volume, mounted read-only.
- traefik-letsencrypt:/data:ro
# Normal certificate files will appear on the VPS here.
- ./exported-certs:/output
# The dumper only reads a local volume and writes exported files.
# It needs no network access.
network_mode: none
labels:
- "traefik.enable=false"
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
read_only: true
tmpfs:
- /tmp:size=16m,mode=1777
networks:
traefik-proxy:
external: true
+5 -17
View File
@@ -11,7 +11,6 @@ ENV_FILE="${ROOT_DIR}/.env.docker.production"
DYNAMIC_DIR="${ROOT_DIR}/dynamic"
REGISTRY_DYNAMIC_FILE="${DYNAMIC_DIR}/registry-upstream.yml"
PROJECT_NAME="${DOCKER_PROD_PROJECT_NAME:-rentaldrivego-prod}"
TRAEFIK_PROJECT_NAME="${DOCKER_TRAEFIK_PROJECT_NAME:-rentaldrivego}"
PORTAINER_PROJECT_NAME="${DOCKER_PORTAINER_PROJECT_NAME:-rentaldrivego-portainer-prod}"
REGISTRY_PROJECT_NAME="${DOCKER_REGISTRY_PROJECT_NAME:-rentaldrivego-registry-prod}"
TRAEFIK_NETWORK="${DOCKER_PROD_TRAEFIK_NETWORK:-traefik-proxy}"
@@ -100,7 +99,9 @@ export_compose_env_from_file() {
done < "${ENV_FILE}"
if [[ -z "${NEXT_PUBLIC_CARPLACE_URL:-}" ]]; then
if [[ -n "${SITE_ORIGIN:-}" ]]; then
if [[ -n "${NEXT_PUBLIC_STOREFRONT_URL:-}" ]]; then
NEXT_PUBLIC_CARPLACE_URL="${NEXT_PUBLIC_STOREFRONT_URL%/}/carplace"
elif [[ -n "${SITE_ORIGIN:-}" ]]; then
NEXT_PUBLIC_CARPLACE_URL="${SITE_ORIGIN%/}/carplace"
fi
export NEXT_PUBLIC_CARPLACE_URL
@@ -263,7 +264,7 @@ registry_compose() {
traefik_compose() {
export_compose_env_from_file
docker compose -p "${TRAEFIK_PROJECT_NAME}" --env-file "${ENV_FILE}" -f "${TRAEFIK_COMPOSE_FILE}" "$@"
docker compose --env-file "${ENV_FILE}" -f "${TRAEFIK_COMPOSE_FILE}" "$@"
}
start_prod_services() {
@@ -348,12 +349,6 @@ pull_prod_release_images() {
require_release_image
ensure_registry_transport_configured
local release_image="${APP_IMAGE:-rentaldrivego/carmanagement}:${IMAGE_TAG}"
if [[ "${IMAGE_TAG}" != "latest" ]] && docker image inspect "${release_image}" >/dev/null 2>&1; then
echo "Release image ${release_image} is already present locally; skipping docker pull."
return 0
fi
if load_release_image_archives; then
return 0
fi
@@ -365,7 +360,7 @@ pull_prod_release_images() {
exit 1
fi
prod_compose pull migrate api homepage carplace dashboard admin
prod_compose pull migrate api carplace dashboard admin
}
load_release_image_archives() {
@@ -501,13 +496,6 @@ start_traefik() {
ensure_env_file
ensure_traefik_network
render_registry_proxy_config
local existing_proxy
existing_proxy="$(docker ps --filter publish=80 --format '{{.ID}} {{.Image}} {{.Names}}' | awk 'tolower($0) ~ /traefik/ { print $1; exit }')"
if [[ -n "${existing_proxy}" ]]; then
echo "Using existing Traefik container ${existing_proxy}; skipping duplicate Traefik startup."
docker network connect "${TRAEFIK_NETWORK}" "${existing_proxy}" 2>/dev/null || true
return 0
fi
traefik_compose up -d
}
+1 -2
View File
@@ -26,9 +26,8 @@ echo "Running database migrations"
run_prod_migrations
echo "Starting application services"
prod_compose up -d api homepage carplace dashboard admin
prod_compose up -d api carplace dashboard admin
wait_for_healthy api 180
wait_for_healthy homepage 180
wait_for_healthy carplace 180
wait_for_healthy dashboard 180
wait_for_healthy admin 180
+1 -2
View File
@@ -90,7 +90,7 @@ echo "Applying migrations"
prod_compose run --rm migrate
echo "Starting application services"
app_services=(api homepage carplace dashboard admin)
app_services=(api carplace dashboard admin)
if [[ "${INCLUDE_PGMANAGE}" == "1" ]]; then
prod_compose --profile private-tools up -d "${app_services[@]}" pgmanage
@@ -98,7 +98,6 @@ else
prod_compose up -d "${app_services[@]}"
fi
wait_for_healthy api 180
wait_for_healthy homepage 180
wait_for_healthy carplace 180
wait_for_healthy dashboard 180
wait_for_healthy admin 180
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
source "${ROOT_DIR}/scripts/docker-prod-common.sh"
start_prod_services carplace
wait_for_healthy carplace
echo "Carplace is up and healthy."
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+74
View File
@@ -0,0 +1,74 @@
/** @type {import('next').NextConfig} */
const { buildSecurityHeaders, normalizeAssetPrefix } = require('../../config/nextSecurityHeaders')
// The storefront proxies /dashboard and /admin to their own Next dev servers.
// Allow those asset-prefix origins so proxied pages can load chunks and HMR.
const dashboardAssetSource = normalizeAssetPrefix(
process.env.DASHBOARD_ASSET_PREFIX ?? (process.env.NODE_ENV !== 'production' ? 'http://localhost:3001' : undefined),
'/dashboard',
)
const securityHeaders = buildSecurityHeaders({
assetSources: [dashboardAssetSource, process.env.ADMIN_ASSET_PREFIX],
})
const nextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'res.cloudinary.com',
},
],
},
transpilePackages: ['@rentaldrivego/types'],
async headers() {
return [
{
source: '/:path*',
headers: securityHeaders,
},
]
},
async redirects() {
return [
{
source: '/dashboard/dashboard',
destination: '/dashboard',
permanent: false,
},
{
source: '/dashboard/dashboard/:path*',
destination: '/dashboard/:path*',
permanent: false,
},
]
},
async rewrites() {
const dashboardOrigin = process.env.DASHBOARD_INTERNAL_URL ?? 'http://dashboard:3001'
const adminOrigin = process.env.ADMIN_INTERNAL_URL ?? 'http://admin:3002'
return [
// Dashboard has basePath '/dashboard'. The proxy preserves the prefix
// so the dashboard can strip it and route internally.
{
source: '/dashboard',
destination: `${dashboardOrigin}/dashboard`,
},
{
source: '/dashboard/:path*',
destination: `${dashboardOrigin}/dashboard/:path*`,
},
// Admin routes (also uses basePath)
{
source: '/admin',
destination: `${adminOrigin}/admin`,
},
{
source: '/admin/:path*',
destination: `${adminOrigin}/admin/:path*`,
},
]
},
}
module.exports = nextConfig
+34
View File
@@ -0,0 +1,34 @@
{
"name": "@rentaldrivego/storefront",
"version": "1.0.0",
"private": true,
"scripts": {
"predev": "npm run build --workspace @rentaldrivego/types",
"dev": "next dev -H 0.0.0.0 -p 3004",
"prebuild": "npm run build --workspace @rentaldrivego/types",
"build": "next build",
"prestart": "npm run build --workspace @rentaldrivego/types",
"pretype-check": "npm run build --workspace @rentaldrivego/types",
"start": "next start -H 0.0.0.0 -p 3004",
"type-check": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@rentaldrivego/types": "*",
"autoprefixer": "^10.4.19",
"lucide-react": "^0.376.0",
"next": "^16.2.7",
"postcss": "^8.4.38",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwindcss": "^3.4.3"
},
"devDependencies": {
"@types/node": "^20.12.0",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.0",
"typescript": "^5.4.0",
"vitest": "^1.6.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

+14
View File
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="8" fill="#0f172a" />
<text
x="16"
y="21"
text-anchor="middle"
font-family="Inter, Arial, sans-serif"
font-size="18"
font-weight="700"
fill="#ffffff"
>
R
</text>
</svg>

After

Width:  |  Height:  |  Size: 302 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 836 KiB

@@ -0,0 +1,31 @@
import React, { isValidElement } from 'react'
import { describe, expect, it } from 'vitest'
import FooterContentPage from '@/components/FooterContentPage'
import AppPrivacyEnPage from './app-privacy-en/page'
import AppPrivacyFrPage from './app-privacy-fr/page'
import AppPrivacyArPage from './app-privacy-ar/page'
import AppTermsEnPage from './app-tc-en/page'
import AppTermsFrPage from './app-tc-fr/page'
import AppTermsArPage from './app-tc-ar/page'
function expectPolicyPage(element: unknown, slug: string, forcedLanguage: string) {
expect(isValidElement(element)).toBe(true)
const page = element as React.ReactElement
expect(page.type).toBe(FooterContentPage)
expect(page.props.slug).toBe(slug)
expect(page.props.forcedLanguage).toBe(forcedLanguage)
}
describe('storefront static app policy pages', () => {
it('binds app privacy pages to explicit locales', () => {
expectPolicyPage(AppPrivacyEnPage(), 'privacy-policy', 'en')
expectPolicyPage(AppPrivacyFrPage(), 'privacy-policy', 'fr')
expectPolicyPage(AppPrivacyArPage(), 'privacy-policy', 'ar')
})
it('binds app terms pages to explicit locales and the app terms content slug', () => {
expectPolicyPage(AppTermsEnPage(), 'general-conditions', 'en')
expectPolicyPage(AppTermsFrPage(), 'general-conditions', 'fr')
expectPolicyPage(AppTermsArPage(), 'general-conditions', 'ar')
})
})
@@ -0,0 +1,6 @@
import React from 'react'
import FooterContentPage from '@/components/FooterContentPage'
export default function AppPrivacyArPage() {
return <FooterContentPage slug="privacy-policy" forcedLanguage="ar" />
}
@@ -0,0 +1,6 @@
import React from 'react'
import FooterContentPage from '@/components/FooterContentPage'
export default function AppPrivacyEnPage() {
return <FooterContentPage slug="privacy-policy" forcedLanguage="en" />
}
@@ -0,0 +1,6 @@
import React from 'react'
import FooterContentPage from '@/components/FooterContentPage'
export default function AppPrivacyFrPage() {
return <FooterContentPage slug="privacy-policy" forcedLanguage="fr" />
}
@@ -0,0 +1,6 @@
import React from 'react'
import FooterContentPage from '@/components/FooterContentPage'
export default function AppTermsArPage() {
return <FooterContentPage slug="general-conditions" forcedLanguage="ar" />
}
@@ -0,0 +1,6 @@
import React from 'react'
import FooterContentPage from '@/components/FooterContentPage'
export default function AppTermsEnPage() {
return <FooterContentPage slug="general-conditions" forcedLanguage="en" />
}
@@ -0,0 +1,6 @@
import React from 'react'
import FooterContentPage from '@/components/FooterContentPage'
export default function AppTermsFrPage() {
return <FooterContentPage slug="general-conditions" forcedLanguage="fr" />
}
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation'
export default function CompanyWorkspacePage() {
redirect('/')
}
@@ -0,0 +1,295 @@
'use client'
import { CalendarDays, Clock3, Info, MapPin, TicketPercent } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
type ExploreSearchFormDict = {
searchTitle: string
pickupLocation: string
dropoffLocation: string
sameDropoff: string
differentDropoff: string
sameAsPickup: string
pickupDate: string
returnDate: string
hour: string
promoCode: string
myAge: string
ageDescription: string
search: string
ageOptions: string[]
}
function toUtcDateTime(date: string, time: string) {
if (!date) return ''
return `${date}T${time || '10:00'}:00.000Z`
}
const inputBase = 'w-full border-0 bg-white dark:bg-blue-900/30 px-4 py-4 text-sm text-stone-900 dark:text-stone-100 outline-none placeholder:text-stone-400 dark:placeholder:text-stone-500'
const selectBase = 'w-full border-0 bg-white dark:bg-blue-900/30 py-4 text-sm text-stone-900 dark:text-stone-100 outline-none'
export default function ExploreSearchForm({
pickupLocation,
dropoffLocation,
dropoffMode,
pickupDate,
pickupTime,
returnDate,
returnTime,
promoCode,
driverAge,
cities,
dict,
}: {
pickupLocation: string
dropoffLocation: string
dropoffMode: 'same' | 'different'
pickupDate: string
pickupTime: string
returnDate: string
returnTime: string
promoCode: string
driverAge: string
cities: string[]
dict: ExploreSearchFormDict
}) {
const router = useRouter()
const cityOptions = Array.isArray(cities) ? cities : []
const ageOptions = Array.isArray(dict.ageOptions) ? dict.ageOptions : []
const [filters, setFilters] = useState({
pickupLocation,
dropoffLocation,
dropoffMode,
pickupDate,
pickupTime: pickupTime || '10:00',
returnDate,
returnTime: returnTime || '10:00',
promoCode,
driverAge: driverAge || '25+',
})
const [ageHelpOpen, setAgeHelpOpen] = useState(false)
function updateFilter(name: keyof typeof filters, value: string) {
setFilters((current) => ({ ...current, [name]: value }))
}
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
const query = new URLSearchParams()
const normalizedPickup = filters.pickupLocation.trim()
const normalizedDropoff = filters.dropoffMode === 'different'
? filters.dropoffLocation.trim()
: normalizedPickup
const normalizedPromo = filters.promoCode.trim()
if (normalizedPickup) {
query.set('pickupLocation', normalizedPickup)
query.set('city', normalizedPickup)
}
query.set('dropoffMode', filters.dropoffMode)
if (filters.dropoffMode === 'different' && normalizedDropoff) {
query.set('dropoffLocation', normalizedDropoff)
}
if (filters.pickupDate) query.set('pickupDate', filters.pickupDate)
if (filters.pickupTime) query.set('pickupTime', filters.pickupTime)
if (filters.returnDate) query.set('returnDate', filters.returnDate)
if (filters.returnTime) query.set('returnTime', filters.returnTime)
if (normalizedPromo) query.set('promoCode', normalizedPromo)
if (filters.driverAge) query.set('driverAge', filters.driverAge)
const startDateTime = toUtcDateTime(filters.pickupDate, filters.pickupTime)
const endDateTime = toUtcDateTime(filters.returnDate, filters.returnTime)
if (startDateTime) query.set('startDate', startDateTime)
if (endDateTime) query.set('endDate', endDateTime)
const target = query.toString() ? `/explore?${query.toString()}` : '/explore'
router.push(target)
}
return (
<div className="mt-8 rounded-[1.75rem] bg-white dark:bg-blue-950 p-5 text-stone-900 dark:text-stone-100 shadow-[0_24px_80px_rgba(0,0,0,0.16)]">
<h2 className="text-3xl font-black tracking-tight text-stone-900 dark:text-stone-100">{dict.searchTitle}</h2>
<form onSubmit={handleSubmit} className="mt-5 space-y-4">
<div className="grid gap-px overflow-hidden rounded-[1.35rem] border border-stone-200 dark:border-blue-800 bg-stone-200 dark:bg-stone-700 md:grid-cols-2">
<label className="flex items-center gap-3 bg-white dark:bg-blue-900/30 px-4">
<MapPin className="h-5 w-5 text-stone-300 dark:text-stone-500" />
<select
name="pickupLocation"
value={filters.pickupLocation}
onChange={(event) => updateFilter('pickupLocation', event.target.value)}
className={selectBase}
>
<option value="">{dict.pickupLocation}</option>
{cityOptions.map((city) => (
<option key={`pickup-${city}`} value={city}>{city}</option>
))}
</select>
</label>
<div className="bg-white dark:bg-blue-900/30 px-4 py-3">
<div className="flex items-center justify-between gap-3">
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-stone-400 dark:text-stone-500">{dict.dropoffLocation}</span>
<div className="inline-flex rounded-full border border-stone-200 dark:border-blue-800 bg-stone-100 dark:bg-blue-950 p-1">
<button
type="button"
onClick={() => setFilters((current) => ({ ...current, dropoffMode: 'same', dropoffLocation: '' }))}
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${
filters.dropoffMode === 'same'
? 'bg-blue-900 text-white dark:bg-orange-400 dark:text-white'
: 'text-stone-500 dark:text-stone-400'
}`}
>
{dict.sameDropoff}
</button>
<button
type="button"
onClick={() => setFilters((current) => ({ ...current, dropoffMode: 'different' }))}
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${
filters.dropoffMode === 'different'
? 'bg-blue-900 text-white dark:bg-orange-400 dark:text-white'
: 'text-stone-500 dark:text-stone-400'
}`}
>
{dict.differentDropoff}
</button>
</div>
</div>
{filters.dropoffMode === 'different' ? (
<label className="mt-3 flex items-center gap-3">
<MapPin className="h-5 w-5 text-stone-300 dark:text-stone-500" />
<select
name="dropoffLocation"
value={filters.dropoffLocation}
onChange={(event) => updateFilter('dropoffLocation', event.target.value)}
className={selectBase}
>
<option value="">{dict.dropoffLocation}</option>
{cityOptions.map((city) => (
<option key={`dropoff-${city}`} value={city}>{city}</option>
))}
</select>
</label>
) : (
<div className="mt-3 flex items-center gap-3 rounded-2xl bg-stone-50 px-4 py-4 text-sm font-medium text-stone-500 dark:bg-blue-950 dark:text-stone-300">
<MapPin className="h-5 w-5 text-stone-300 dark:text-stone-500" />
<span>{filters.pickupLocation || dict.sameAsPickup}</span>
</div>
)}
</div>
</div>
<div className="grid gap-px overflow-hidden rounded-[1.35rem] border border-stone-200 dark:border-blue-800 bg-stone-200 dark:bg-stone-700 md:grid-cols-4">
<label className="flex items-center gap-3 bg-white dark:bg-blue-900/30 px-4">
<CalendarDays className="h-5 w-5 text-stone-500 dark:text-stone-400" />
<div className="flex w-full flex-col py-3">
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-stone-400 dark:text-stone-500">{dict.pickupDate}</span>
<input
name="pickupDate"
type="date"
value={filters.pickupDate}
min={new Date().toISOString().slice(0, 10)}
onChange={(event) => updateFilter('pickupDate', event.target.value)}
className="border-0 bg-transparent px-0 py-0 text-sm text-stone-900 dark:text-stone-100 outline-none [color-scheme:light] dark:[color-scheme:dark]"
/>
</div>
</label>
<label className="flex items-center gap-3 bg-white dark:bg-blue-900/30 px-4">
<Clock3 className="h-5 w-5 text-stone-500 dark:text-stone-400" />
<div className="flex w-full flex-col py-3">
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-stone-400 dark:text-stone-500">{dict.hour}</span>
<input
name="pickupTime"
type="time"
value={filters.pickupTime}
onChange={(event) => updateFilter('pickupTime', event.target.value)}
className="border-0 bg-transparent px-0 py-0 text-sm text-stone-900 dark:text-stone-100 outline-none [color-scheme:light] dark:[color-scheme:dark]"
/>
</div>
</label>
<label className="flex items-center gap-3 bg-white dark:bg-blue-900/30 px-4">
<CalendarDays className="h-5 w-5 text-stone-500 dark:text-stone-400" />
<div className="flex w-full flex-col py-3">
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-stone-400 dark:text-stone-500">{dict.returnDate}</span>
<input
name="returnDate"
type="date"
value={filters.returnDate}
min={filters.pickupDate || new Date().toISOString().slice(0, 10)}
onChange={(event) => updateFilter('returnDate', event.target.value)}
className="border-0 bg-transparent px-0 py-0 text-sm text-stone-900 dark:text-stone-100 outline-none [color-scheme:light] dark:[color-scheme:dark]"
/>
</div>
</label>
<label className="flex items-center gap-3 bg-white dark:bg-blue-900/30 px-4">
<Clock3 className="h-5 w-5 text-stone-500 dark:text-stone-400" />
<div className="flex w-full flex-col py-3">
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-stone-400 dark:text-stone-500">{dict.hour}</span>
<input
name="returnTime"
type="time"
value={filters.returnTime}
onChange={(event) => updateFilter('returnTime', event.target.value)}
className="border-0 bg-transparent px-0 py-0 text-sm text-stone-900 dark:text-stone-100 outline-none [color-scheme:light] dark:[color-scheme:dark]"
/>
</div>
</label>
</div>
<div className="relative z-10 grid gap-px overflow-visible rounded-[1.35rem] border border-stone-200 dark:border-blue-800 bg-stone-200 dark:bg-stone-700 lg:grid-cols-[1.1fr_1.1fr_1.35fr]">
<label className="flex items-center gap-3 rounded-t-[1.35rem] bg-white dark:bg-blue-900/30 px-4 lg:rounded-l-[1.35rem] lg:rounded-tr-none">
<TicketPercent className="h-5 w-5 text-stone-300 dark:text-stone-500" />
<input
name="promoCode"
value={filters.promoCode}
onChange={(event) => updateFilter('promoCode', event.target.value)}
placeholder={dict.promoCode}
className={`${inputBase} pl-2`}
/>
</label>
<label className="flex items-center justify-between gap-3 bg-white dark:bg-blue-900/30 px-4 py-3">
<div className="flex min-w-0 items-center gap-3">
<span className="text-sm font-semibold text-stone-600 dark:text-stone-300">{dict.myAge}</span>
<select
name="driverAge"
value={filters.driverAge}
onChange={(event) => updateFilter('driverAge', event.target.value)}
className="border-0 bg-transparent text-sm font-semibold text-stone-900 dark:text-stone-100 outline-none"
>
{ageOptions.map((option) => (
<option key={option} value={option}>{option}</option>
))}
</select>
</div>
<div
className="relative shrink-0"
onMouseEnter={() => setAgeHelpOpen(true)}
onMouseLeave={() => setAgeHelpOpen(false)}
>
<button
type="button"
aria-label={dict.ageDescription}
onClick={() => setAgeHelpOpen((open) => !open)}
className="text-stone-400 dark:text-stone-500 transition hover:text-stone-600 dark:hover:text-stone-300"
>
<Info className="h-5 w-5" />
</button>
{ageHelpOpen ? (
<div className="absolute bottom-[calc(100%+0.85rem)] right-0 z-30 w-72 rounded-2xl border border-orange-300 dark:border-orange-600 bg-white dark:bg-blue-950 px-4 py-3 text-left text-sm leading-6 text-stone-700 dark:text-stone-200 shadow-[0_16px_40px_rgba(0,0,0,0.14)]">
{dict.ageDescription}
<span className="absolute right-6 top-full h-3 w-3 -translate-y-1/2 rotate-45 border-b border-r border-orange-300 dark:border-orange-600 bg-white dark:bg-blue-950" />
</div>
) : null}
</div>
</label>
<button
type="submit"
className="min-h-[76px] rounded-b-[1.35rem] bg-orange-600 px-6 text-sm font-black uppercase tracking-[0.08em] text-white transition hover:bg-orange-700 dark:bg-orange-400 dark:text-white dark:hover:bg-orange-300 lg:rounded-b-none lg:rounded-r-[1.35rem]"
>
{dict.search}
</button>
</div>
</form>
</div>
)
}
@@ -0,0 +1,112 @@
import Link from 'next/link'
import { formatCurrency, type Locale } from '@rentaldrivego/types'
interface Vehicle {
id: string
make: string
model: string
year: number
category: string
dailyRate: number
photos: string[]
availability?: boolean | null
availabilityStatus?: 'AVAILABLE' | 'RESERVED' | 'MAINTENANCE' | 'UNAVAILABLE'
nextAvailableAt?: string | null
company: {
slug: string
brand: {
displayName: string
logoUrl: string | null
subdomain: string
marketplaceRating: number | null
} | null
}
}
interface Dict {
rentalCompany: string
checkAvailability: string
available: string
reserved: string
maintenance: string
unavailableStatus: string
from: string
viewVehicle: string
availableFrom: string
unavailableNoDate: string
vehicleUnavailable: string
listings: string
availableVehicles: string
}
function formatCents(cents: number, language: Locale) {
return formatCurrency(cents, 'MAD', language)
}
function formatAvailabilityDate(value?: string | null) {
if (!value) return null
return new Date(value).toLocaleDateString('en-GB', { year: 'numeric', month: 'long', day: 'numeric' })
}
function getStatusCopy(vehicle: Vehicle, dict: Dict) {
if (vehicle.availabilityStatus === 'RESERVED') return dict.reserved
if (vehicle.availabilityStatus === 'MAINTENANCE') return dict.maintenance
if (vehicle.availabilityStatus === 'UNAVAILABLE') return dict.unavailableStatus
if (vehicle.availability === false) return dict.checkAvailability
return dict.available
}
export default function ExploreVehicleGrid({ vehicles, dict, language }: { vehicles: Vehicle[]; dict: Dict; language: Locale }) {
return (
<section>
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-stone-900 dark:text-stone-100">{dict.availableVehicles}</h2>
<p className="text-sm text-stone-500 dark:text-stone-400">{vehicles.length} {dict.listings}</p>
</div>
<div className="mt-4 grid gap-5 md:grid-cols-2 xl:grid-cols-3">
{vehicles.map((vehicle) => (
<article key={vehicle.id} className="card">
<div className="aspect-[16/10] overflow-hidden rounded-t-[2rem] bg-stone-100 dark:bg-blue-900/30">
{vehicle.photos[0] ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={vehicle.photos[0]} alt={`${vehicle.make} ${vehicle.model}`} className="h-full w-full object-cover" />
) : null}
</div>
<div className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="truncate text-xs uppercase tracking-[0.16em] text-stone-500 dark:text-stone-400">{vehicle.company.brand?.displayName ?? dict.rentalCompany}</p>
<h3 className="mt-2 truncate text-lg font-bold text-stone-900 dark:text-stone-100">{vehicle.make} {vehicle.model}</h3>
<p className="mt-1 truncate text-sm text-stone-500 dark:text-stone-400">{vehicle.year} · {vehicle.category}</p>
</div>
<span className={`shrink-0 rounded-full px-3 py-1 text-xs font-semibold ${vehicle.availability === false ? 'bg-rose-100 text-rose-700 dark:bg-rose-950/50 dark:text-rose-400' : 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/50 dark:text-emerald-400'}`}>
{getStatusCopy(vehicle, dict)}
</span>
</div>
{vehicle.availability === false && (
<p className="mt-3 text-sm text-stone-500 dark:text-stone-400">
{vehicle.nextAvailableAt ? `${dict.availableFrom} ${formatAvailabilityDate(vehicle.nextAvailableAt)}` : dict.unavailableNoDate}
</p>
)}
<div className="mt-4 flex items-end justify-between gap-3">
<div className="min-w-0">
<p className="text-sm text-stone-500 dark:text-stone-400">{dict.from}</p>
<p className="truncate text-xl font-black text-stone-900 dark:text-stone-100">{formatCents(vehicle.dailyRate, language)}</p>
</div>
<Link
href={`/explore/${vehicle.company.slug}/vehicles/${vehicle.id}`}
className="shrink-0 rounded-full bg-blue-900 px-4 py-2 text-sm font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:text-white dark:hover:bg-orange-400"
>
{dict.viewVehicle}
</Link>
</div>
</div>
</article>
))}
{vehicles.length === 0 && (
<div className="card p-6 text-sm text-stone-500 dark:text-stone-400 md:col-span-2 xl:col-span-3">{dict.vehicleUnavailable}</div>
)}
</div>
</section>
)
}
@@ -0,0 +1,159 @@
import Link from 'next/link'
import { storefrontFetchOrDefault } from '@/lib/api'
import { getStorefrontLanguage } from '@/lib/i18n.server'
import { formatCurrency } from '@rentaldrivego/types'
interface CompanyProfile {
id: string
name: string
slug: string
brand: {
displayName: string
logoUrl: string | null
publicCity: string | null
publicCountry: string | null
publicPhone: string | null
whatsappNumber: string | null
marketplaceRating: number | null
} | null
vehicles: Array<{ id: string; make: string; model: string; dailyRate: number; photos: string[]; category: string; availability: boolean; nextAvailableAt: string | null }>
offers: Array<{ id: string; title: string; discountValue: number; validUntil: string }>
}
export default async function CompanyProfilePage({ params }: { params: { slug: string } }) {
const language = await getStorefrontLanguage()
const dict = {
en: {
unavailable: 'Storefront unavailable',
unavailableTitle: 'Company details are temporarily unavailable.',
unavailableBody: 'The storefront API is running, but the database is not reachable in this local environment.',
backToExplore: 'Back to explore',
partner: 'Storefront partner',
vehicles: 'vehicles',
offers: 'active offers',
rating: 'Rating',
new: 'New',
currentOffers: 'Current offers',
validUntil: 'Valid until',
noOffers: 'No active offers right now.',
fleet: 'Fleet',
viewVehicle: 'View vehicle',
availableFrom: 'Available from',
unavailableNoDate: 'Temporarily unavailable for new reservations.',
},
fr: {
unavailable: 'Storefront indisponible',
unavailableTitle: "Les détails de l'entreprise sont temporairement indisponibles.",
unavailableBody: "L'API storefront fonctionne, mais la base de données n'est pas accessible dans cet environnement local.",
backToExplore: "Retour à l'exploration",
partner: 'Partenaire storefront',
vehicles: 'véhicules',
offers: 'offres actives',
rating: 'Note',
new: 'Nouveau',
currentOffers: 'Offres actuelles',
validUntil: "Valable jusqu'au",
noOffers: 'Aucune offre active pour le moment.',
fleet: 'Flotte',
viewVehicle: 'Voir le véhicule',
availableFrom: 'Disponible à partir du',
unavailableNoDate: 'Temporairement indisponible pour les nouvelles réservations.',
},
ar: {
unavailable: 'السوق غير متاح',
unavailableTitle: 'تفاصيل الشركة غير متاحة مؤقتاً.',
unavailableBody: 'واجهة السوق تعمل، لكن قاعدة البيانات غير متاحة في هذه البيئة المحلية.',
backToExplore: 'العودة إلى الاستكشاف',
partner: 'شريك في السوق',
vehicles: 'مركبات',
offers: 'عروض نشطة',
rating: 'التقييم',
new: 'جديد',
currentOffers: 'العروض الحالية',
validUntil: 'صالح حتى',
noOffers: 'لا توجد عروض نشطة حالياً.',
fleet: 'الأسطول',
viewVehicle: 'عرض السيارة',
availableFrom: 'متاح ابتداءً من',
unavailableNoDate: 'غير متاحة مؤقتاً للحجوزات الجديدة.',
},
}[language]
const company = await storefrontFetchOrDefault<CompanyProfile | null>(`/storefront/${params.slug}`, null)
if (!company) {
return (
<main className="site-page">
<div className="site-section">
<section className="card p-8">
<p className="text-sm uppercase tracking-[0.18em] text-orange-700 dark:text-orange-400">{dict.unavailable}</p>
<h1 className="mt-3 text-3xl font-black text-stone-900 dark:text-stone-100">{dict.unavailableTitle}</h1>
<p className="mt-3 text-stone-600 dark:text-stone-400">{dict.unavailableBody}</p>
<div className="mt-6">
<Link href="/explore" className="rounded-full bg-blue-900 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:text-white dark:hover:bg-orange-400">{dict.backToExplore}</Link>
</div>
</section>
</div>
</main>
)
}
return (
<main className="site-page">
<div className="site-section space-y-8">
<section className="card p-8">
<p className="text-sm uppercase tracking-[0.18em] text-orange-700 dark:text-orange-400">{company.brand?.publicCity ?? dict.partner}</p>
<h1 className="mt-3 text-4xl font-black text-stone-900 dark:text-stone-100">{company.brand?.displayName ?? company.name}</h1>
<p className="mt-3 text-stone-600 dark:text-stone-400">{company.brand?.publicCountry ?? ''}</p>
<div className="mt-6 flex flex-wrap gap-3 text-sm text-stone-500 dark:text-stone-400">
<span>{company.vehicles.length} {dict.vehicles}</span>
<span>{company.offers.length} {dict.offers}</span>
<span>{dict.rating} {company.brand?.marketplaceRating?.toFixed(1) ?? dict.new}</span>
</div>
</section>
<section>
<h2 className="text-2xl font-bold text-stone-900 dark:text-stone-100">{dict.currentOffers}</h2>
<div className="mt-4 grid gap-4 md:grid-cols-2">
{company.offers.map((offer) => (
<div key={offer.id} className="card p-5">
<p className="text-sm font-semibold text-stone-900 dark:text-stone-100">{offer.title}</p>
<p className="mt-2 text-3xl font-black text-orange-700 dark:text-orange-400">{offer.discountValue}%</p>
<p className="mt-2 text-sm text-stone-500 dark:text-stone-400">{dict.validUntil} {new Date(offer.validUntil).toLocaleDateString()}</p>
</div>
))}
{company.offers.length === 0 && <div className="card p-6 text-sm text-stone-500 dark:text-stone-400">{dict.noOffers}</div>}
</div>
</section>
<section>
<h2 className="text-2xl font-bold text-stone-900 dark:text-stone-100">{dict.fleet}</h2>
<div className="mt-4 grid gap-5 md:grid-cols-2 xl:grid-cols-3">
{company.vehicles.map((vehicle) => (
<article key={vehicle.id} className="card">
<div className="aspect-[16/10] overflow-hidden rounded-t-[2rem] bg-stone-100 dark:bg-blue-900/30">
{vehicle.photos[0] ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={vehicle.photos[0]} alt={`${vehicle.make} ${vehicle.model}`} className="h-full w-full object-cover" />
) : null}
</div>
<div className="p-4">
<h3 className="truncate text-lg font-bold text-stone-900 dark:text-stone-100">{vehicle.make} {vehicle.model}</h3>
<p className="mt-1 truncate text-sm text-stone-500 dark:text-stone-400">{vehicle.category}</p>
{!vehicle.availability && (
<p className="mt-2 text-sm text-stone-500 dark:text-stone-400">
{vehicle.nextAvailableAt ? `${dict.availableFrom} ${new Date(vehicle.nextAvailableAt).toLocaleDateString('en-GB', { year: 'numeric', month: 'long', day: 'numeric' })}` : dict.unavailableNoDate}
</p>
)}
<div className="mt-4 flex items-end justify-between gap-3">
<p className="min-w-0 truncate text-xl font-black text-stone-900 dark:text-stone-100">{formatCurrency(vehicle.dailyRate, 'MAD', language)}</p>
<Link href={`/explore/${params.slug}/vehicles/${vehicle.id}`} className="shrink-0 rounded-full bg-blue-900 px-4 py-2 text-sm font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:text-white dark:hover:bg-orange-400">{dict.viewVehicle}</Link>
</div>
</div>
</article>
))}
</div>
</section>
</div>
</main>
)
}
@@ -0,0 +1,283 @@
import Link from 'next/link'
import { storefrontFetchOrDefault } from '@/lib/api'
import { getStorefrontLanguage } from '@/lib/i18n.server'
import { formatCurrency } from '@rentaldrivego/types'
import BookingForm from '@/components/BookingForm'
interface VehicleDetail {
id: string
make: string
model: string
year: number
category: string
seats: number
transmission: string
fuelType: string
features: string[]
dailyRate: number
photos: string[]
pickupLocations: string[]
allowDifferentDropoff: boolean
dropoffLocations: string[]
availability: boolean
availabilityStatus: 'AVAILABLE' | 'RESERVED' | 'MAINTENANCE' | 'UNAVAILABLE'
nextAvailableAt: string | null
company: {
brand: {
displayName: string
subdomain: string
publicPhone: string | null
whatsappNumber: string | null
} | null
}
}
export default async function VehicleDetailPage({ params }: { params: { slug: string; id: string } }) {
const language = await getStorefrontLanguage()
const dict = {
en: {
unavailable: 'Storefront unavailable',
unavailableTitle: 'Vehicle details are temporarily unavailable.',
unavailableBody: 'The storefront frontend is running, but the backing database is not reachable right now.',
backToFleet: 'Back to fleet',
backToExplore: 'Back to explore',
noPhotos: 'No photos available.',
rentalCompany: 'Rental company',
perDay: '/ day',
availableFrom: 'Available from',
unavailableNoDate: 'This vehicle is temporarily unavailable for new reservations.',
specs: 'Specifications',
year: 'Year',
category: 'Category',
seats: 'Seats',
transmission: 'Transmission',
fuel: 'Fuel type',
features: 'Features & extras',
contact: 'Contact company',
phone: 'Phone',
whatsapp: 'WhatsApp',
photos: 'Photos',
},
fr: {
unavailable: 'Storefront indisponible',
unavailableTitle: 'Les détails du véhicule sont temporairement indisponibles.',
unavailableBody: "Le frontend storefront fonctionne, mais la base de données n'est pas accessible pour le moment.",
backToFleet: 'Retour à la flotte',
backToExplore: 'Retour à lexploration',
noPhotos: 'Aucune photo disponible.',
rentalCompany: 'Société de location',
perDay: '/ jour',
availableFrom: 'Disponible à partir du',
unavailableNoDate: 'Ce véhicule est temporairement indisponible pour les nouvelles réservations.',
specs: 'Caractéristiques',
year: 'Année',
category: 'Catégorie',
seats: 'Places',
transmission: 'Transmission',
fuel: 'Carburant',
features: 'Équipements & extras',
contact: 'Contacter la société',
phone: 'Téléphone',
whatsapp: 'WhatsApp',
photos: 'Photos',
},
ar: {
unavailable: 'السوق غير متاح',
unavailableTitle: 'تفاصيل السيارة غير متاحة مؤقتاً.',
unavailableBody: 'واجهة السوق تعمل، لكن قاعدة البيانات في الخلفية غير متاحة حالياً.',
backToFleet: 'العودة إلى الأسطول',
backToExplore: 'العودة إلى الاستكشاف',
noPhotos: 'لا توجد صور متاحة.',
rentalCompany: 'شركة تأجير',
perDay: '/ يوم',
availableFrom: 'متاح ابتداءً من',
unavailableNoDate: 'هذه السيارة غير متاحة مؤقتاً للحجوزات الجديدة.',
specs: 'المواصفات',
year: 'السنة',
category: 'الفئة',
seats: 'المقاعد',
transmission: 'ناقل الحركة',
fuel: 'نوع الوقود',
features: 'المميزات والإضافات',
contact: 'تواصل مع الشركة',
phone: 'الهاتف',
whatsapp: 'واتساب',
photos: 'الصور',
},
}[language]
const vehicle = await storefrontFetchOrDefault<VehicleDetail | null>(`/storefront/${params.slug}/vehicles/${params.id}`, null)
if (!vehicle) {
return (
<main className="site-page">
<div className="site-section">
<section className="card p-8">
<p className="text-sm uppercase tracking-[0.18em] text-orange-700 dark:text-orange-400">{dict.unavailable}</p>
<h1 className="mt-3 text-3xl font-black text-stone-900 dark:text-stone-100">{dict.unavailableTitle}</h1>
<p className="mt-3 text-stone-600 dark:text-stone-400">{dict.unavailableBody}</p>
<div className="mt-6 flex gap-3">
<Link href={`/explore/${params.slug}`} className="rounded-full bg-blue-900 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:text-white dark:hover:bg-orange-400">{dict.backToFleet}</Link>
<Link href="/explore" className="rounded-full border border-stone-200 px-5 py-2.5 text-sm font-semibold text-stone-700 transition hover:border-stone-400 dark:border-blue-800 dark:text-stone-300 dark:hover:border-stone-500">{dict.backToExplore}</Link>
</div>
</section>
</div>
</main>
)
}
const availabilityDate = vehicle.nextAvailableAt
? new Date(vehicle.nextAvailableAt).toLocaleDateString('en-GB', { year: 'numeric', month: 'long', day: 'numeric' })
: null
const [heroPhoto, ...galleryPhotos] = vehicle.photos
return (
<main className="site-page">
<div className="site-section space-y-8">
{/* Breadcrumb */}
<nav className="flex items-center gap-2 text-sm text-stone-500 dark:text-stone-400">
<Link href="/explore" className="hover:text-stone-900 dark:hover:text-stone-100 transition">{dict.backToExplore}</Link>
<span>/</span>
<Link href={`/explore/${params.slug}`} className="hover:text-stone-900 dark:hover:text-stone-100 transition">{vehicle.company.brand?.displayName ?? dict.rentalCompany}</Link>
<span>/</span>
<span className="text-stone-900 dark:text-stone-100 font-medium">{vehicle.make} {vehicle.model}</span>
</nav>
<div className="grid gap-8 lg:grid-cols-[1fr_380px]">
{/* Left — photos + specs */}
<div className="space-y-6">
{/* Hero photo */}
{heroPhoto ? (
<div className="overflow-hidden rounded-3xl bg-stone-100 dark:bg-blue-900/30 aspect-[16/9]">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={heroPhoto} alt={`${vehicle.make} ${vehicle.model}`} className="h-full w-full object-cover" />
</div>
) : (
<div className="overflow-hidden rounded-3xl bg-stone-100 dark:bg-blue-900/30 aspect-[16/9] flex items-center justify-center text-sm text-stone-400">{dict.noPhotos}</div>
)}
{/* Gallery */}
{galleryPhotos.length > 0 && (
<div>
<p className="mb-3 text-xs font-semibold uppercase tracking-[0.16em] text-stone-400 dark:text-stone-500">{dict.photos}</p>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{galleryPhotos.map((photo, index) => (
<div key={`${photo}-${index}`} className="overflow-hidden rounded-2xl bg-stone-100 dark:bg-blue-900/30 aspect-[4/3]">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={photo} alt={`${vehicle.make} ${vehicle.model} ${index + 2}`} className="h-full w-full object-cover" />
</div>
))}
</div>
</div>
)}
{/* Specs */}
<div className="card p-6 space-y-4">
<h2 className="text-lg font-bold text-stone-900 dark:text-stone-100">{dict.specs}</h2>
<dl className="grid grid-cols-2 gap-x-6 gap-y-4 sm:grid-cols-3">
<div>
<dt className="text-xs font-medium uppercase tracking-[0.14em] text-stone-400 dark:text-stone-500">{dict.year}</dt>
<dd className="mt-1 text-base font-semibold text-stone-900 dark:text-stone-100">{vehicle.year}</dd>
</div>
<div>
<dt className="text-xs font-medium uppercase tracking-[0.14em] text-stone-400 dark:text-stone-500">{dict.category}</dt>
<dd className="mt-1 text-base font-semibold text-stone-900 dark:text-stone-100">{vehicle.category}</dd>
</div>
{vehicle.seats > 0 && (
<div>
<dt className="text-xs font-medium uppercase tracking-[0.14em] text-stone-400 dark:text-stone-500">{dict.seats}</dt>
<dd className="mt-1 text-base font-semibold text-stone-900 dark:text-stone-100">{vehicle.seats}</dd>
</div>
)}
{vehicle.transmission && (
<div>
<dt className="text-xs font-medium uppercase tracking-[0.14em] text-stone-400 dark:text-stone-500">{dict.transmission}</dt>
<dd className="mt-1 text-base font-semibold text-stone-900 dark:text-stone-100">{vehicle.transmission}</dd>
</div>
)}
{vehicle.fuelType && (
<div>
<dt className="text-xs font-medium uppercase tracking-[0.14em] text-stone-400 dark:text-stone-500">{dict.fuel}</dt>
<dd className="mt-1 text-base font-semibold text-stone-900 dark:text-stone-100">{vehicle.fuelType}</dd>
</div>
)}
</dl>
{vehicle.features.length > 0 && (
<div className="pt-2 border-t border-stone-100 dark:border-blue-900">
<p className="text-xs font-medium uppercase tracking-[0.14em] text-stone-400 dark:text-stone-500 mb-3">{dict.features}</p>
<div className="flex flex-wrap gap-2">
{vehicle.features.map((feature) => (
<span key={feature} className="rounded-full bg-stone-100 dark:bg-blue-900/30 px-3 py-1 text-xs font-medium text-stone-700 dark:text-stone-300">{feature}</span>
))}
</div>
</div>
)}
</div>
{/* Contact */}
{(vehicle.company.brand?.publicPhone || vehicle.company.brand?.whatsappNumber) && (
<div className="card p-6 space-y-3">
<h2 className="text-lg font-bold text-stone-900 dark:text-stone-100">{dict.contact}</h2>
<p className="text-sm text-stone-500 dark:text-stone-400">{vehicle.company.brand?.displayName ?? dict.rentalCompany}</p>
<div className="flex flex-wrap gap-3">
{vehicle.company.brand?.publicPhone && (
<a href={`tel:${vehicle.company.brand.publicPhone}`} className="inline-flex items-center gap-2 rounded-full border border-stone-300 dark:border-blue-800 px-4 py-2 text-sm font-semibold text-stone-700 dark:text-stone-300 hover:bg-stone-100 dark:hover:bg-blue-900/40 transition">
{dict.phone}: {vehicle.company.brand.publicPhone}
</a>
)}
{vehicle.company.brand?.whatsappNumber && (
<a href={`https://wa.me/${vehicle.company.brand.whatsappNumber.replace(/\D/g, '')}`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-2 rounded-full bg-orange-600 px-4 py-2 text-sm font-semibold text-white hover:bg-orange-700 transition">
{dict.whatsapp}
</a>
)}
</div>
</div>
)}
</div>
{/* Right — sticky booking panel */}
<aside className="space-y-4">
<div className="card p-6 space-y-4 lg:sticky lg:top-20">
<p className="text-xs uppercase tracking-[0.18em] text-stone-500 dark:text-stone-400">{vehicle.company.brand?.displayName ?? dict.rentalCompany}</p>
<h1 className="text-3xl font-black text-stone-900 dark:text-stone-100">{vehicle.make} {vehicle.model}</h1>
<p className="text-stone-500 dark:text-stone-400">{vehicle.year} · {vehicle.category}</p>
<p className="text-3xl font-black text-stone-900 dark:text-stone-100">
{formatCurrency(vehicle.dailyRate, 'MAD', language)}
<span className="text-base font-medium text-stone-500 dark:text-stone-400"> {dict.perDay}</span>
</p>
{!vehicle.availability && (
<div className="rounded-2xl border border-orange-200 bg-orange-50 dark:border-orange-800 dark:bg-orange-950/40 px-4 py-3 text-sm text-orange-800 dark:text-orange-300">
{availabilityDate ? `${dict.availableFrom} ${availabilityDate}` : dict.unavailableNoDate}
</div>
)}
{vehicle.availability && (
<BookingForm
vehicleId={vehicle.id}
companySlug={params.slug}
dailyRate={vehicle.dailyRate}
pickupLocations={vehicle.pickupLocations}
allowDifferentDropoff={vehicle.allowDifferentDropoff}
dropoffLocations={vehicle.dropoffLocations}
/>
)}
<Link
href={`/explore/${params.slug}`}
className="block rounded-full border border-stone-300 dark:border-blue-800 px-6 py-3 text-center text-sm font-semibold text-stone-700 dark:text-stone-300 hover:bg-stone-100 dark:hover:bg-blue-900/40 transition"
>
{dict.backToFleet}
</Link>
</div>
</aside>
</div>
</div>
</main>
)
}
@@ -0,0 +1,329 @@
import Link from 'next/link'
import { storefrontFetchOrDefault } from '@/lib/api'
import { getStorefrontLanguage } from '@/lib/i18n.server'
import ExploreSearchForm from './ExploreSearchForm'
import ExploreVehicleGrid from './ExploreVehicleGrid'
interface Vehicle {
id: string
make: string
model: string
year: number
category: string
dailyRate: number
photos: string[]
pickupLocations: string[]
allowDifferentDropoff: boolean
dropoffLocations: string[]
availability?: boolean | null
availabilityStatus?: 'AVAILABLE' | 'RESERVED' | 'MAINTENANCE' | 'UNAVAILABLE'
nextAvailableAt?: string | null
company: {
slug: string
brand: {
displayName: string
logoUrl: string | null
subdomain: string
marketplaceRating: number | null
} | null
}
}
interface Offer {
id: string
title: string
discountValue: number
validUntil: string
company: {
brand: {
displayName: string
subdomain: string
} | null
}
}
interface CompanyCard {
id: string
brand: {
displayName: string
subdomain: string
publicCity: string | null
marketplaceRating: number | null
} | null
_count: {
vehicles: number
}
}
const CATEGORY_VALUES = [
'ECONOMY',
'COMPACT',
'MIDSIZE',
'FULLSIZE',
'SUV',
'LUXURY',
'VAN',
'TRUCK',
] as const
export default async function ExplorePage({ searchParams }: { searchParams?: Promise<Record<string, string | string[] | undefined>> }) {
const resolvedSearchParams = await searchParams
const language = await getStorefrontLanguage()
const dict = {
en: {
kicker: 'Discovery only',
title: 'Find your next rental from trusted local companies.',
body: 'Browse, filter, and reserve — the company confirms and contacts you directly.',
searchTitle: 'Find a vehicle',
pickupLocation: 'Pick-up location',
dropoffLocation: 'Drop-off location',
sameDropoff: 'Same drop-off',
differentDropoff: 'Different drop-off',
sameAsPickup: 'Return to the same location',
pickupDate: 'Start date',
returnDate: 'Return date',
hour: 'Hour',
promoCode: 'I have a promo code',
myAge: 'My age',
ageDescription: 'Driver age can affect availability, deposit amount, and final pricing.',
ageOptions: ['18+', '21+', '23+', '25+', '30+'],
search: 'Search',
currentDeals: 'Current deals',
featuredOffers: 'Featured storefront offers',
company: 'Company',
validUntil: 'Valid until',
offersUnavailable: 'Storefront offers are unavailable right now.',
availableVehicles: 'Available vehicles',
listings: 'listings',
rentalCompany: 'Rental company',
checkAvailability: 'Check availability',
available: 'Available',
reserved: 'Reserved',
maintenance: 'In maintenance',
unavailableStatus: 'Unavailable',
from: 'From',
viewVehicle: 'View vehicle',
availableFrom: 'Available from',
unavailableNoDate: 'This vehicle is temporarily unavailable for new reservations.',
vehicleUnavailable: 'Vehicle listings are unavailable right now.',
browseByCategory: 'Browse by category',
browseByCompany: 'Browse by company',
storefrontPartners: 'Storefront partners',
storefrontPartner: 'Storefront partner',
rating: 'Rating',
new: 'New',
publishedVehicles: 'published vehicles',
viewFleet: 'View fleet',
categories: ['Economy', 'Compact', 'Midsize', 'Full-size', 'SUV', 'Luxury', 'Van', 'Truck'],
},
fr: {
kicker: 'Découverte uniquement',
title: "Trouvez votre prochaine location auprès d'entreprises locales fiables.",
body: "Parcourez, filtrez et réservez — l'entreprise confirme et vous contacte directement.",
searchTitle: 'Trouver un véhicule',
pickupLocation: 'Lieu de départ',
dropoffLocation: "Lieu de retour",
sameDropoff: 'Même retour',
differentDropoff: 'Retour différent',
sameAsPickup: 'Retour au même lieu',
pickupDate: 'Date de départ',
returnDate: 'Date de retour',
hour: 'Heure',
promoCode: "J'ai un code promo",
myAge: 'Mon âge',
ageDescription: "L'âge du conducteur peut influencer la disponibilité, le dépôt de garantie et le prix final.",
ageOptions: ['18+', '21+', '23+', '25+', '30+'],
search: 'Rechercher',
currentDeals: 'Offres du moment',
featuredOffers: 'Offres storefront mises en avant',
company: 'Entreprise',
validUntil: "Valable jusqu'au",
offersUnavailable: 'Les offres storefront sont indisponibles pour le moment.',
availableVehicles: 'Véhicules disponibles',
listings: 'annonces',
rentalCompany: 'Société de location',
checkAvailability: 'Vérifier la disponibilité',
available: 'Disponible',
reserved: 'Réservé',
maintenance: 'En maintenance',
unavailableStatus: 'Indisponible',
from: 'À partir de',
viewVehicle: 'Voir le véhicule',
availableFrom: 'Disponible à partir du',
unavailableNoDate: 'Ce véhicule est temporairement indisponible pour les nouvelles réservations.',
vehicleUnavailable: 'Les annonces véhicules sont indisponibles pour le moment.',
browseByCategory: 'Parcourir par catégorie',
browseByCompany: 'Parcourir par entreprise',
storefrontPartners: 'Partenaires storefront',
storefrontPartner: 'Partenaire storefront',
rating: 'Note',
new: 'Nouveau',
publishedVehicles: 'véhicules publiés',
viewFleet: 'Voir la flotte',
categories: ['Économie', 'Compacte', 'Intermédiaire', 'Grande berline', 'SUV', 'Luxe', 'Van', 'Camion'],
},
ar: {
kicker: 'للاستكشاف فقط',
title: 'اعثر على سيارتك القادمة من شركات محلية موثوقة.',
body: 'تصفّح وابحث واحجز — تؤكد الشركة وتتواصل معك مباشرةً.',
searchTitle: 'احجز سيارة',
pickupLocation: 'موقع الاستلام',
dropoffLocation: 'موقع الإرجاع',
sameDropoff: 'نفس موقع الإرجاع',
differentDropoff: 'موقع إرجاع مختلف',
sameAsPickup: 'الإرجاع في نفس موقع الاستلام',
pickupDate: 'تاريخ البدء',
returnDate: 'تاريخ الإرجاع',
hour: 'الساعة',
promoCode: 'لدي رمز ترويجي',
myAge: 'عمري',
ageDescription: 'قد يؤثر عمر السائق على التوفر ومبلغ التأمين والسعر النهائي.',
ageOptions: ['18+', '21+', '23+', '25+', '30+'],
search: 'بحث',
currentDeals: 'العروض الحالية',
featuredOffers: 'عروض السوق المميزة',
company: 'شركة',
validUntil: 'صالح حتى',
offersUnavailable: 'عروض السوق غير متاحة حالياً.',
availableVehicles: 'السيارات المتاحة',
listings: 'إعلانات',
rentalCompany: 'شركة تأجير',
checkAvailability: 'تحقق من التوفر',
available: 'متاح',
reserved: 'محجوز',
maintenance: 'قيد الصيانة',
unavailableStatus: 'غير متاح',
from: 'ابتداءً من',
viewVehicle: 'عرض السيارة',
availableFrom: 'متاح ابتداءً من',
unavailableNoDate: 'هذه السيارة غير متاحة مؤقتاً للحجوزات الجديدة.',
vehicleUnavailable: 'قوائم السيارات غير متاحة حالياً.',
browseByCategory: 'تصفح حسب الفئة',
browseByCompany: 'تصفح حسب الشركة',
storefrontPartners: 'شركاء السوق',
storefrontPartner: 'شريك في السوق',
rating: 'التقييم',
new: 'جديد',
publishedVehicles: 'سيارات منشورة',
viewFleet: 'عرض الأسطول',
categories: ['اقتصادية', 'مدمجة', 'متوسطة', 'كبيرة', 'SUV', 'فاخرة', 'فان', 'شاحنة'],
},
}[language]
const city = typeof resolvedSearchParams?.city === 'string' ? resolvedSearchParams.city : ''
const pickupLocation = typeof resolvedSearchParams?.pickupLocation === 'string' ? resolvedSearchParams.pickupLocation : city
const dropoffLocation = typeof resolvedSearchParams?.dropoffLocation === 'string' ? resolvedSearchParams.dropoffLocation : ''
const dropoffMode = resolvedSearchParams?.dropoffMode === 'different' ? 'different' : 'same'
const pickupDate = typeof resolvedSearchParams?.pickupDate === 'string' ? resolvedSearchParams.pickupDate : ''
const pickupTime = typeof resolvedSearchParams?.pickupTime === 'string' ? resolvedSearchParams.pickupTime : '10:00'
const returnDate = typeof resolvedSearchParams?.returnDate === 'string' ? resolvedSearchParams.returnDate : ''
const returnTime = typeof resolvedSearchParams?.returnTime === 'string' ? resolvedSearchParams.returnTime : '10:00'
const promoCode = typeof resolvedSearchParams?.promoCode === 'string' ? resolvedSearchParams.promoCode : ''
const driverAge = typeof resolvedSearchParams?.driverAge === 'string' ? resolvedSearchParams.driverAge : '23+'
const category = typeof resolvedSearchParams?.category === 'string' ? resolvedSearchParams.category : ''
const transmission = typeof resolvedSearchParams?.transmission === 'string' ? resolvedSearchParams.transmission : ''
const make = typeof resolvedSearchParams?.make === 'string' ? resolvedSearchParams.make : ''
const model = typeof resolvedSearchParams?.model === 'string' ? resolvedSearchParams.model : ''
const startDate = typeof resolvedSearchParams?.startDate === 'string' ? resolvedSearchParams.startDate : ''
const endDate = typeof resolvedSearchParams?.endDate === 'string' ? resolvedSearchParams.endDate : ''
const query = new URLSearchParams()
if (pickupLocation) query.set('city', pickupLocation)
if (pickupLocation) query.set('pickupLocation', pickupLocation)
query.set('dropoffMode', dropoffMode)
if (dropoffMode === 'different' && dropoffLocation) query.set('dropoffLocation', dropoffLocation)
if (startDate) query.set('startDate', startDate)
if (endDate) query.set('endDate', endDate)
if (category) query.set('category', category)
if (transmission) query.set('transmission', transmission)
if (make) query.set('make', make)
if (model) query.set('model', model)
query.set('pageSize', '24')
const [offers, vehicles, companies] = await Promise.all([
storefrontFetchOrDefault<Offer[]>('/storefront/offers', []),
storefrontFetchOrDefault<Vehicle[]>(`/storefront/search?${query.toString()}`, []),
storefrontFetchOrDefault<CompanyCard[]>('/storefront/companies?pageSize=8', []),
])
const cities = Array.from(
new Set(
companies
.map((company) => company.brand?.publicCity?.trim())
.filter((city): city is string => Boolean(city))
)
).sort((left, right) => left.localeCompare(right))
return (
<main className="site-page">
<div className="site-section space-y-10">
<section className="site-panel-contrast bg-[linear-gradient(135deg,#0f2460,#1a3a8f)] dark:bg-[linear-gradient(135deg,#0a1735,#112d6e)] px-8 py-12">
<p className="site-kicker text-orange-300 dark:text-orange-300">{dict.kicker}</p>
<h1 className="mt-4 text-4xl font-black tracking-tight">{dict.title}</h1>
<p className="mt-4 max-w-2xl text-blue-100 dark:text-blue-100">{dict.body}</p>
<ExploreSearchForm
pickupLocation={pickupLocation}
dropoffLocation={dropoffLocation}
dropoffMode={dropoffMode}
pickupDate={pickupDate}
pickupTime={pickupTime}
returnDate={returnDate}
returnTime={returnTime}
promoCode={promoCode}
driverAge={driverAge}
cities={cities}
dict={dict}
/>
</section>
<section>
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-stone-900 dark:text-stone-100">{dict.currentDeals}</h2>
<p className="text-sm text-stone-500 dark:text-stone-400">{dict.featuredOffers}</p>
</div>
<div className="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{offers.map((offer) => (
<Link key={offer.id} href={`/explore/${offer.company.brand?.subdomain ?? ''}#offers`} className="card flex flex-col p-5 transition-colors hover:border-orange-300 dark:hover:border-orange-500">
<p className="truncate text-xs uppercase tracking-[0.18em] text-orange-700 dark:text-orange-400">{offer.company.brand?.displayName ?? dict.company}</p>
<h3 className="mt-3 truncate text-lg font-semibold text-stone-900 dark:text-stone-100">{offer.title}</h3>
<p className="mt-3 text-3xl font-black text-stone-900 dark:text-stone-100">{offer.discountValue}%</p>
<p className="mt-2 text-sm text-stone-500 dark:text-stone-400">{dict.validUntil} {new Date(offer.validUntil).toLocaleDateString()}</p>
</Link>
))}
{offers.length === 0 && <div className="card p-6 text-sm text-stone-500 dark:text-stone-400 md:col-span-2 xl:col-span-4">{dict.offersUnavailable}</div>}
</div>
</section>
<ExploreVehicleGrid vehicles={vehicles} dict={dict} language={language} />
<section>
<h2 className="text-2xl font-bold text-stone-900 dark:text-stone-100">{dict.browseByCategory}</h2>
<div className="mt-4 flex flex-wrap gap-3">
{dict.categories.map((categoryName, index) => (
<Link key={CATEGORY_VALUES[index] ?? categoryName} href={`/explore?category=${CATEGORY_VALUES[index]}`} className="rounded-full border border-stone-200 bg-white px-4 py-2 text-sm font-semibold text-stone-700 transition hover:border-stone-400 dark:border-blue-800 dark:bg-blue-950 dark:text-stone-300 dark:hover:border-stone-500">
{categoryName}
</Link>
))}
</div>
</section>
<section>
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-stone-900 dark:text-stone-100">{dict.browseByCompany}</h2>
<p className="text-sm text-stone-500 dark:text-stone-400">{dict.storefrontPartners}</p>
</div>
<div className="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{companies.map((company) => (
<Link key={company.id} href={`/explore/${company.brand?.subdomain ?? ''}`} className="card flex flex-col p-5 transition-colors hover:border-orange-300 dark:hover:border-orange-500">
<p className="truncate text-xs uppercase tracking-[0.18em] text-stone-500 dark:text-stone-400">{company.brand?.publicCity ?? dict.storefrontPartner}</p>
<h3 className="mt-3 truncate text-lg font-semibold text-stone-900 dark:text-stone-100">{company.brand?.displayName ?? dict.rentalCompany}</h3>
<p className="mt-2 text-sm text-stone-500 dark:text-stone-400">{dict.rating} {company.brand?.marketplaceRating?.toFixed(1) ?? dict.new}</p>
<p className="mt-1 text-sm text-stone-500 dark:text-stone-400">{company._count.vehicles} {dict.publishedVehicles}</p>
<span className="mt-4 inline-flex self-start rounded-full bg-blue-900 px-4 py-2 text-sm font-semibold text-white dark:bg-orange-500 dark:text-white">{dict.viewFleet}</span>
</Link>
))}
</div>
</section>
</div>
</main>
)
}
@@ -0,0 +1,34 @@
import React, { isValidElement } from 'react'
import { describe, expect, it, vi } from 'vitest'
const navigation = vi.hoisted(() => ({
notFound: vi.fn(() => {
throw new Error('NEXT_NOT_FOUND')
}),
}))
vi.mock('next/navigation', () => navigation)
import FooterContentPage from '@/components/FooterContentPage'
import FooterPage, { generateStaticParams } from './page'
import { footerPageSlugs } from '@/lib/footerContent'
describe('storefront footer route', () => {
it('generates one static route per registered footer slug', () => {
expect(generateStaticParams()).toEqual(footerPageSlugs.map((slug) => ({ slug })))
})
it('passes valid slugs through to FooterContentPage', () => {
const element = FooterPage({ params: { slug: 'privacy-policy' } })
expect(isValidElement(element)).toBe(true)
expect(element.type).toBe(FooterContentPage)
expect(element.props.slug).toBe('privacy-policy')
expect(element.props.forcedLanguage).toBeUndefined()
})
it('rejects unknown slugs instead of rendering arbitrary policy pages', () => {
expect(() => FooterPage({ params: { slug: 'definitely-not-a-policy' } })).toThrow('NEXT_NOT_FOUND')
expect(navigation.notFound).toHaveBeenCalled()
})
})
@@ -0,0 +1,22 @@
import React from 'react'
import { notFound } from 'next/navigation'
import FooterContentPage from '@/components/FooterContentPage'
import { footerPageSlugs, isFooterPageSlug } from '@/lib/footerContent'
export function generateStaticParams() {
return footerPageSlugs.map((slug) => ({ slug }))
}
export default function FooterPage({
params,
}: {
params: { slug: string }
}) {
const { slug } = params
if (!isFooterPageSlug(slug)) {
notFound()
}
return <FooterContentPage slug={slug} />
}
+6
View File
@@ -0,0 +1,6 @@
import type { ReactNode } from 'react'
import SitePageLayout from '@/components/public/SitePageLayout'
export default function PublicLayout({ children }: { children: ReactNode }) {
return <SitePageLayout>{children}</SitePageLayout>
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation'
export default function StorefrontHomePage() {
redirect('/explore')
}
+293
View File
@@ -0,0 +1,293 @@
@import "../styles/phase16-tokens.css";
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
@apply bg-white text-blue-950 antialiased transition-colors dark:text-slate-100;
}
html.dark body {
background-image:
linear-gradient(180deg, #0a1535 0%, #0d1f52 35%, #091228 100%);
}
.shell {
@apply mx-auto max-w-7xl px-4 sm:px-6 lg:px-8;
}
.card {
@apply rounded-[2rem] border shadow-[0_30px_80px_rgba(28,25,23,0.08)] backdrop-blur transition-colors dark:shadow-[0_30px_80px_rgba(0,0,0,0.36)];
border-color: rgb(231 229 228 / 0.8);
background-color: rgb(255 255 255 / 0.82);
}
html.dark .card {
border-color: rgb(30 60 140 / 0.40);
background-color: rgb(11 25 55 / 0.72);
}
.site-page {
@apply min-h-screen overflow-hidden text-blue-950;
background-image:
linear-gradient(180deg, #ffffff 0%, #f5f8ff 28%, #eef4ff 58%, #ffffff 100%);
}
html.dark .site-page {
@apply text-slate-100;
background-image:
linear-gradient(180deg, #0a1535 0%, #0d1f52 35%, #091228 100%);
}
.site-glow {
background-image:
radial-gradient(circle at top left, rgba(234, 88, 12, 0.24), transparent 34%),
radial-gradient(circle at top right, rgba(59, 130, 246, 0.18), transparent 26%);
}
html.dark .site-glow {
background-image:
radial-gradient(circle at top left, rgba(251, 146, 60, 0.22), transparent 34%),
radial-gradient(circle at top right, rgba(96, 165, 250, 0.18), transparent 26%);
}
.site-section {
@apply shell py-10 sm:py-14 lg:py-16;
}
.site-panel {
@apply rounded-[2rem] border p-7 shadow-[0_30px_80px_rgba(28,25,23,0.08)] backdrop-blur transition-colors sm:p-8;
border-color: rgb(231 229 228 / 0.8);
background-color: rgb(255 255 255 / 0.82);
}
html.dark .site-panel {
border-color: rgb(30 60 140 / 0.40);
background-color: rgb(11 25 55 / 0.72);
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.36);
}
.site-panel-muted {
@apply rounded-[2rem] border p-7 transition-colors sm:p-8;
border-color: rgb(231 229 228 / 0.8);
background-image: linear-gradient(160deg, #f5f8ff 0%, #edf2ff 100%);
}
html.dark .site-panel-muted {
border-color: rgb(30 60 140 / 0.40);
background-image: linear-gradient(160deg, #0c1830 0%, #10203e 100%);
}
.site-panel-contrast {
@apply rounded-[2rem] border p-7 text-white shadow-[0_30px_80px_rgba(28,25,23,0.18)] transition-colors sm:p-8;
border-color: rgb(14 40 90 / 0.8);
background-color: rgb(10 25 75);
}
html.dark .site-panel-contrast {
border-color: rgb(30 64 175);
}
.site-kicker {
@apply text-xs font-bold uppercase tracking-[0.28em] text-orange-600 dark:text-orange-400;
}
.site-title {
@apply mt-4 text-4xl font-black tracking-[-0.04em] text-blue-950 dark:text-white sm:text-5xl;
}
.site-lead {
@apply mt-5 text-base leading-8 text-stone-600 dark:text-slate-300 sm:text-lg;
}
.site-link-primary {
@apply rounded-full bg-orange-600 px-6 py-3 text-sm font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:hover:bg-orange-400;
}
.site-link-secondary {
@apply rounded-full border border-stone-300 bg-white/85 px-6 py-3 text-sm font-semibold text-stone-700 transition hover:border-blue-900 hover:text-blue-900 dark:border-blue-800 dark:bg-blue-950/30 dark:text-blue-100 dark:hover:border-blue-400 dark:hover:text-white;
}
/* Phase 16 operational application layer */
@layer base {
html {
min-width: 320px;
background: var(--rdg-surface-page);
color: var(--rdg-text-primary);
scroll-behavior: smooth;
text-rendering: optimizeLegibility;
}
body {
min-height: 100dvh;
background: var(--rdg-surface-page);
color: var(--rdg-text-primary);
font-family: var(--rdg-font-latin);
line-height: 1.6;
}
h1,
h2,
h3,
h4,
h5,
h6 {
color: var(--rdg-text-primary);
line-height: 1.18;
text-wrap: balance;
}
p {
text-wrap: pretty;
}
button,
input,
select,
textarea {
font: inherit;
}
a,
button,
input,
select,
textarea {
-webkit-tap-highlight-color: transparent;
}
}
@layer components {
.shell {
width: min(100%, var(--rdg-container-max));
}
.panel,
.card,
.glass-card,
.site-panel {
border-color: var(--rdg-border);
border-radius: var(--rdg-radius-lg);
background: color-mix(in srgb, var(--rdg-surface-elevated) 92%, transparent);
box-shadow: var(--rdg-shadow-card);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
}
.site-panel-muted {
border-color: var(--rdg-border);
border-radius: var(--rdg-radius-lg);
background: var(--rdg-surface-muted);
}
.site-panel-contrast {
border-color: color-mix(in srgb, var(--rdg-blue-700) 68%, var(--rdg-border));
border-radius: var(--rdg-radius-lg);
background:
radial-gradient(circle at 90% 10%, color-mix(in srgb, var(--rdg-blue-500) 30%, transparent), transparent 40%),
linear-gradient(135deg, var(--rdg-navy-900), var(--rdg-blue-800));
}
.btn-primary,
.site-link-primary {
min-height: var(--rdg-touch-min);
border-radius: var(--rdg-radius-sm);
background: var(--rdg-action-conversion);
color: #ffffff;
box-shadow: var(--rdg-shadow-subtle);
}
.btn-primary:hover,
.site-link-primary:hover {
background: var(--rdg-action-conversion-hover);
}
.btn-secondary,
.site-link-secondary {
min-height: var(--rdg-touch-min);
border-color: var(--rdg-border-strong);
border-radius: var(--rdg-radius-sm);
background: var(--rdg-surface-primary);
color: var(--rdg-action-primary);
}
.btn-secondary:hover,
.site-link-secondary:hover {
border-color: var(--rdg-action-primary);
background: var(--rdg-soft-blue);
color: var(--rdg-action-primary-hover);
}
.input-field,
input:not([type='checkbox']):not([type='radio']):not([type='range']),
select,
textarea {
min-height: var(--rdg-touch-min);
border-color: var(--rdg-border);
border-radius: var(--rdg-radius-sm);
background: var(--rdg-surface-primary);
color: var(--rdg-text-primary);
}
.input-field::placeholder,
input::placeholder,
textarea::placeholder {
color: var(--rdg-text-subdued);
}
table {
border-color: var(--rdg-border);
}
thead {
background: var(--rdg-surface-muted);
color: var(--rdg-text-secondary);
}
tbody tr {
border-color: var(--rdg-border);
}
}
@media (forced-colors: active) {
.panel,
.card,
.glass-card,
.site-panel,
.site-panel-muted,
.site-panel-contrast {
border: 1px solid CanvasText;
box-shadow: none;
}
}
html.dark body,
html[data-theme='dark'] body,
.site-page,
html.dark .site-page,
html[data-theme='dark'] .site-page {
background:
radial-gradient(circle at 12% 0%, color-mix(in srgb, var(--rdg-soft-blue) 78%, transparent), transparent 30%),
var(--rdg-surface-page);
color: var(--rdg-text-primary);
}
.site-glow,
html.dark .site-glow,
html[data-theme='dark'] .site-glow {
background-image:
radial-gradient(circle at top left, color-mix(in srgb, var(--rdg-soft-orange) 72%, transparent), transparent 34%),
radial-gradient(circle at top right, color-mix(in srgb, var(--rdg-soft-blue) 72%, transparent), transparent 28%);
}
html.dark .card,
html.dark .site-panel,
html.dark .site-panel-muted,
html[data-theme='dark'] .card,
html[data-theme='dark'] .site-panel,
html[data-theme='dark'] .site-panel-muted {
border-color: var(--rdg-border);
background: color-mix(in srgb, var(--rdg-surface-elevated) 92%, transparent);
box-shadow: var(--rdg-shadow-card);
}
+41
View File
@@ -0,0 +1,41 @@
import type { Metadata } from 'next'
import { cookies } from 'next/headers'
import StorefrontShell from '@/components/StorefrontShell'
import { getStorefrontLanguage } from '@/lib/i18n.server'
import './globals.css'
export const metadata: Metadata = {
title: 'RentalDriveGo Storefront',
description: 'Discover vehicles from trusted rental companies.',
icons: {
icon: '/rentaldrivego.png',
shortcut: '/favicon.ico',
apple: '/rentaldrivego.png',
},
}
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const language = await getStorefrontLanguage()
const cookieStore = await cookies()
const rawTheme =
cookieStore.get('rentaldrivego-theme')?.value ??
cookieStore.get('storefront-theme')?.value
const theme = rawTheme === 'dark' ? 'dark' : 'light'
return (
<html lang={language} dir={language === 'ar' ? 'rtl' : 'ltr'} suppressHydrationWarning>
<head>
{/* Runs before hydration to prevent flash of wrong theme */}
<script
dangerouslySetInnerHTML={{
__html:
"(function(){try{var m=document.cookie.match(/(?:^|; )rentaldrivego-theme=([^;]+)/);var theme=m?decodeURIComponent(m[1]):(localStorage.getItem('rentaldrivego-theme')||localStorage.getItem('storefront-theme'));if(theme!=='light'&&theme!=='dark'){theme=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'}document.documentElement.classList.toggle('dark',theme==='dark');document.documentElement.style.colorScheme=theme;document.body&&document.body.setAttribute('data-theme',theme)}catch(e){}})();",
}}
/>
</head>
<body suppressHydrationWarning>
<StorefrontShell initialLanguage={language} initialTheme={theme}>{children}</StorefrontShell>
</body>
</html>
)
}
@@ -0,0 +1,304 @@
'use client'
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useStorefrontPreferences } from '@/components/StorefrontShell'
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
interface SavedCompany {
id: string
brand?: {
displayName: string
subdomain: string
logoUrl?: string | null
} | null
}
interface RenterProfile {
id: string
firstName: string
lastName: string
email: string
phone?: string | null
savedCompanies?: SavedCompany[]
}
type Status = 'loading' | 'ready' | 'error'
export default function RenterDashboardPage() {
const router = useRouter()
const { language } = useStorefrontPreferences()
const dict = {
en: {
loadProfile: 'Could not load profile.',
reachServer: 'Could not reach the server.',
backToExplore: 'Back to explore',
renterDashboard: 'Renter Dashboard',
welcome: 'Welcome back,',
exploreVehicles: 'Explore vehicles',
logout: 'Log out',
cards: [
['Profile', 'Review your account details and preferences.'],
['Saved companies', 'Jump back into companies you bookmarked.'],
['Notifications', 'See booking updates and in-app alerts.'],
],
yourProfile: 'Your profile',
firstName: 'First name',
lastName: 'Last name',
email: 'Email',
phone: 'Phone',
savedCompanies: 'Saved companies',
noneSaved: 'None saved yet',
saved: 'saved',
saveCompaniesPrompt: 'Save companies you like while browsing the storefront.',
startExploring: 'Start exploring',
rentalCompany: 'Rental company',
},
fr: {
loadProfile: 'Impossible de charger le profil.',
reachServer: 'Impossible de joindre le serveur.',
backToExplore: 'Retour à lexploration',
renterDashboard: 'Espace client',
welcome: 'Bon retour,',
exploreVehicles: 'Explorer les véhicules',
logout: 'Déconnexion',
cards: [
['Profil', 'Consultez vos informations et préférences.'],
['Entreprises sauvegardées', 'Revenez vers les entreprises que vous avez enregistrées.'],
['Notifications', 'Consultez les mises à jour de réservation et les alertes.'],
],
yourProfile: 'Votre profil',
firstName: 'Prénom',
lastName: 'Nom',
email: 'E-mail',
phone: 'Téléphone',
savedCompanies: 'Entreprises sauvegardées',
noneSaved: 'Aucune pour le moment',
saved: 'sauvegardées',
saveCompaniesPrompt: 'Enregistrez les entreprises que vous aimez en parcourant la storefront.',
startExploring: 'Commencer à explorer',
rentalCompany: 'Société de location',
},
ar: {
loadProfile: 'تعذر تحميل الملف الشخصي.',
reachServer: 'تعذر الوصول إلى الخادم.',
backToExplore: 'العودة إلى الاستكشاف',
renterDashboard: 'بوابة المستأجر',
welcome: 'مرحباً بعودتك،',
exploreVehicles: 'استكشف السيارات',
logout: 'تسجيل الخروج',
cards: [
['الملف الشخصي', 'راجع بيانات حسابك وتفضيلاتك.'],
['الشركات المحفوظة', 'ارجع بسرعة إلى الشركات التي حفظتها.'],
['الإشعارات', 'اطلع على تحديثات الحجز والتنبيهات داخل التطبيق.'],
],
yourProfile: 'ملفك الشخصي',
firstName: 'الاسم الأول',
lastName: 'اسم العائلة',
email: 'البريد الإلكتروني',
phone: 'الهاتف',
savedCompanies: 'الشركات المحفوظة',
noneSaved: 'لا يوجد شيء محفوظ بعد',
saved: 'محفوظة',
saveCompaniesPrompt: 'احفظ الشركات التي تعجبك أثناء تصفح السوق.',
startExploring: 'ابدأ الاستكشاف',
rentalCompany: 'شركة تأجير',
},
}[language]
const [profile, setProfile] = useState<RenterProfile | null>(null)
const [status, setStatus] = useState<Status>('loading')
const [errorMsg, setErrorMsg] = useState('')
useEffect(() => {
fetch(`${API_BASE}/auth/renter/me`, {
credentials: 'include',
})
.then(async (res) => {
const json = await res.json().catch(() => null)
if (!res.ok) {
if (res.status === 401) {
void fetch(`${API_BASE}/auth/renter/logout`, { method: 'POST', credentials: 'include' })
router.replace('/')
} else {
setErrorMsg(json?.message ?? dict.loadProfile)
setStatus('error')
}
return
}
setProfile(json?.data ?? json)
setStatus('ready')
})
.catch(() => {
setErrorMsg(dict.reachServer)
setStatus('error')
})
}, [router])
if (status === 'loading') {
return (
<div className="shell">
<div className="animate-pulse space-y-6">
<div className="h-8 w-48 rounded-xl bg-stone-200" />
<div className="h-40 rounded-2xl bg-stone-200" />
<div className="h-40 rounded-2xl bg-stone-200" />
</div>
</div>
)
}
if (status === 'error') {
return (
<div className="shell">
<div className="card p-8 text-center">
<p className="text-sm text-rose-600">{errorMsg}</p>
<Link
href="/explore"
className="mt-4 inline-flex rounded-full bg-orange-600 px-6 py-2.5 text-sm font-semibold text-white"
>
{dict.backToExplore}
</Link>
</div>
</div>
)
}
const savedCompanies = profile?.savedCompanies ?? []
return (
<div className="shell space-y-8">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-orange-700">
{dict.renterDashboard}
</p>
<h1 className="mt-1 text-3xl font-black tracking-tight text-blue-900">
{dict.welcome} {profile?.firstName}
</h1>
</div>
<Link
href="/explore"
className="self-start rounded-full border border-stone-200 bg-white px-5 py-2.5 text-sm font-semibold text-stone-700 shadow-sm hover:border-stone-300"
>
{dict.exploreVehicles}
</Link>
</div>
<section className="grid gap-4 sm:grid-cols-3">
{[
{ href: '/renter/profile', label: dict.cards[0][0], copy: dict.cards[0][1] },
{ href: '/renter/saved-companies', label: dict.cards[1][0], copy: dict.cards[1][1] },
{ href: '/renter/notifications', label: dict.cards[2][0], copy: dict.cards[2][1] },
].map((item) => (
<Link key={item.href} href={item.href} className="card p-5 hover:border-orange-300 transition-colors">
<p className="text-sm font-semibold text-blue-900">{item.label}</p>
<p className="mt-2 text-sm leading-6 text-stone-500">{item.copy}</p>
</Link>
))}
</section>
{/* Profile card */}
<section className="card p-8">
<h2 className="text-lg font-bold text-blue-900">{dict.yourProfile}</h2>
<div className="mt-6 grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
{[
{ label: dict.firstName, value: profile?.firstName },
{ label: dict.lastName, value: profile?.lastName },
{ label: dict.email, value: profile?.email },
{ label: dict.phone, value: profile?.phone ?? '—' },
].map(({ label, value }) => (
<div key={label}>
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-stone-400">
{label}
</p>
<p className="mt-1.5 text-sm font-medium text-blue-900 break-all">
{value}
</p>
</div>
))}
</div>
</section>
{/* Saved companies */}
<section>
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-blue-900">{dict.savedCompanies}</h2>
<p className="text-sm text-stone-500">
{savedCompanies.length === 0
? dict.noneSaved
: `${savedCompanies.length} ${dict.saved}`}
</p>
</div>
{savedCompanies.length === 0 ? (
<div className="card mt-4 flex flex-col items-center gap-4 py-16 text-center">
<svg
className="h-10 w-10 text-stone-300"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M17.593 3.322c1.1.128 1.907 1.077 1.907 2.185V21L12 17.25 4.5 21V5.507c0-1.108.806-2.057 1.907-2.185a48.507 48.507 0 0111.186 0z"
/>
</svg>
<p className="text-sm text-stone-500">
{dict.saveCompaniesPrompt}
</p>
<Link
href="/explore"
className="rounded-full bg-orange-600 px-6 py-2.5 text-sm font-semibold text-white hover:bg-orange-700"
>
{dict.startExploring}
</Link>
</div>
) : (
<div className="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{savedCompanies.map((company) => (
<Link
key={company.id}
href={`/explore/${company.brand?.subdomain ?? company.id}`}
className="card flex items-center gap-4 p-5 hover:border-orange-300 transition-colors"
>
{company.brand?.logoUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={company.brand.logoUrl}
alt={company.brand.displayName}
className="h-10 w-10 rounded-lg object-contain"
/>
) : (
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-orange-100 text-orange-700 font-bold text-sm">
{company.brand?.displayName?.charAt(0) ?? '?'}
</div>
)}
<div className="min-w-0">
<p className="truncate font-semibold text-blue-900">
{company.brand?.displayName ?? dict.rentalCompany}
</p>
<p className="mt-0.5 text-xs text-stone-400">
{company.brand?.subdomain ?? ''}
</p>
</div>
<svg
className="ml-auto h-4 w-4 shrink-0 text-stone-400"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</Link>
))}
</div>
)}
</section>
</div>
)
}
+5
View File
@@ -0,0 +1,5 @@
import RenterShell from '@/components/RenterShell'
export default function RenterLayout({ children }: { children: React.ReactNode }) {
return <RenterShell>{children}</RenterShell>
}
@@ -0,0 +1,153 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import {
RenterAuthError,
loadRenterNotifications,
markAllRenterNotificationsRead,
markRenterNotificationRead,
type RenterNotification,
} from '@/lib/renter'
import { useStorefrontPreferences } from '@/components/StorefrontShell'
export default function RenterNotificationsPage() {
const router = useRouter()
const { language } = useStorefrontPreferences()
const dict = {
en: {
load: 'Could not load notifications.',
updateOne: 'Could not update the notification.',
updateAll: 'Could not update notifications.',
renter: 'Renter',
title: 'Notifications',
markAll: 'Mark all read',
back: 'Back to dashboard',
loading: 'Loading notifications…',
empty: 'No renter notifications yet.',
read: 'Read',
markRead: 'Mark read',
},
fr: {
load: 'Impossible de charger les notifications.',
updateOne: 'Impossible de mettre à jour la notification.',
updateAll: 'Impossible de mettre à jour les notifications.',
renter: 'Client',
title: 'Notifications',
markAll: 'Tout marquer comme lu',
back: 'Retour au tableau de bord',
loading: 'Chargement des notifications…',
empty: 'Aucune notification client pour le moment.',
read: 'Lu',
markRead: 'Marquer comme lu',
},
ar: {
load: 'تعذر تحميل الإشعارات.',
updateOne: 'تعذر تحديث الإشعار.',
updateAll: 'تعذر تحديث الإشعارات.',
renter: 'المستأجر',
title: 'الإشعارات',
markAll: 'تعيين الكل كمقروء',
back: 'العودة إلى بوابة المستأجر',
loading: 'جارٍ تحميل الإشعارات…',
empty: 'لا توجد إشعارات للمستأجر حالياً.',
read: 'مقروء',
markRead: 'تعيين كمقروء',
},
}[language]
const [notifications, setNotifications] = useState<RenterNotification[]>([])
const [errorMsg, setErrorMsg] = useState('')
const [loading, setLoading] = useState(true)
const [updating, setUpdating] = useState(false)
useEffect(() => {
loadRenterNotifications()
.then((items) => setNotifications(items))
.catch((err) => {
if (err instanceof RenterAuthError) {
router.replace('/')
return
}
setErrorMsg(err instanceof Error ? err.message : dict.load)
})
.finally(() => setLoading(false))
}, [router])
async function markRead(id: string) {
setUpdating(true)
try {
await markRenterNotificationRead(id)
setNotifications((current) =>
current.map((item) => (item.id === id ? { ...item, readAt: item.readAt ?? new Date().toISOString() } : item)),
)
} catch (err) {
setErrorMsg(err instanceof Error ? err.message : dict.updateOne)
} finally {
setUpdating(false)
}
}
async function markAllRead() {
setUpdating(true)
try {
await markAllRenterNotificationsRead()
setNotifications((current) => current.map((item) => ({ ...item, readAt: item.readAt ?? new Date().toISOString() })))
} catch (err) {
setErrorMsg(err instanceof Error ? err.message : dict.updateAll)
} finally {
setUpdating(false)
}
}
return (
<div className="shell space-y-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<h1 className="text-3xl font-black tracking-tight text-blue-900">{dict.title}</h1>
<button
type="button"
onClick={markAllRead}
disabled={loading || updating}
className="rounded-full border border-stone-200 bg-white px-5 py-2.5 text-sm font-semibold text-stone-700"
>
{dict.markAll}
</button>
</div>
{loading ? <div className="card p-8 text-sm text-stone-500">{dict.loading}</div> : null}
{!loading && errorMsg ? <div className="card p-8 text-sm text-rose-600">{errorMsg}</div> : null}
{!loading && !errorMsg && notifications.length === 0 ? (
<div className="card p-10 text-center text-sm text-stone-500">{dict.empty}</div>
) : null}
{!loading && !errorMsg && notifications.length > 0 ? (
<div className="space-y-4">
{notifications.map((notification) => (
<article key={notification.id} className="card p-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-orange-700">{notification.type.replaceAll('_', ' ')}</p>
<h2 className="mt-2 text-lg font-bold text-blue-900">{notification.title}</h2>
</div>
{notification.readAt ? (
<span className="rounded-full bg-stone-100 px-3 py-1 text-xs font-semibold text-stone-600">{dict.read}</span>
) : (
<button
type="button"
onClick={() => markRead(notification.id)}
disabled={updating}
className="rounded-full bg-orange-100 px-3 py-1 text-xs font-semibold text-orange-800"
>
{dict.markRead}
</button>
)}
</div>
<p className="mt-3 text-sm leading-7 text-stone-600">{notification.body}</p>
<p className="mt-4 text-xs text-stone-400">{new Date(notification.createdAt).toLocaleString()}</p>
</article>
))}
</div>
) : null}
</div>
)
}
+318
View File
@@ -0,0 +1,318 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import {
RenterAuthError,
clearRenterSession,
loadRenterPreferences,
loadRenterProfile,
updateRenterPreferences,
updateRenterProfile,
type RenterPreference,
type RenterProfile,
} from '@/lib/renter'
import { useStorefrontPreferences } from '@/components/StorefrontShell'
type Status = 'loading' | 'ready' | 'error'
const RENTER_EVENTS = ['BOOKING_CONFIRMED', 'BOOKING_REMINDER', 'RETURN_REMINDER', 'REFUND_ISSUED', 'NEW_OFFER']
const RENTER_CHANNELS = ['EMAIL', 'SMS', 'WHATSAPP', 'PUSH', 'IN_APP']
export default function RenterProfilePage() {
const router = useRouter()
const { language } = useStorefrontPreferences()
const dict = {
en: {
profile: 'Profile',
loading: 'Loading profile…',
loadFailed: 'Could not load profile.',
yourProfile: 'Your profile',
firstName: 'First name',
lastName: 'Last name',
email: 'Email',
phone: 'Phone',
locale: 'Locale',
currency: 'Currency',
saving: 'Saving…',
saveProfile: 'Save profile',
verification: 'Verification',
verified: 'Email verified',
pending: 'Email verification is still pending.',
notificationPrefs: 'Notification preferences',
notificationBody: 'Choose how reminders, offers, and booking updates reach you.',
savePreferences: 'Save preferences',
event: 'Event',
back: 'Back to dashboard',
savedCompanies: 'Saved companies',
notifications: 'Notifications',
logout: 'Log out',
renter: 'Renter',
saveFailed: 'Could not save profile.',
prefFailed: 'Could not save preferences.',
},
fr: {
profile: 'Profil',
loading: 'Chargement du profil…',
loadFailed: 'Impossible de charger le profil.',
yourProfile: 'Votre profil',
firstName: 'Prénom',
lastName: 'Nom',
email: 'Email',
phone: 'Téléphone',
locale: 'Langue',
currency: 'Devise',
saving: 'Enregistrement…',
saveProfile: 'Enregistrer le profil',
verification: 'Vérification',
verified: 'Email vérifié',
pending: 'La vérification de lemail est encore en attente.',
notificationPrefs: 'Préférences de notification',
notificationBody: 'Choisissez comment les rappels, offres et mises à jour vous parviennent.',
savePreferences: 'Enregistrer les préférences',
event: 'Événement',
back: 'Retour au dashboard',
savedCompanies: 'Entreprises sauvegardées',
notifications: 'Notifications',
logout: 'Déconnexion',
renter: 'Client',
saveFailed: 'Impossible denregistrer le profil.',
prefFailed: 'Impossible denregistrer les préférences.',
},
ar: {
profile: 'الملف الشخصي',
loading: 'جارٍ تحميل الملف الشخصي…',
loadFailed: 'تعذر تحميل الملف الشخصي.',
yourProfile: 'ملفك الشخصي',
firstName: 'الاسم الأول',
lastName: 'اسم العائلة',
email: 'البريد الإلكتروني',
phone: 'الهاتف',
locale: 'اللغة',
currency: 'العملة',
saving: 'جارٍ الحفظ…',
saveProfile: 'حفظ الملف الشخصي',
verification: 'التحقق',
verified: 'تم التحقق من البريد الإلكتروني',
pending: 'التحقق من البريد الإلكتروني ما زال قيد الانتظار.',
notificationPrefs: 'تفضيلات الإشعارات',
notificationBody: 'اختر كيف تصلك التذكيرات والعروض وتحديثات الحجز.',
savePreferences: 'حفظ التفضيلات',
event: 'الحدث',
back: 'العودة إلى اللوحة',
savedCompanies: 'الشركات المحفوظة',
notifications: 'الإشعارات',
logout: 'تسجيل الخروج',
renter: 'المستأجر',
saveFailed: 'تعذر حفظ الملف الشخصي.',
prefFailed: 'تعذر حفظ التفضيلات.',
},
}[language]
const [profile, setProfile] = useState<RenterProfile | null>(null)
const [preferences, setPreferences] = useState<Record<string, boolean>>({})
const [status, setStatus] = useState<Status>('loading')
const [errorMsg, setErrorMsg] = useState('')
const [saving, setSaving] = useState(false)
useEffect(() => {
Promise.all([loadRenterProfile(), loadRenterPreferences()])
.then(([profileData, preferenceData]) => {
setProfile(profileData)
setPreferences(
Object.fromEntries(
preferenceData.map((item) => [`${item.notificationType}:${item.channel}`, item.enabled]),
),
)
setStatus('ready')
})
.catch((err) => {
if (err instanceof RenterAuthError) {
router.replace('/')
return
}
setErrorMsg(err instanceof Error ? err.message : dict.loadFailed)
setStatus('error')
})
}, [router])
if (status === 'loading') {
return <div className="shell"><div className="card p-8 text-sm text-stone-500">{dict.loading}</div></div>
}
if (status === 'error') {
return <div className="shell"><ErrorCard message={errorMsg} /></div>
}
return (
<div className="shell space-y-6">
<div className="card p-8">
<h1 className="text-3xl font-black tracking-tight text-blue-900">{dict.yourProfile}</h1>
<div className="mt-8 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
<EditableField label={dict.firstName} value={profile?.firstName ?? ''} onChange={(value) => setProfile((current) => current ? { ...current, firstName: value } : current)} />
<EditableField label={dict.lastName} value={profile?.lastName ?? ''} onChange={(value) => setProfile((current) => current ? { ...current, lastName: value } : current)} />
<Field label={dict.email} value={profile?.email} />
<EditableField label={dict.phone} value={profile?.phone ?? ''} onChange={(value) => setProfile((current) => current ? { ...current, phone: value } : current)} />
<SelectField
label={dict.locale}
value={profile?.preferredLocale || 'en'}
options={['en', 'fr', 'ar']}
onChange={(value) => setProfile((current) => current ? { ...current, preferredLocale: value } : current)}
/>
<SelectField
label={dict.currency}
value="MAD"
options={['MAD']}
onChange={() => {}}
/>
</div>
<div className="mt-8 flex justify-end">
<button
type="button"
onClick={async () => {
if (!profile) return
setSaving(true)
setErrorMsg('')
try {
const updated = await updateRenterProfile({
firstName: profile.firstName,
lastName: profile.lastName,
phone: profile.phone,
preferredLocale: profile.preferredLocale,
preferredCurrency: profile.preferredCurrency,
})
setProfile(updated)
} catch (err) {
setErrorMsg(err instanceof Error ? err.message : dict.saveFailed)
} finally {
setSaving(false)
}
}}
className="rounded-full bg-orange-600 px-5 py-2 text-sm font-semibold text-white"
>
{saving ? dict.saving : dict.saveProfile}
</button>
</div>
<div className="mt-8 rounded-2xl border border-stone-200 bg-stone-50 p-5">
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-stone-500">{dict.verification}</p>
<p className="mt-2 text-sm text-stone-700">
{profile?.emailVerified ? dict.verified : dict.pending}
</p>
</div>
</div>
<div className="card p-8">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h2 className="text-2xl font-bold text-blue-900">{dict.notificationPrefs}</h2>
<p className="mt-2 text-sm text-stone-600">{dict.notificationBody}</p>
</div>
<button
type="button"
onClick={async () => {
setSaving(true)
setErrorMsg('')
try {
const payload: RenterPreference[] = Object.entries(preferences).map(([key, enabled]) => {
const [notificationType, channel] = key.split(':')
return { notificationType, channel, enabled }
})
await updateRenterPreferences(payload)
} catch (err) {
setErrorMsg(err instanceof Error ? err.message : dict.prefFailed)
} finally {
setSaving(false)
}
}}
className="rounded-full bg-orange-600 px-5 py-2 text-sm font-semibold text-white"
>
{saving ? dict.saving : dict.savePreferences}
</button>
</div>
<div className="mt-6 overflow-x-auto">
<table className="min-w-full text-sm">
<thead className="bg-stone-50">
<tr>
<th className="px-4 py-3 text-left font-semibold text-stone-500">{dict.event}</th>
{RENTER_CHANNELS.map((channel) => (
<th key={channel} className="px-4 py-3 text-center font-semibold text-stone-500">
{channel}
</th>
))}
</tr>
</thead>
<tbody>
{RENTER_EVENTS.map((eventName) => (
<tr key={eventName} className="border-t border-stone-100">
<td className="px-4 py-3 font-medium text-blue-900">{eventName.replaceAll('_', ' ')}</td>
{RENTER_CHANNELS.map((channel) => (
<td key={`${eventName}-${channel}`} className="px-4 py-3 text-center">
<input
type="checkbox"
checked={preferences[`${eventName}:${channel}`] ?? true}
onChange={(event) =>
setPreferences((current) => ({
...current,
[`${eventName}:${channel}`]: event.target.checked,
}))
}
className="h-4 w-4 rounded border-stone-300 text-orange-600"
/>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)
}
function Field({ label, value }: { label: string; value?: string | null }) {
return (
<div>
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-stone-400">{label}</p>
<p className="mt-2 text-sm font-medium text-blue-900 break-all">{value}</p>
</div>
)
}
function EditableField({ label, value, onChange }: { label: string; value: string; onChange: (value: string) => void }) {
return (
<label className="block">
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-stone-400">{label}</p>
<input
value={value}
onChange={(event) => onChange(event.target.value)}
className="mt-2 w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 text-sm font-medium text-blue-900"
/>
</label>
)
}
function SelectField({ label, value, options, onChange }: { label: string; value: string; options: string[]; onChange: (value: string) => void }) {
return (
<label className="block">
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-stone-400">{label}</p>
<select
value={value}
onChange={(event) => onChange(event.target.value)}
className="mt-2 w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 text-sm font-medium text-blue-900"
>
{options.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
)
}
function ErrorCard({ message }: { message: string }) {
return (
<div className="card p-8 text-center">
<p className="text-sm text-rose-600">{message}</p>
</div>
)
}
@@ -0,0 +1,103 @@
'use client'
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { RenterAuthError, loadRenterProfile, type SavedCompany } from '@/lib/renter'
import { useStorefrontPreferences } from '@/components/StorefrontShell'
export default function RenterSavedCompaniesPage() {
const router = useRouter()
const { language } = useStorefrontPreferences()
const dict = {
en: {
load: 'Could not load saved companies.',
renter: 'Renter',
title: 'Saved companies',
back: 'Back to dashboard',
loading: 'Loading saved companies…',
empty: 'You have not saved any companies yet.',
browse: 'Browse the storefront',
rentalCompany: 'Rental company',
},
fr: {
load: 'Impossible de charger les entreprises sauvegardées.',
renter: 'Client',
title: 'Entreprises sauvegardées',
back: 'Retour au tableau de bord',
loading: 'Chargement des entreprises sauvegardées…',
empty: 'Vous navez encore enregistré aucune entreprise.',
browse: 'Parcourir la storefront',
rentalCompany: 'Société de location',
},
ar: {
load: 'تعذر تحميل الشركات المحفوظة.',
renter: 'المستأجر',
title: 'الشركات المحفوظة',
back: 'العودة إلى بوابة المستأجر',
loading: 'جارٍ تحميل الشركات المحفوظة…',
empty: 'لم تقم بحفظ أي شركة بعد.',
browse: 'تصفح السوق',
rentalCompany: 'شركة تأجير',
},
}[language]
const [companies, setCompanies] = useState<SavedCompany[]>([])
const [errorMsg, setErrorMsg] = useState('')
const [loading, setLoading] = useState(true)
useEffect(() => {
loadRenterProfile()
.then((profile) => setCompanies(profile.savedCompanies ?? []))
.catch((err) => {
if (err instanceof RenterAuthError) {
router.replace('/')
return
}
setErrorMsg(err instanceof Error ? err.message : dict.load)
})
.finally(() => setLoading(false))
}, [router])
return (
<div className="shell space-y-6">
<h1 className="text-3xl font-black tracking-tight text-blue-900">{dict.title}</h1>
{loading ? <div className="card p-8 text-sm text-stone-500">{dict.loading}</div> : null}
{!loading && errorMsg ? <div className="card p-8 text-sm text-rose-600">{errorMsg}</div> : null}
{!loading && !errorMsg && companies.length === 0 ? (
<div className="card p-10 text-center">
<p className="text-sm text-stone-500">{dict.empty}</p>
<Link href="/explore" className="mt-5 inline-flex rounded-full bg-orange-600 px-6 py-2.5 text-sm font-semibold text-white">
{dict.browse}
</Link>
</div>
) : null}
{!loading && !errorMsg && companies.length > 0 ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{companies.map((company) => (
<Link
key={company.id}
href={`/explore/${company.brand?.subdomain ?? company.id}`}
className="card flex items-center gap-4 p-5 hover:border-orange-300 transition-colors"
>
{company.brand?.logoUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={company.brand.logoUrl} alt={company.brand.displayName} className="h-11 w-11 rounded-xl object-contain" />
) : (
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-orange-100 text-sm font-bold text-orange-700">
{company.brand?.displayName?.charAt(0) ?? '?'}
</div>
)}
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-blue-900">{company.brand?.displayName ?? dict.rentalCompany}</p>
<p className="mt-1 truncate text-xs text-stone-400">{company.brand?.subdomain ?? company.id}</p>
</div>
</Link>
))}
</div>
) : null}
</div>
)
}
@@ -0,0 +1,39 @@
import { headers } from 'next/headers'
import { redirect } from 'next/navigation'
import { resolveServerAppUrl } from '@/lib/appUrls'
type SearchParams = Record<string, string | string[] | undefined>
function buildSearch(params: SearchParams): string {
const nextParams = new URLSearchParams()
for (const [key, value] of Object.entries(params)) {
if (typeof value === 'string') {
nextParams.set(key, value)
continue
}
if (Array.isArray(value)) {
for (const entry of value) nextParams.append(key, entry)
}
}
const search = nextParams.toString()
return search ? `?${search}` : ''
}
export default async function RenterSignInPage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}) {
const requestHeaders = await headers()
const dashboardUrl = resolveServerAppUrl(
process.env.NEXT_PUBLIC_HOMEPAGE_URL ?? 'http://localhost:3000',
requestHeaders.get('host'),
requestHeaders.get('x-forwarded-proto'),
)
const params = await searchParams
redirect(`${dashboardUrl}/sign-in${buildSearch(params)}`)
}
@@ -0,0 +1,39 @@
import { headers } from 'next/headers'
import { redirect } from 'next/navigation'
import { resolveServerAppUrl } from '@/lib/appUrls'
type SearchParams = Record<string, string | string[] | undefined>
function buildSearch(params: SearchParams): string {
const nextParams = new URLSearchParams()
for (const [key, value] of Object.entries(params)) {
if (typeof value === 'string') {
nextParams.set(key, value)
continue
}
if (Array.isArray(value)) {
for (const entry of value) nextParams.append(key, entry)
}
}
const search = nextParams.toString()
return search ? `?${search}` : ''
}
export default async function RenterSignUpPage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}) {
const requestHeaders = await headers()
const dashboardUrl = resolveServerAppUrl(
process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard',
requestHeaders.get('host'),
requestHeaders.get('x-forwarded-proto'),
)
const params = await searchParams
redirect(`${dashboardUrl}/sign-up${buildSearch(params)}`)
}
+40
View File
@@ -0,0 +1,40 @@
import { headers } from 'next/headers'
import { redirect } from 'next/navigation'
import { resolveServerAppUrl } from '@/lib/appUrls'
type SearchParams = Record<string, string | string[] | undefined>
function buildSearch(params: SearchParams): string {
const nextParams = new URLSearchParams()
for (const [key, value] of Object.entries(params)) {
if (typeof value === 'string') {
nextParams.set(key, value)
continue
}
if (Array.isArray(value)) {
for (const entry of value) nextParams.append(key, entry)
}
}
const search = nextParams.toString()
return search ? `?${search}` : ''
}
export default async function SignInPage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}) {
const requestHeaders = await headers()
const dashboardUrl = resolveServerAppUrl(
process.env.NEXT_PUBLIC_HOMEPAGE_URL ?? 'http://localhost:3000',
requestHeaders.get('host'),
requestHeaders.get('x-forwarded-proto'),
)
const params = await searchParams
redirect(`${dashboardUrl}/sign-in${buildSearch(params)}`)
}
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest'
import { calculateBookingTotalDays, normalizeOptionalText, resolveReturnLocation } from './BookingForm'
describe('BookingForm helpers', () => {
it('calculates booking nights from date-only inputs', () => {
expect(calculateBookingTotalDays('2026-06-09', '2026-06-10')).toBe(1)
expect(calculateBookingTotalDays('2026-06-09', '2026-06-16')).toBe(7)
})
it('never returns a negative total for reversed or incomplete dates', () => {
expect(calculateBookingTotalDays('2026-06-16', '2026-06-09')).toBe(0)
expect(calculateBookingTotalDays('', '2026-06-09')).toBe(0)
expect(calculateBookingTotalDays('2026-06-09', '')).toBe(0)
})
it('uses pickup location for same-location returns', () => {
expect(resolveReturnLocation({ dropoffMode: 'same', pickupLocation: 'Marrakesh Airport', returnLocation: 'Casablanca' })).toBe('Marrakesh Airport')
})
it('uses the explicit return location for different dropoffs and preserves an unset optional value', () => {
expect(resolveReturnLocation({ dropoffMode: 'different', pickupLocation: 'Marrakesh', returnLocation: 'Casablanca' })).toBe('Casablanca')
expect(resolveReturnLocation({ dropoffMode: 'different', pickupLocation: 'Marrakesh', returnLocation: '' })).toBeUndefined()
})
it('normalizes optional free-text fields without leaking empty strings into payloads', () => {
expect(normalizeOptionalText(' flight lands at 9 ')).toBe('flight lands at 9')
expect(normalizeOptionalText(' ')).toBeUndefined()
})
})
+721
View File
@@ -0,0 +1,721 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { formatCurrency } from '@rentaldrivego/types'
import { StorefrontApiError, storefrontFetch, storefrontPost } from '@/lib/api'
import { useStorefrontPreferences } from '@/components/StorefrontShell'
type Props = {
vehicleId: string
companySlug: string
dailyRate: number
pickupLocations: string[]
allowDifferentDropoff: boolean
dropoffLocations: string[]
}
type DropoffMode = 'same' | 'different'
type BookingFields = {
firstName: string
lastName: string
email: string
phone: string
pickupLocation: string
dropoffMode: DropoffMode
returnLocation: string
startDate: string
endDate: string
notes: string
dateOfBirth: string
nationality: string
identityDocumentNumber: string
fullAddress: string
driverLicense: string
licenseIssuedAt: string
licenseExpiry: string
licenseCountry: string
licenseCategory: string
internationalLicenseNumber: string
}
type AvailabilityState = {
status: 'idle' | 'checking' | 'available' | 'unavailable' | 'error'
nextAvailableAt: string | null
}
type AvailabilityResponse = {
available: boolean
availabilityStatus: string
nextAvailableAt: string | null
blockingReason: string | null
}
type BookingResponse = {
reservationId: string
bookingReference?: string | null
companyName?: string
vehicleName?: string
startDate?: string
endDate?: string
status?: string
message?: string
}
type FunnelEventName =
| 'booking_form_viewed'
| 'trip_dates_selected'
| 'contact_details_started'
| 'driver_details_expanded'
| 'booking_request_submitted'
| 'booking_request_failed'
| 'booking_request_success'
const copy = {
en: {
title: 'Request this car in 1 minute',
subtitle: 'Send the essentials now. Driver documents can be added later if the company needs them.',
tripDetails: 'Trip details',
contactDetails: 'Contact details',
optionalDetails: 'Driver details, optional now',
optionalDetailsHint: 'Skip this unless you want to speed up final approval. The company can collect it after confirming availability.',
showDriverDetails: 'Add driver details now',
hideDriverDetails: 'Hide driver details',
firstName: 'First name',
lastName: 'Last name',
email: 'Email',
phone: 'Phone',
dateOfBirth: 'Date of birth',
nationality: 'Nationality',
identityDocumentNumber: 'CIN / Passport number',
fullAddress: 'Full address',
driverLicense: 'License number',
licenseIssuedAt: 'Issue date',
licenseExpiry: 'Expiry date',
licenseCountry: 'Country of issue',
licenseCategory: 'License category',
internationalLicenseNumber: 'International permit number',
pickupLocation: 'Pick-up location',
returnLocation: 'Drop-off location',
sameDropoff: 'Same drop-off',
differentDropoff: 'Different drop-off',
sameAsPickup: 'Return to the same location',
startDate: 'Pick-up date',
endDate: 'Return date',
notes: 'Anything the company should know? Optional.',
submit: 'Send booking request',
submitting: 'Sending…',
successTitle: 'Request sent.',
successBody: 'This first step is a request, not an instant booking. Here is what happens next.',
bookingReference: 'Booking reference',
pendingStatus: 'Pending',
invalidDates: 'Return date must be after pick-up date.',
required: 'Add your trip dates, name, email, and phone.',
perDay: '/ day',
estimatedTotal: 'Estimated total',
days: 'day(s)',
genericError: 'Something went wrong. Please try again.',
locationRequired: 'Select the required pick-up and drop-off locations.',
checkingAvailability: 'Checking availability for these dates…',
likelyAvailable: 'Likely available for these dates. Final confirmation still depends on the rental company.',
unavailableWithDate: 'Unavailable for these dates. Next likely availability: {date}.',
unavailableWithoutDate: 'Unavailable for these dates. Choose different trip dates to continue.',
availabilityError: 'Availability could not be checked right now. You can still try again in a moment.',
nextStepRequestSent: 'Request sent',
nextStepRequestSentBody: 'The company receives your dates and contact details first.',
nextStepAvailability: 'Company confirms availability',
nextStepAvailabilityBody: 'They review the request and accept it if the car is still open.',
nextStepDocuments: 'Driver documents if needed',
nextStepDocumentsBody: 'License and identity details can be collected after the company accepts the request.',
nextStepPayment: 'Payment or deposit',
nextStepPaymentBody: 'Any required payment is handled only after availability is confirmed.',
nextStepConfirmed: 'Booking confirmed',
nextStepConfirmedBody: 'The booking is final once availability, documents, and payment are all cleared.',
},
fr: {
title: 'Demander cette voiture en 1 minute',
subtitle: 'Envoyez lessentiel maintenant. Les documents conducteur peuvent être ajoutés plus tard si lagence les demande.',
tripDetails: 'Détails du trajet',
contactDetails: 'Coordonnées',
optionalDetails: 'Détails conducteur, optionnels maintenant',
optionalDetailsHint: 'Ignorez cette partie sauf si vous voulez accélérer la validation finale. Lagence pourra les demander après confirmation de disponibilité.',
showDriverDetails: 'Ajouter les détails conducteur',
hideDriverDetails: 'Masquer les détails conducteur',
firstName: 'Prénom',
lastName: 'Nom',
email: 'E-mail',
phone: 'Téléphone',
dateOfBirth: 'Date de naissance',
nationality: 'Nationalité',
identityDocumentNumber: 'N° CIN / passeport',
fullAddress: 'Adresse complète',
driverLicense: 'Numéro du permis',
licenseIssuedAt: 'Date de délivrance',
licenseExpiry: 'Date dexpiration',
licenseCountry: 'Pays de délivrance',
licenseCategory: 'Catégorie du permis',
internationalLicenseNumber: 'N° de permis international',
pickupLocation: 'Lieu de départ',
returnLocation: 'Lieu de retour',
sameDropoff: 'Même retour',
differentDropoff: 'Retour différent',
sameAsPickup: 'Retour au même lieu',
startDate: 'Date de départ',
endDate: 'Date de retour',
notes: 'Une information utile pour lagence ? Optionnel.',
submit: 'Envoyer la demande',
submitting: 'Envoi…',
successTitle: 'Demande envoyée.',
successBody: 'Cette première étape reste une demande, pas une réservation instantanée. Voici la suite.',
bookingReference: 'Référence de réservation',
pendingStatus: 'En attente',
invalidDates: 'La date de retour doit être après la date de départ.',
required: 'Ajoutez vos dates, votre nom, votre e-mail et votre téléphone.',
perDay: '/ jour',
estimatedTotal: 'Total estimé',
days: 'jour(s)',
genericError: 'Une erreur est survenue. Veuillez réessayer.',
locationRequired: 'Sélectionnez les lieux de départ et de retour requis.',
checkingAvailability: 'Vérification de la disponibilité pour ces dates…',
likelyAvailable: 'Probablement disponible pour ces dates. La confirmation finale dépend encore de lagence.',
unavailableWithDate: 'Indisponible pour ces dates. Prochaine disponibilité probable : {date}.',
unavailableWithoutDate: 'Indisponible pour ces dates. Choisissez dautres dates pour continuer.',
availabilityError: 'La disponibilité ne peut pas être vérifiée pour le moment. Réessayez dans un instant.',
nextStepRequestSent: 'Demande envoyée',
nextStepRequestSentBody: 'Lagence reçoit dabord vos dates et vos coordonnées.',
nextStepAvailability: 'Confirmation de disponibilité',
nextStepAvailabilityBody: 'Lagence vérifie la demande et laccepte si la voiture est toujours libre.',
nextStepDocuments: 'Documents conducteur si nécessaires',
nextStepDocumentsBody: 'Le permis et les pièces didentité peuvent être demandés après lacceptation de la demande.',
nextStepPayment: 'Paiement ou dépôt',
nextStepPaymentBody: 'Le paiement demandé intervient seulement après la confirmation de disponibilité.',
nextStepConfirmed: 'Réservation confirmée',
nextStepConfirmedBody: 'La réservation devient finale une fois la disponibilité, les documents et le paiement validés.',
},
ar: {
title: 'اطلب هذه السيارة خلال دقيقة',
subtitle: 'أرسل المعلومات الأساسية الآن. يمكن إضافة وثائق السائق لاحقاً إذا احتاجت الشركة إليها.',
tripDetails: 'تفاصيل الرحلة',
contactDetails: 'بيانات التواصل',
optionalDetails: 'بيانات السائق، اختيارية الآن',
optionalDetailsHint: 'تجاوز هذا الجزء إلا إذا أردت تسريع الموافقة النهائية. يمكن للشركة طلبه بعد تأكيد التوفر.',
showDriverDetails: 'إضافة بيانات السائق الآن',
hideDriverDetails: 'إخفاء بيانات السائق',
firstName: 'الاسم الأول',
lastName: 'اسم العائلة',
email: 'البريد الإلكتروني',
phone: 'الهاتف',
dateOfBirth: 'تاريخ الميلاد',
nationality: 'الجنسية',
identityDocumentNumber: 'رقم البطاقة الوطنية / جواز السفر',
fullAddress: 'العنوان الكامل',
driverLicense: 'رقم الرخصة',
licenseIssuedAt: 'تاريخ الإصدار',
licenseExpiry: 'تاريخ الانتهاء',
licenseCountry: 'بلد الإصدار',
licenseCategory: 'فئة الرخصة',
internationalLicenseNumber: 'رقم الرخصة الدولية',
pickupLocation: 'موقع الاستلام',
returnLocation: 'موقع الإرجاع',
sameDropoff: 'نفس موقع الإرجاع',
differentDropoff: 'موقع إرجاع مختلف',
sameAsPickup: 'الإرجاع في نفس موقع الاستلام',
startDate: 'تاريخ الاستلام',
endDate: 'تاريخ الإرجاع',
notes: 'أي معلومة تريد إبلاغ الشركة بها؟ اختياري.',
submit: 'إرسال طلب الحجز',
submitting: 'جارٍ الإرسال…',
successTitle: 'تم إرسال الطلب.',
successBody: 'هذه الخطوة الأولى عبارة عن طلب وليست حجزاً فورياً. هذه هي الخطوات التالية.',
bookingReference: 'مرجع الحجز',
pendingStatus: 'قيد المراجعة',
invalidDates: 'يجب أن يكون تاريخ الإرجاع بعد تاريخ الاستلام.',
required: 'أضف تواريخ الرحلة والاسم والبريد والهاتف.',
perDay: '/ يوم',
estimatedTotal: 'الإجمالي التقديري',
days: 'يوم',
genericError: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
locationRequired: 'اختر مواقع الاستلام والإرجاع المطلوبة.',
checkingAvailability: 'جارٍ التحقق من التوفر لهذه التواريخ…',
likelyAvailable: 'السيارة متاحة غالباً لهذه التواريخ، لكن التأكيد النهائي يعود إلى شركة التأجير.',
unavailableWithDate: 'غير متاحة لهذه التواريخ. أقرب توفر متوقع: {date}.',
unavailableWithoutDate: 'غير متاحة لهذه التواريخ. اختر تواريخ مختلفة للمتابعة.',
availabilityError: 'تعذر التحقق من التوفر الآن. حاول مرة أخرى بعد قليل.',
nextStepRequestSent: 'تم إرسال الطلب',
nextStepRequestSentBody: 'تتلقى الشركة أولاً التواريخ وبيانات التواصل.',
nextStepAvailability: 'تأكيد التوفر',
nextStepAvailabilityBody: 'تراجع الشركة الطلب وتقبله إذا كانت السيارة ما زالت متاحة.',
nextStepDocuments: 'وثائق السائق عند الحاجة',
nextStepDocumentsBody: 'يمكن طلب الرخصة ووثائق الهوية بعد قبول الطلب.',
nextStepPayment: 'الدفع أو العربون',
nextStepPaymentBody: 'أي دفعة مطلوبة تتم فقط بعد تأكيد التوفر.',
nextStepConfirmed: 'تأكيد الحجز',
nextStepConfirmedBody: 'يصبح الحجز نهائياً بعد اعتماد التوفر والوثائق والدفع.',
},
} as const
const emptyFields: BookingFields = {
firstName: '',
lastName: '',
email: '',
phone: '',
pickupLocation: '',
dropoffMode: 'same',
returnLocation: '',
startDate: '',
endDate: '',
notes: '',
dateOfBirth: '',
nationality: '',
identityDocumentNumber: '',
fullAddress: '',
driverLicense: '',
licenseIssuedAt: '',
licenseExpiry: '',
licenseCountry: '',
licenseCategory: '',
internationalLicenseNumber: '',
}
export function calculateBookingTotalDays(startDate: string, endDate: string): number {
if (!startDate || !endDate) return 0
return Math.max(0, Math.ceil((new Date(endDate).getTime() - new Date(startDate).getTime()) / 86_400_000))
}
export function resolveReturnLocation({
dropoffMode,
returnLocation,
pickupLocation,
}: {
dropoffMode: DropoffMode
returnLocation: string
pickupLocation: string
}): string | undefined {
if (dropoffMode === 'different') return returnLocation || undefined
return pickupLocation || undefined
}
export function normalizeOptionalText(value: string): string | undefined {
const trimmed = value.trim()
return trimmed || undefined
}
function interpolate(template: string, replacements: Record<string, string>) {
return Object.entries(replacements).reduce(
(current, [key, value]) => current.replace(`{${key}}`, value),
template,
)
}
function formatLocalizedDate(value: string, language: 'en' | 'fr' | 'ar') {
return new Intl.DateTimeFormat(language, { dateStyle: 'medium' }).format(new Date(value))
}
function getBookingSessionId() {
if (typeof window === 'undefined') return 'server'
const existing = window.sessionStorage.getItem('storefront-booking-session-id')
if (existing) return existing
const created =
typeof window.crypto?.randomUUID === 'function'
? window.crypto.randomUUID()
: `booking-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
window.sessionStorage.setItem('storefront-booking-session-id', created)
return created
}
function RequiredMark() {
return <span className="text-red-500">*</span>
}
type FieldProps = {
label: string
value: string
onChange: (value: string) => void
required?: boolean
type?: string
textarea?: boolean
}
function Field({ label, value, onChange, required = false, type = 'text', textarea = false }: FieldProps) {
const className = 'w-full rounded-xl border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 outline-none focus:border-stone-400 dark:border-blue-800 dark:bg-blue-900/30 dark:text-stone-100 dark:focus:border-stone-500'
return (
<div className="space-y-1">
<label className="text-xs font-medium text-stone-600 dark:text-stone-400">
{label} {required ? <RequiredMark /> : null}
</label>
{textarea ? (
<textarea value={value} onChange={(e) => onChange(e.target.value)} rows={3} className={className} />
) : (
<input required={required} type={type} value={value} onChange={(e) => onChange(e.target.value)} className={className} />
)}
</div>
)
}
export default function BookingForm({
vehicleId,
companySlug,
dailyRate,
pickupLocations,
allowDifferentDropoff,
dropoffLocations,
}: Props) {
const { language } = useStorefrontPreferences()
const t = copy[language]
const pickupOptions = Array.isArray(pickupLocations) ? pickupLocations : []
const dropoffOptions = Array.isArray(dropoffLocations) ? dropoffLocations : []
const [fields, setFields] = useState<BookingFields>({
...emptyFields,
pickupLocation: pickupOptions[0] ?? '',
returnLocation: dropoffOptions[0] ?? '',
})
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState(false)
const [successBooking, setSuccessBooking] = useState<BookingResponse | null>(null)
const [availability, setAvailability] = useState<AvailabilityState>({ status: 'idle', nextAvailableAt: null })
const totalDays = calculateBookingTotalDays(fields.startDate, fields.endDate)
const sessionIdRef = useRef<string>('')
const startedAtRef = useRef<number>(Date.now())
const trackedRef = useRef({
viewed: false,
tripDates: false,
contactStarted: false,
})
if (!sessionIdRef.current) {
sessionIdRef.current = getBookingSessionId()
}
function buildUnavailableMessage(nextAvailableAt: string | null) {
return nextAvailableAt
? interpolate(t.unavailableWithDate, { date: formatLocalizedDate(nextAvailableAt, language) })
: t.unavailableWithoutDate
}
async function trackEvent(eventName: FunnelEventName, metadata?: Record<string, string | number | boolean | null>) {
await storefrontPost('/storefront/events', {
eventName,
companySlug,
vehicleId,
sessionId: sessionIdRef.current,
path: typeof window === 'undefined' ? undefined : window.location.pathname,
metadata,
}).catch(() => null)
}
async function checkAvailability(startDate: string, endDate: string, signal?: AbortSignal) {
const start = new Date(startDate)
const end = new Date(endDate)
const result = await storefrontFetch<AvailabilityResponse>(
`/storefront/${companySlug}/vehicles/${vehicleId}/availability?startDate=${encodeURIComponent(start.toISOString())}&endDate=${encodeURIComponent(end.toISOString())}`,
signal ? { signal } : undefined,
)
setAvailability({
status: result.available ? 'available' : 'unavailable',
nextAvailableAt: result.nextAvailableAt,
})
return result
}
function update<K extends keyof BookingFields>(key: K, value: BookingFields[K]) {
setFields((current) => ({ ...current, [key]: value }))
}
useEffect(() => {
if (trackedRef.current.viewed) return
trackedRef.current.viewed = true
void trackEvent('booking_form_viewed', {
allowDifferentDropoff,
hasPickupLocations: pickupOptions.length > 0,
hasDropoffLocations: dropoffOptions.length > 0,
})
}, [allowDifferentDropoff, dropoffOptions.length, pickupOptions.length])
useEffect(() => {
if (trackedRef.current.contactStarted) return
if (!fields.firstName.trim() && !fields.lastName.trim() && !fields.email.trim() && !fields.phone.trim()) return
trackedRef.current.contactStarted = true
void trackEvent('contact_details_started')
}, [fields.email, fields.firstName, fields.lastName, fields.phone])
useEffect(() => {
if (!fields.startDate || !fields.endDate) {
setAvailability({ status: 'idle', nextAvailableAt: null })
return
}
const start = new Date(fields.startDate)
const end = new Date(fields.endDate)
if (end <= start) {
setAvailability({ status: 'idle', nextAvailableAt: null })
return
}
if (!trackedRef.current.tripDates) {
trackedRef.current.tripDates = true
void trackEvent('trip_dates_selected', { totalDays: calculateBookingTotalDays(fields.startDate, fields.endDate) })
}
const controller = new AbortController()
setAvailability((current) => ({ ...current, status: 'checking' }))
void checkAvailability(fields.startDate, fields.endDate, controller.signal).catch((err) => {
if (controller.signal.aborted) return
if (err instanceof StorefrontApiError && err.code === 'invalid_dates') {
setAvailability({ status: 'idle', nextAvailableAt: null })
return
}
setAvailability({ status: 'error', nextAvailableAt: null })
})
return () => controller.abort()
}, [companySlug, fields.endDate, fields.startDate, vehicleId])
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)
if (!fields.firstName.trim() || !fields.lastName.trim() || !fields.email.trim() || !fields.phone.trim() || !fields.startDate || !fields.endDate) {
setError(t.required)
return
}
if ((pickupOptions.length > 0 && !fields.pickupLocation) || (fields.dropoffMode === 'different' && dropoffOptions.length > 0 && !fields.returnLocation)) {
setError(t.locationRequired)
return
}
const start = new Date(fields.startDate)
const end = new Date(fields.endDate)
if (end <= start) {
setError(t.invalidDates)
return
}
setSubmitting(true)
try {
void trackEvent('booking_request_submitted', {
totalDays,
})
const availabilityResult = await checkAvailability(fields.startDate, fields.endDate)
if (!availabilityResult.available) {
const message = buildUnavailableMessage(availabilityResult.nextAvailableAt)
setError(message)
void trackEvent('booking_request_failed', {
reason: 'unavailable',
nextAvailableAt: availabilityResult.nextAvailableAt,
})
return
}
const booking = await storefrontPost<BookingResponse>('/storefront/reservations', {
vehicleId,
companySlug,
firstName: fields.firstName.trim(),
lastName: fields.lastName.trim(),
email: fields.email.trim(),
phone: fields.phone.trim(),
startDate: start.toISOString(),
endDate: end.toISOString(),
pickupLocation: fields.pickupLocation || undefined,
returnLocation: resolveReturnLocation({
dropoffMode: fields.dropoffMode,
returnLocation: fields.returnLocation,
pickupLocation: fields.pickupLocation,
}),
notes: normalizeOptionalText(fields.notes),
language,
})
setSuccessBooking(booking)
setSuccess(true)
void trackEvent('booking_request_success', {
totalDays,
elapsedMs: Date.now() - startedAtRef.current,
})
} catch (err: unknown) {
if (err instanceof StorefrontApiError && err.code === 'unavailable') {
setAvailability({ status: 'unavailable', nextAvailableAt: err.nextAvailableAt ?? null })
setError(buildUnavailableMessage(err.nextAvailableAt ?? null))
} else {
setError(err instanceof Error ? err.message : t.genericError)
}
void trackEvent('booking_request_failed', {
reason: err instanceof StorefrontApiError ? err.code ?? 'request_failed' : 'request_failed',
})
} finally {
setSubmitting(false)
}
}
if (success) {
return (
<div className="rounded-2xl border border-green-200 bg-green-50 p-6 dark:border-green-800 dark:bg-green-950/40">
<p className="text-base font-semibold text-green-800 dark:text-green-300">{t.successTitle}</p>
<p className="mt-2 text-sm text-green-700 dark:text-green-400">{t.successBody}</p>
{successBooking?.bookingReference ? (
<div className="mt-4 rounded-2xl border border-green-200 bg-white/70 px-4 py-3 text-sm text-green-900 dark:border-green-800 dark:bg-green-950/20 dark:text-green-200">
<p className="font-semibold">{t.bookingReference}: {successBooking.bookingReference}</p>
<p className="mt-1 text-xs text-green-700 dark:text-green-400">
{successBooking.status ?? t.pendingStatus}
</p>
</div>
) : null}
<div className="mt-4 space-y-3">
{[
{ title: t.nextStepRequestSent, body: t.nextStepRequestSentBody, active: true },
{ title: t.nextStepAvailability, body: t.nextStepAvailabilityBody },
{ title: t.nextStepDocuments, body: t.nextStepDocumentsBody },
{ title: t.nextStepPayment, body: t.nextStepPaymentBody },
{ title: t.nextStepConfirmed, body: t.nextStepConfirmedBody },
].map((step, index) => (
<div
key={step.title}
className={`rounded-2xl border px-4 py-3 ${step.active ? 'border-green-300 bg-white/80 dark:border-green-700 dark:bg-green-950/20' : 'border-green-100 bg-white/50 dark:border-green-900/60 dark:bg-transparent'}`}
>
<p className="text-sm font-semibold text-green-900 dark:text-green-200">{index + 1}. {step.title}</p>
<p className="mt-1 text-xs leading-5 text-green-700 dark:text-green-400">{step.body}</p>
</div>
))}
</div>
</div>
)
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<p className="text-sm font-semibold text-stone-900 dark:text-stone-100">{t.title}</p>
<p className="mt-1 text-xs leading-5 text-stone-500 dark:text-stone-400">{t.subtitle}</p>
</div>
{error ? (
<p className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-950/40 dark:text-red-400">
{error}
</p>
) : null}
<section className="space-y-3 rounded-2xl border border-stone-200 bg-stone-50 p-4 dark:border-blue-800 dark:bg-blue-950">
<p className="text-sm font-semibold text-stone-900 dark:text-stone-100">{t.tripDetails}</p>
{pickupOptions.length > 0 ? (
<div className="space-y-1">
<label className="text-xs font-medium text-stone-600 dark:text-stone-400">{t.pickupLocation} <RequiredMark /></label>
<select
value={fields.pickupLocation}
onChange={(e) => update('pickupLocation', e.target.value)}
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 outline-none focus:border-stone-400 dark:border-blue-800 dark:bg-blue-900/30 dark:text-stone-100 dark:focus:border-stone-500"
>
{pickupOptions.map((location) => <option key={location} value={location}>{location}</option>)}
</select>
</div>
) : null}
{allowDifferentDropoff ? (
<div className="space-y-3">
<div className="inline-flex rounded-full border border-stone-200 bg-white p-1 dark:border-blue-800 dark:bg-blue-900/30">
<button
type="button"
onClick={() => update('dropoffMode', 'same')}
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${fields.dropoffMode === 'same' ? 'bg-blue-900 text-white dark:bg-orange-400' : 'text-stone-500 dark:text-stone-400'}`}
>
{t.sameDropoff}
</button>
<button
type="button"
onClick={() => update('dropoffMode', 'different')}
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${fields.dropoffMode === 'different' ? 'bg-blue-900 text-white dark:bg-orange-400' : 'text-stone-500 dark:text-stone-400'}`}
>
{t.differentDropoff}
</button>
</div>
{fields.dropoffMode === 'different' && dropoffOptions.length > 0 ? (
<div className="space-y-1">
<label className="text-xs font-medium text-stone-600 dark:text-stone-400">{t.returnLocation} <RequiredMark /></label>
<select
value={fields.returnLocation}
onChange={(e) => update('returnLocation', e.target.value)}
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 outline-none focus:border-stone-400 dark:border-blue-800 dark:bg-blue-900/30 dark:text-stone-100 dark:focus:border-stone-500"
>
{dropoffOptions.map((location) => <option key={location} value={location}>{location}</option>)}
</select>
</div>
) : (
<p className="text-sm text-stone-500 dark:text-stone-400">{fields.pickupLocation || t.sameAsPickup}</p>
)}
</div>
) : null}
<div className="grid grid-cols-2 gap-3">
<Field label={t.startDate} required type="date" value={fields.startDate} onChange={(value) => update('startDate', value)} />
<Field label={t.endDate} required type="date" value={fields.endDate} onChange={(value) => update('endDate', value)} />
</div>
{totalDays > 0 ? (
<p className="text-sm text-stone-600 dark:text-stone-400">
{t.estimatedTotal}:{' '}
<strong className="text-stone-900 dark:text-stone-100">{formatCurrency(dailyRate * totalDays, 'MAD', language)}</strong>{' '}
({totalDays} {t.days})
</p>
) : null}
{availability.status === 'checking' ? (
<p className="rounded-xl border border-stone-200 bg-white px-4 py-3 text-sm text-stone-600 dark:border-blue-800 dark:bg-blue-900/30 dark:text-stone-300">
{t.checkingAvailability}
</p>
) : null}
{availability.status === 'available' ? (
<p className="rounded-xl border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-700 dark:border-green-800 dark:bg-green-950/40 dark:text-green-300">
{t.likelyAvailable}
</p>
) : null}
{availability.status === 'unavailable' ? (
<p className="rounded-xl border border-orange-200 bg-orange-50 px-4 py-3 text-sm text-orange-800 dark:border-orange-800 dark:bg-orange-950/40 dark:text-orange-300">
{buildUnavailableMessage(availability.nextAvailableAt)}
</p>
) : null}
{availability.status === 'error' ? (
<p className="rounded-xl border border-stone-200 bg-white px-4 py-3 text-sm text-stone-600 dark:border-blue-800 dark:bg-blue-900/30 dark:text-stone-300">
{t.availabilityError}
</p>
) : null}
</section>
<section className="space-y-3 rounded-2xl border border-stone-200 bg-stone-50 p-4 dark:border-blue-800 dark:bg-blue-950">
<p className="text-sm font-semibold text-stone-900 dark:text-stone-100">{t.contactDetails}</p>
<div className="grid grid-cols-2 gap-3">
<Field label={t.firstName} required value={fields.firstName} onChange={(value) => update('firstName', value)} />
<Field label={t.lastName} required value={fields.lastName} onChange={(value) => update('lastName', value)} />
</div>
<Field label={t.email} required type="email" value={fields.email} onChange={(value) => update('email', value)} />
<Field label={t.phone} required type="tel" value={fields.phone} onChange={(value) => update('phone', value)} />
<Field label={t.notes} textarea value={fields.notes} onChange={(value) => update('notes', value)} />
</section>
<button
type="submit"
disabled={submitting || availability.status === 'checking'}
className="w-full rounded-full bg-orange-600 px-6 py-3 text-center text-sm font-semibold text-white transition hover:bg-orange-700 disabled:opacity-60 dark:bg-orange-400 dark:text-white dark:hover:bg-orange-300"
>
{submitting ? t.submitting : t.submit}
</button>
</form>
)
}
@@ -0,0 +1,64 @@
import React, { isValidElement } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const preferenceState = vi.hoisted(() => ({ language: 'en' as 'en' | 'fr' | 'ar' }))
vi.mock('@/components/StorefrontShell', () => ({
useStorefrontPreferences: () => ({ language: preferenceState.language, theme: 'light' }),
}))
import FooterContentPage from './FooterContentPage'
function collectText(node: unknown): string[] {
if (node === null || node === undefined || typeof node === 'boolean') return []
if (typeof node === 'string' || typeof node === 'number') return [String(node)]
if (Array.isArray(node)) return node.flatMap(collectText)
if (isValidElement<{ children?: React.ReactNode }>(node)) return collectText(node.props.children)
return []
}
function collectElements(node: unknown): React.ReactElement[] {
if (node === null || node === undefined || typeof node === 'boolean') return []
if (Array.isArray(node)) return node.flatMap(collectElements)
if (!isValidElement<{ children?: React.ReactNode }>(node)) return []
return [node, ...collectElements(node.props.children)]
}
describe('FooterContentPage', () => {
beforeEach(() => {
preferenceState.language = 'en'
})
it('uses the current storefront language when no forced language is provided', () => {
preferenceState.language = 'fr'
const page = FooterContentPage({ slug: 'contact-sales' })
const text = collectText(page).join(' ')
expect(text).toContain('Informations du pied de page')
expect(text).toContain('Besoin de plus de détails')
})
it('lets static app policy pages force their own locale', () => {
preferenceState.language = 'en'
const page = FooterContentPage({ slug: 'terms-of-service', forcedLanguage: 'fr' })
const text = collectText(page).join(' ')
expect(text).toContain('Informations du pied de page')
expect(text).not.toContain('Footer information')
})
it('marks Arabic content as right-aligned', () => {
const page = FooterContentPage({ slug: 'privacy-policy', forcedLanguage: 'ar' })
const elements = collectElements(page)
expect(elements.some((element) => String(element.props.className ?? '').includes('text-right'))).toBe(true)
})
it('turns plain URLs embedded in policy paragraphs into safe anchors', () => {
const page = FooterContentPage({ slug: 'privacy-policy', forcedLanguage: 'en' })
const links = collectElements(page).filter((element) => element.type === 'a')
expect(links.some((link) => String(link.props.href).startsWith('https://'))).toBe(true)
expect(links.every((link) => link.props.href === collectText(link).join(''))).toBe(true)
})
})
@@ -0,0 +1,107 @@
'use client'
import React from 'react'
import { useStorefrontPreferences } from '@/components/StorefrontShell'
import { type FooterPageSlug, getFooterPageContent } from '@/lib/footerContent'
import { type StorefrontLanguage } from '@/lib/i18n'
const pageMeta = {
en: {
kicker: 'Footer information',
cta: 'Need more details?',
support: 'Contact the RentalDriveGo team through our official channels for business, support, or partnership requests.',
},
fr: {
kicker: 'Informations du pied de page',
cta: 'Besoin de plus de détails ?',
support: "Contactez l'équipe RentalDriveGo via nos canaux officiels pour toute demande commerciale, support ou partenariat.",
},
ar: {
kicker: 'معلومات التذييل',
cta: 'هل تحتاج إلى مزيد من التفاصيل؟',
support: 'تواصل مع فريق RentalDriveGo عبر القنوات الرسمية للاستفسارات التجارية أو الدعم أو الشراكات.',
},
} as const
const urlPattern = /(https?:\/\/[^\s]+)/g
function renderParagraphText(paragraph: string) {
const parts = paragraph.split(urlPattern)
return parts.map((part, index) => {
if (urlPattern.test(part)) {
urlPattern.lastIndex = 0
const match = part.match(/^(https?:\/\/[^\s]+?)([.,!?;:]*)$/)
const href = match?.[1] ?? part
const trailing = match?.[2] ?? ''
return (
<span key={`${part}-${index}`}>
<a
href={href}
className="text-orange-700 underline decoration-orange-300 underline-offset-4 transition hover:text-blue-900 dark:text-orange-300 dark:hover:text-stone-100"
>
{href}
</a>
{trailing}
</span>
)
}
urlPattern.lastIndex = 0
return <span key={`${part}-${index}`}>{part}</span>
})
}
export default function FooterContentPage({ slug, forcedLanguage }: { slug: FooterPageSlug; forcedLanguage?: StorefrontLanguage }) {
const { language } = useStorefrontPreferences()
const activeLanguage = forcedLanguage ?? language
const content = getFooterPageContent(activeLanguage, slug)
const meta = pageMeta[activeLanguage]
const isArabic = activeLanguage === 'ar'
return (
<main className="site-page">
<div className="site-section mx-auto max-w-4xl">
<section className="site-panel overflow-hidden p-0">
<div className="site-panel-muted rounded-none border-0 border-b border-stone-100/80 px-6 py-10 sm:px-10 sm:py-14 dark:border-blue-900">
<p className="site-kicker">
{meta.kicker}
</p>
<h1 className="site-title">
{content.title}
</h1>
</div>
<div className={`space-y-6 px-6 py-10 sm:px-10 sm:py-12 ${isArabic ? 'text-right' : 'text-left'}`}>
{content.paragraphs.map((paragraph) => (
<p key={paragraph} className="text-lg leading-8 text-stone-700 dark:text-stone-300">
{renderParagraphText(paragraph)}
</p>
))}
{content.sections?.map((section) => (
<section key={section.heading} className="space-y-4">
<h2 className="text-xl font-semibold text-stone-900 dark:text-stone-100">
{section.heading}
</h2>
{section.paragraphs.map((paragraph) => (
<p key={`${section.heading}-${paragraph}`} className="text-lg leading-8 text-stone-700 dark:text-stone-300">
{renderParagraphText(paragraph)}
</p>
))}
</section>
))}
<div className="rounded-3xl border border-orange-200 bg-orange-50 px-6 py-5 dark:border-orange-800 dark:bg-orange-950/30">
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-orange-800 dark:text-orange-400">
{meta.cta}
</p>
<p className="mt-2 text-base leading-7 text-stone-700 dark:text-stone-300">{meta.support}</p>
</div>
</div>
</section>
</div>
</main>
)
}
+223
View File
@@ -0,0 +1,223 @@
'use client'
import Link from 'next/link'
import { usePathname, useRouter } from 'next/navigation'
import {
LayoutDashboard,
User,
Bookmark,
Bell,
LogOut,
ChevronRight,
ChevronLeft,
Car,
} from 'lucide-react'
import { useEffect, useState } from 'react'
import { useStorefrontPreferences } from '@/components/StorefrontShell'
import { clearRenterSession, loadRenterProfile } from '@/lib/renter'
const NAV_ITEMS = [
{ href: '/renter/dashboard', key: 'dashboard', icon: LayoutDashboard, exact: true },
{ href: '/renter/profile', key: 'profile', icon: User },
{ href: '/renter/saved-companies', key: 'savedCompanies', icon: Bookmark },
{ href: '/renter/notifications', key: 'notifications', icon: Bell },
] as const
const dicts = {
en: {
dashboard: 'Dashboard',
profile: 'Profile',
savedCompanies: 'Saved companies',
notifications: 'Notifications',
signOut: 'Sign out',
renterPortal: 'Renter Portal',
rights: 'All rights reserved.',
},
fr: {
dashboard: 'Tableau de bord',
profile: 'Profil',
savedCompanies: 'Entreprises sauvegardées',
notifications: 'Notifications',
signOut: 'Déconnexion',
renterPortal: 'Espace client',
rights: 'Tous droits réservés.',
},
ar: {
dashboard: 'لوحة التحكم',
profile: 'الملف الشخصي',
savedCompanies: 'الشركات المحفوظة',
notifications: 'الإشعارات',
signOut: 'تسجيل الخروج',
renterPortal: 'بوابة المستأجر',
rights: 'جميع الحقوق محفوظة.',
},
} as const
function computeInitials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean)
if (parts.length === 0) return 'R'
if (parts.length === 1) return parts[0].slice(0, 1).toUpperCase()
return `${parts[0].slice(0, 1)}${parts[1].slice(0, 1)}`.toUpperCase()
}
export default function RenterShell({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
const router = useRouter()
const { language } = useStorefrontPreferences()
const dict = dicts[language]
const isRtl = language === 'ar'
const [open, setOpen] = useState(false)
const [userInitials, setUserInitials] = useState('R')
const [userName, setUserName] = useState<string | null>(null)
const [userEmail, setUserEmail] = useState<string | null>(null)
useEffect(() => {
loadRenterProfile()
.then((profile) => {
const fullName = `${profile.firstName ?? ''} ${profile.lastName ?? ''}`.trim()
setUserName(fullName || profile.email.split('@')[0])
setUserEmail(profile.email)
setUserInitials(computeInitials(fullName || profile.email))
})
.catch(() => {})
}, [])
useEffect(() => {
setOpen(false)
}, [pathname])
function signOut() {
clearRenterSession()
router.push('/')
}
const isActive = (item: (typeof NAV_ITEMS)[number]) => {
if ('exact' in item && item.exact) return pathname === item.href
return pathname.startsWith(item.href)
}
const activeNav = NAV_ITEMS.find((item) => isActive(item))
const pageTitle = activeNav ? (dict[activeNav.key as keyof typeof dict] as string) : dict.dashboard
return (
<div className="site-page flex min-h-screen">
{open && (
<div
className="fixed inset-0 z-30 bg-blue-950/50 lg:hidden"
onClick={() => setOpen(false)}
/>
)}
{!open && (
<button
onClick={() => setOpen(true)}
aria-label="Open sidebar"
className={[
'fixed top-1/2 z-50 -translate-y-1/2 flex items-center justify-center w-6 h-14 bg-blue-950 text-white shadow-lg lg:hidden',
isRtl ? 'right-0 rounded-l-lg' : 'left-0 rounded-r-lg',
].join(' ')}
>
{isRtl ? <ChevronLeft className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
</button>
)}
<aside
className={[
'fixed inset-y-0 z-40 flex w-64 flex-col bg-blue-950 transition-transform duration-300',
isRtl ? 'right-0' : 'left-0',
'lg:translate-x-0',
open ? 'translate-x-0' : isRtl ? 'translate-x-full' : '-translate-x-full',
].join(' ')}
>
<div className="flex items-center gap-3 border-b border-blue-900/50 px-6 py-5">
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-orange-500 overflow-hidden">
<Car className="h-4 w-4 text-white" />
</div>
<span className="truncate text-sm font-bold tracking-wide text-white">
{dict.renterPortal}
</span>
</div>
<nav className="flex-1 space-y-0.5 overflow-y-auto px-3 py-4">
{NAV_ITEMS.map((item) => {
const Icon = item.icon
const active = isActive(item)
return (
<Link
key={item.href}
href={item.href}
className={[
'flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors',
active
? 'bg-orange-500 text-white'
: 'text-slate-400 hover:bg-blue-900/40 hover:text-white',
].join(' ')}
>
<Icon className="h-4 w-4 flex-shrink-0" />
{dict[item.key as keyof typeof dict] as string}
</Link>
)
})}
</nav>
<div className="border-t border-stone-800 px-3 py-4">
<div className="flex items-center gap-3 rounded-lg px-3 py-2">
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-orange-500 text-xs font-semibold text-white">
{userInitials}
</div>
<div className="min-w-0 flex-1">
{userName && (
<p className="truncate text-sm font-medium text-white">{userName}</p>
)}
{userEmail && (
<p className="truncate text-xs text-stone-400">{userEmail}</p>
)}
</div>
</div>
<button
onClick={signOut}
className="mt-2 flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm text-slate-400 transition-colors hover:bg-blue-900/40 hover:text-white"
>
<LogOut className="h-4 w-4" />
{dict.signOut}
</button>
</div>
<button
onClick={() => setOpen(false)}
aria-label="Close sidebar"
className={[
'absolute top-1/2 z-10 -translate-y-1/2 flex items-center justify-center w-5 h-14 bg-blue-950 text-white shadow-lg lg:hidden',
isRtl ? '-left-5 rounded-l-lg' : '-right-5 rounded-r-lg',
].join(' ')}
>
{isRtl ? <ChevronRight className="h-3.5 w-3.5" /> : <ChevronLeft className="h-3.5 w-3.5" />}
</button>
</aside>
<div
className={[
'flex min-h-screen min-w-0 flex-1 flex-col',
isRtl ? 'lg:mr-64' : 'lg:ml-64',
].join(' ')}
>
<header className="flex h-16 items-center justify-between border-b border-stone-200/80 bg-white/80 px-6 backdrop-blur transition-colors dark:border-blue-900 dark:bg-blue-950/70">
<h1 className="text-lg font-semibold text-stone-900 dark:text-stone-100">{pageTitle}</h1>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-orange-500 text-xs font-semibold text-white">
{userInitials}
</div>
</header>
<main className="flex-1 overflow-y-auto py-8">
{children}
</main>
<footer className="border-t border-stone-200/80 bg-white/80 px-6 py-4 backdrop-blur transition-colors dark:border-blue-900 dark:bg-blue-950/70">
<p className="text-center text-xs text-stone-400 dark:text-stone-500">
&copy; {new Date().getFullYear()} RentalDriveGo. {dict.rights}
</p>
</footer>
</div>
</div>
)
}
@@ -0,0 +1,123 @@
'use client'
import { ChevronDown } from 'lucide-react'
import Link from 'next/link'
import { useEffect, useRef, useState } from 'react'
type LocaleOption = {
value: 'en' | 'fr' | 'ar'
label: string
flag: string
}
type FooterItem = {
label: string
href?: string
}
export default function StorefrontFooter({
primaryItems,
secondaryItems,
localeLabel,
rightsLabel,
localeOptions,
currentLocale,
onSelectLanguage,
}: {
primaryItems: FooterItem[]
secondaryItems: FooterItem[]
localeLabel: string
rightsLabel: string
localeOptions: LocaleOption[]
currentLocale: LocaleOption
onSelectLanguage: (language: LocaleOption['value']) => void
}) {
const localeMenuRef = useRef<HTMLDivElement | null>(null)
const [localeMenuOpen, setLocaleMenuOpen] = useState(false)
useEffect(() => {
function handlePointerDown(event: MouseEvent) {
if (!localeMenuRef.current?.contains(event.target as Node)) {
setLocaleMenuOpen(false)
}
}
document.addEventListener('mousedown', handlePointerDown)
return () => document.removeEventListener('mousedown', handlePointerDown)
}, [])
return (
<footer className="border-t border-stone-200/80 bg-white/72 px-4 py-8 text-stone-600 backdrop-blur-xl transition-colors dark:border-blue-900 dark:bg-blue-950/72 dark:text-stone-300">
<div className="shell flex flex-col items-center gap-5 text-center">
<nav className="flex flex-wrap items-center justify-center gap-y-3 text-sm">
{primaryItems.map((item, index) => (
<div key={item.label} className="flex items-center">
<FooterNavItem item={item} />
{index < primaryItems.length - 1 ? (
<span className="px-2 text-stone-400 dark:text-stone-600" aria-hidden="true">|</span>
) : null}
</div>
))}
</nav>
<div className="flex flex-wrap items-center justify-center gap-y-3 text-sm">
{secondaryItems.map((item) => (
<div key={item.label} className="flex items-center">
<FooterNavItem item={item} />
<span className="px-2 text-stone-400 dark:text-stone-600" aria-hidden="true">|</span>
</div>
))}
<div ref={localeMenuRef} className="relative px-3">
<button
type="button"
onClick={() => setLocaleMenuOpen((open) => !open)}
className="inline-flex items-center gap-2 text-sky-600 transition hover:text-sky-700 dark:text-sky-400 dark:hover:text-sky-300"
aria-expanded={localeMenuOpen}
aria-haspopup="menu"
>
<span aria-hidden="true" className="text-base leading-none">{currentLocale.flag}</span>
<span>{localeLabel}</span>
<ChevronDown className={`h-4 w-4 transition-transform ${localeMenuOpen ? 'rotate-180' : ''}`} />
</button>
{localeMenuOpen ? (
<div className="absolute left-1/2 top-full z-20 mt-3 w-56 -translate-x-1/2 overflow-hidden rounded-2xl border border-stone-200 bg-white/95 text-left shadow-[0_20px_60px_rgba(0,0,0,0.12)] backdrop-blur dark:border-blue-900 dark:bg-blue-950/95 dark:shadow-[0_20px_60px_rgba(0,0,0,0.35)]">
{localeOptions.map((option) => (
<button
key={option.value}
type="button"
onClick={() => {
onSelectLanguage(option.value)
setLocaleMenuOpen(false)
}}
className="flex w-full items-center gap-3 px-4 py-3 text-sm text-stone-700 transition hover:bg-stone-100 hover:text-blue-900 dark:text-stone-200 dark:hover:bg-blue-900/40 dark:hover:text-white"
>
<span aria-hidden="true" className="text-base leading-none">{option.flag}</span>
<span>{option.label}</span>
</button>
))}
</div>
) : null}
</div>
</div>
<p className="text-sm text-stone-500 dark:text-stone-400">
&copy; {new Date().getFullYear()} RentalDriveGo. {rightsLabel}
</p>
</div>
</footer>
)
}
function FooterNavItem({ item }: { item: FooterItem }) {
const className = 'px-3 text-stone-600 transition hover:text-blue-900 dark:text-stone-300 dark:hover:text-white'
if (item.href) {
return (
<Link href={item.href} className={className}>
{item.label}
</Link>
)
}
return <span className={className}>{item.label}</span>
}
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest'
import {
buildDashboardSignInHref,
companyInitial,
localeMenuPositionClass,
ownerWorkspaceHref,
} from './StorefrontHeader'
describe('StorefrontHeader helpers', () => {
it('builds dashboard sign-in links with language and theme query parameters', () => {
expect(buildDashboardSignInHref('https://app.example.com/dashboard', 'fr', 'dark')).toBe(
'https://app.example.com/dashboard/sign-in?lang=fr&theme=dark'
)
expect(buildDashboardSignInHref('/dashboard', 'ar', 'light')).toBe('/dashboard/sign-in?lang=ar&theme=light')
})
it('keeps the locale dropdown anchored to the readable side for RTL and LTR languages', () => {
expect(localeMenuPositionClass('ar')).toBe('right-0 sm:right-0')
expect(localeMenuPositionClass('en')).toBe('left-0 sm:left-auto sm:right-0')
expect(localeMenuPositionClass('fr')).toBe('left-0 sm:left-auto sm:right-0')
})
it('routes existing companies to their workspace and new owners to sign-up', () => {
expect(ownerWorkspaceHref('https://dashboard.example.com', 'Atlas Cars')).toBe('https://dashboard.example.com')
expect(ownerWorkspaceHref('https://dashboard.example.com', null)).toBe('https://dashboard.example.com/sign-up')
})
it('normalizes company initials for the owner workspace pill', () => {
expect(companyInitial('atlas cars')).toBe('A')
expect(companyInitial(' زاكورة كار ')).toBe('ز')
})
})
@@ -0,0 +1,231 @@
'use client'
import { ChevronDown } from 'lucide-react'
import Image from 'next/image'
import Link from 'next/link'
import { useEffect, useRef, useState } from 'react'
export type Theme = 'light' | 'dark'
export type Language = 'en' | 'fr' | 'ar'
type Dictionary = {
storefront: string
explore: string
signIn: string
ownerSignIn: string
theme: string
light: string
dark: string
}
type LanguageMeta = {
value: Language
flag: string
shortLabel: string
}
export function buildDashboardSignInHref(dashboardUrl: string, language: Language, theme: Theme): string {
const signInParams = new URLSearchParams()
signInParams.set('lang', language)
signInParams.set('theme', theme)
return `${dashboardUrl}/sign-in?${signInParams.toString()}`
}
export function localeMenuPositionClass(language: Language): string {
return language === 'ar' ? 'right-0 sm:right-0' : 'left-0 sm:left-auto sm:right-0'
}
export function ownerWorkspaceHref(dashboardUrl: string, companyName: string | null): string {
return companyName ? dashboardUrl : `${dashboardUrl}/sign-up`
}
export function companyInitial(companyName: string): string {
return companyName.trim().charAt(0).toUpperCase()
}
export default function StorefrontHeader({
dict,
theme,
setTheme,
companyName,
dashboardUrl,
currentLanguage,
localeOptions,
onSelectLanguage,
}: {
dict: Dictionary
theme: Theme
setTheme: (theme: Theme) => void
companyName: string | null
dashboardUrl: string
currentLanguage: LanguageMeta
localeOptions: LanguageMeta[]
onSelectLanguage: (language: Language) => void
}) {
const localeMenuRef = useRef<HTMLDivElement | null>(null)
const [localeMenuOpen, setLocaleMenuOpen] = useState(false)
const themeMenuRef = useRef<HTMLDivElement | null>(null)
const [themeMenuOpen, setThemeMenuOpen] = useState(false)
const themeOptions = [
{ value: 'light' as const, label: dict.light },
{ value: 'dark' as const, label: dict.dark },
]
const currentTheme = themeOptions.find((option) => option.value === theme) ?? themeOptions[0]
const menuPositionClass = localeMenuPositionClass(currentLanguage.value)
useEffect(() => {
function handlePointerDown(event: MouseEvent) {
if (!localeMenuRef.current?.contains(event.target as Node)) {
setLocaleMenuOpen(false)
}
if (!themeMenuRef.current?.contains(event.target as Node)) {
setThemeMenuOpen(false)
}
}
document.addEventListener('mousedown', handlePointerDown)
return () => document.removeEventListener('mousedown', handlePointerDown)
}, [])
function toggleLocaleMenu() {
setThemeMenuOpen(false)
setLocaleMenuOpen((open) => !open)
}
function toggleThemeMenu() {
setLocaleMenuOpen(false)
setThemeMenuOpen((open) => !open)
}
const signInHref = buildDashboardSignInHref(dashboardUrl, currentLanguage.value, theme)
return (
<header className="sticky top-0 z-50 border-b border-stone-200/80 bg-white/78 backdrop-blur-xl shadow-[0_10px_30px_rgba(15,23,42,0.08)] transition-colors dark:border-blue-900 dark:bg-blue-950/76 dark:shadow-[0_10px_30px_rgba(0,0,0,0.32)]">
<div className="shell flex min-h-14 flex-col gap-1.5 py-1.5 lg:flex-row lg:items-center lg:justify-between lg:gap-3 lg:py-2">
<div className="flex items-center justify-between gap-3">
<Link href="/" className="flex items-center gap-1.5 text-[11px] font-bold uppercase tracking-[0.14em] text-stone-900 dark:text-stone-100 sm:text-sm sm:tracking-[0.2em]">
<Image
src="/rentaldrivego.png"
alt="RentalDriveGo"
width={36}
height={36}
priority
unoptimized
className="h-8 w-8 rounded-md object-contain sm:h-9 sm:w-9"
/>
<span>RentalDriveGo</span>
</Link>
</div>
<div className="flex flex-col gap-1.5 lg:flex-row lg:items-center lg:gap-3">
<nav className="flex items-center gap-0.5 overflow-x-auto">
<Link
href="/"
className="rounded-full px-2.5 py-1 text-[12px] font-medium text-stone-600 transition hover:bg-stone-100 hover:text-stone-900 dark:text-stone-300 dark:hover:bg-blue-900/40 dark:hover:text-stone-100 sm:px-4 sm:py-2 sm:text-sm"
>
{dict.storefront}
</Link>
<Link
href="/explore"
className="rounded-full px-2.5 py-1 text-[12px] font-medium text-stone-600 transition hover:bg-stone-100 hover:text-stone-900 dark:text-stone-300 dark:hover:bg-blue-900/40 dark:hover:text-stone-100 sm:px-4 sm:py-2 sm:text-sm"
>
{dict.explore}
</Link>
<a
href={signInHref}
className="rounded-full px-2.5 py-1 text-[12px] font-medium text-stone-600 transition hover:bg-stone-100 hover:text-stone-900 dark:text-stone-300 dark:hover:bg-blue-900/40 dark:hover:text-stone-100 sm:px-4 sm:py-2 sm:text-sm"
>
{dict.signIn}
</a>
{companyName ? (
<a
href={dashboardUrl}
className="ml-1 flex items-center gap-1.5 rounded-full bg-orange-600 px-3 py-1 text-[12px] font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:hover:bg-orange-400 sm:ml-2 sm:gap-2 sm:px-4 sm:py-2 sm:text-sm"
>
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-white/20 text-[10px] font-black dark:bg-blue-950/20">
{companyInitial(companyName)}
</span>
{companyName}
</a>
) : (
<a
href={ownerWorkspaceHref(dashboardUrl, companyName)}
className="ml-1 rounded-full bg-orange-600 px-3 py-1 text-[12px] font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:hover:bg-orange-400 sm:ml-2 sm:px-5 sm:py-2 sm:text-sm"
>
{dict.ownerSignIn}
</a>
)}
</nav>
<div className="flex items-center self-start rounded-full border border-stone-200/80 bg-white/95 p-0.5 shadow-sm dark:border-blue-800 dark:bg-blue-950/85 sm:self-auto sm:p-1">
<div ref={localeMenuRef} className="relative">
<button
type="button"
onClick={toggleLocaleMenu}
className="inline-flex min-w-[4.75rem] items-center justify-center gap-1.5 rounded-full px-2.5 py-1.5 text-[11px] font-semibold text-stone-700 transition hover:bg-stone-100 dark:text-stone-200 dark:hover:bg-blue-900/40 sm:min-w-[5.25rem] sm:px-3 sm:py-2 sm:text-xs"
aria-expanded={localeMenuOpen}
aria-haspopup="menu"
>
<span aria-hidden="true">{currentLanguage.flag}</span>
<span>{currentLanguage.shortLabel}</span>
<ChevronDown className={`h-3.5 w-3.5 transition-transform ${localeMenuOpen ? 'rotate-180' : ''}`} />
</button>
{localeMenuOpen ? (
<div className={`absolute top-full z-30 mt-2 w-44 max-w-[calc(100vw-2rem)] overflow-hidden rounded-2xl border border-stone-200 bg-white/95 text-left shadow-[0_20px_60px_rgba(0,0,0,0.12)] backdrop-blur dark:border-blue-900 dark:bg-blue-950/95 dark:shadow-[0_20px_60px_rgba(0,0,0,0.35)] ${menuPositionClass}`}>
{localeOptions.map((option) => (
<button
key={option.value}
type="button"
onClick={() => {
onSelectLanguage(option.value)
setLocaleMenuOpen(false)
}}
className="flex w-full items-center gap-3 px-4 py-3 text-sm text-stone-700 transition hover:bg-stone-100 hover:text-blue-900 dark:text-stone-200 dark:hover:bg-blue-900/40 dark:hover:text-white"
>
<span aria-hidden="true">{option.flag}</span>
<span>{option.shortLabel}</span>
</button>
))}
</div>
) : null}
</div>
<span className="mx-1 h-6 w-px bg-stone-200 dark:bg-stone-700" aria-hidden="true" />
<div ref={themeMenuRef} className="relative">
<button
type="button"
onClick={toggleThemeMenu}
className="inline-flex items-center gap-1.5 rounded-full px-2 py-1 transition hover:bg-stone-100 dark:hover:bg-blue-900/40 sm:px-2.5 sm:py-1.5"
aria-expanded={themeMenuOpen}
aria-haspopup="menu"
>
<span className="rounded-full bg-blue-900 px-3 py-1 text-[11px] font-semibold text-white dark:bg-orange-400 dark:text-white sm:px-3.5 sm:py-1.5 sm:text-xs">
{currentTheme.label}
</span>
<ChevronDown className={`h-3.5 w-3.5 text-stone-500 transition-transform dark:text-stone-400 ${themeMenuOpen ? 'rotate-180' : ''}`} />
</button>
{themeMenuOpen ? (
<div className={`absolute top-full z-30 mt-2 w-44 max-w-[calc(100vw-2rem)] overflow-hidden rounded-2xl border border-stone-200 bg-white/95 text-left shadow-[0_20px_60px_rgba(0,0,0,0.12)] backdrop-blur dark:border-blue-900 dark:bg-blue-950/95 dark:shadow-[0_20px_60px_rgba(0,0,0,0.35)] ${menuPositionClass}`}>
{themeOptions
.filter((option) => option.value !== theme)
.map((option) => (
<button
key={option.value}
type="button"
onClick={() => {
setTheme(option.value)
setThemeMenuOpen(false)
}}
className="flex w-full items-center justify-between px-4 py-3 text-sm text-stone-700 transition hover:bg-stone-100 hover:text-blue-900 dark:text-stone-200 dark:hover:bg-blue-900/40 dark:hover:text-white"
>
<span>{option.label}</span>
</button>
))}
</div>
) : null}
</div>
</div>
</div>
</div>
</header>
)
}
@@ -0,0 +1,41 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { clearCachedEmployeeProfile, hasCachedEmployeeProfile } from './StorefrontShell'
afterEach(() => {
vi.unstubAllGlobals()
})
describe('StorefrontShell auth helpers', () => {
it('does not report an employee profile when the browser cache is empty', () => {
vi.stubGlobal('window', {
localStorage: {
getItem: vi.fn(() => null),
},
})
expect(hasCachedEmployeeProfile()).toBe(false)
})
it('detects the cached employee profile marker written by dashboard sign-in', () => {
vi.stubGlobal('window', {
localStorage: {
getItem: vi.fn((key: string) => (key === 'employee_profile' ? '{"id":"emp_1"}' : null)),
},
})
expect(hasCachedEmployeeProfile()).toBe(true)
})
it('clears the cached employee profile marker', () => {
const removeItem = vi.fn()
vi.stubGlobal('window', {
localStorage: {
removeItem,
},
})
clearCachedEmployeeProfile()
expect(removeItem).toHaveBeenCalledWith('employee_profile')
})
})
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import { getFooterContent, localeOptions } from './StorefrontShell'
describe('StorefrontShell footer content registry', () => {
it('exposes exactly the supported storefront locales in selector order', () => {
expect(localeOptions.map((option) => option.value)).toEqual(['en', 'ar', 'fr'])
expect(localeOptions.every((option) => option.label.length > 0 && option.flag.length > 0)).toBe(true)
})
it('returns English footer links with app privacy pointing at the English mobile policy', () => {
const content = getFooterContent('en')
expect(content.localeLabel).toBe('Global (English)')
expect(content.rightsLabel).toBe('All rights reserved.')
expect(content.primary).toContainEqual({ label: 'Privacy Policy', href: '/app-privacy-en' })
expect(content.secondary).toContainEqual({ label: 'Contact Sales', href: '/footer/contact-sales' })
})
it('returns French labels while preserving canonical footer slugs', () => {
const content = getFooterContent('fr')
expect(content.localeLabel).toBe('Europe (French)')
expect(content.primary).toContainEqual({ label: "Conditions d'utilisation", href: '/footer/terms-of-service' })
expect(content.secondary).toContainEqual({ label: 'Conditions générales', href: '/footer/general-conditions' })
})
it('returns Arabic labels and app privacy href without falling back to English copy', () => {
const content = getFooterContent('ar')
expect(content.localeLabel).toBe('North Africa (Arabic)')
expect(content.rightsLabel).toBe('جميع الحقوق محفوظة.')
expect(content.primary).toContainEqual({ label: 'سياسة الخصوصية', href: '/app-privacy-ar' })
expect(content.primary.map((item) => item.label)).not.toContain('Privacy Policy')
})
})
@@ -0,0 +1,364 @@
'use client'
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'
import { usePathname, useRouter } from 'next/navigation'
import { appPrivacyHref, footerPageHref } from '@/lib/footerContent'
import { MARKETPLACE_LANGUAGE_COOKIE, SHARED_LANGUAGE_COOKIE, isStorefrontLanguage, type StorefrontLanguage } from '@/lib/i18n'
import { SHARED_LANGUAGE_KEY, SHARED_THEME_KEY, readCurrentUserScopedPreference, readScopedPreference, writeScopedPreference } from '@/lib/preferences'
type Theme = 'light' | 'dark'
type Dictionary = {
storefront: string
explore: string
signIn: string
ownerSignIn: string
language: string
theme: string
light: string
dark: string
preferences: string
}
const dictionaries: Record<StorefrontLanguage, Dictionary> = {
en: {
storefront: 'Home',
explore: 'Storefront',
signIn: 'Sign in',
ownerSignIn: 'Create Agency Space',
language: 'Language',
theme: 'Theme',
light: 'Light',
dark: 'Dark',
preferences: 'Storefront preferences',
},
fr: {
storefront: 'Accueil',
explore: 'Storefront',
signIn: 'Connexion',
ownerSignIn: "Créer un espace d'agence",
language: 'Langue',
theme: 'Mode',
light: 'Clair',
dark: 'Sombre',
preferences: 'Préférences storefront',
},
ar: {
storefront: 'الرئيسية',
explore: 'السوق',
signIn: 'تسجيل الدخول',
ownerSignIn: 'إنشاء مساحة الوكالة',
language: 'اللغة',
theme: 'الوضع',
light: 'فاتح',
dark: 'داكن',
preferences: 'تفضيلات السوق',
},
}
const EMPLOYEE_PROFILE_KEY = 'employee_profile'
export const localeOptions: Array<{ value: StorefrontLanguage; label: string; flag: string }> = [
{ value: 'en', label: 'Global (English)', flag: '🇺🇸' },
{ value: 'ar', label: 'North Africa (Arabic)', flag: '🇲🇦' },
{ value: 'fr', label: 'Europe (French)', flag: '🇫🇷' },
]
export function getFooterContent(language: StorefrontLanguage): {
primary: Array<{ label: string; href?: string }>
secondary: Array<{ label: string; href?: string }>
localeLabel: string
rightsLabel: string
} {
switch (language) {
case 'fr':
return {
primary: [
{ label: 'À propos de nous', href: footerPageHref['about-us'] },
{ label: "Conditions d'utilisation", href: footerPageHref['terms-of-service'] },
{ label: 'Sécurité', href: footerPageHref.security },
{ label: 'Conformité', href: footerPageHref.compliance },
{ label: 'Politique de confidentialité', href: appPrivacyHref.fr },
{ label: 'Politique relative aux cookies', href: footerPageHref['cookie-policy'] },
],
secondary: [
{ label: 'Newsletter', href: footerPageHref.newsletter },
{ label: 'Contacter les ventes', href: footerPageHref['contact-sales'] },
{ label: 'Conditions générales', href: footerPageHref['general-conditions'] },
],
localeLabel: 'Europe (French)',
rightsLabel: 'Tous droits réservés.',
}
case 'ar':
return {
primary: [
{ label: 'من نحن', href: footerPageHref['about-us'] },
{ label: 'شروط الاستخدام', href: footerPageHref['terms-of-service'] },
{ label: 'الأمان', href: footerPageHref.security },
{ label: 'الامتثال', href: footerPageHref.compliance },
{ label: 'سياسة الخصوصية', href: appPrivacyHref.ar },
{ label: 'سياسة ملفات تعريف الارتباط', href: footerPageHref['cookie-policy'] },
],
secondary: [
{ label: 'النشرة الإخبارية', href: footerPageHref.newsletter },
{ label: 'تواصل مع المبيعات', href: footerPageHref['contact-sales'] },
{ label: 'الشروط العامة', href: footerPageHref['general-conditions'] },
],
localeLabel: 'North Africa (Arabic)',
rightsLabel: 'جميع الحقوق محفوظة.',
}
case 'en':
default:
return {
primary: [
{ label: 'About Us', href: footerPageHref['about-us'] },
{ label: 'Terms of Service', href: footerPageHref['terms-of-service'] },
{ label: 'Security', href: footerPageHref.security },
{ label: 'Compliance', href: footerPageHref.compliance },
{ label: 'Privacy Policy', href: appPrivacyHref.en },
{ label: 'Cookie Policy', href: footerPageHref['cookie-policy'] },
],
secondary: [
{ label: 'Newsletter', href: footerPageHref.newsletter },
{ label: 'Contact Sales', href: footerPageHref['contact-sales'] },
{ label: 'General Conditions', href: footerPageHref['general-conditions'] },
],
localeLabel: 'Global (English)',
rightsLabel: 'All rights reserved.',
}
}
}
export function hasCachedEmployeeProfile(): boolean {
if (typeof window === 'undefined') return false
try {
return Boolean(window.localStorage.getItem(EMPLOYEE_PROFILE_KEY))
} catch {
return false
}
}
export function clearCachedEmployeeProfile() {
if (typeof window === 'undefined') return
try {
window.localStorage.removeItem(EMPLOYEE_PROFILE_KEY)
} catch {}
}
function detectBrowserLanguage(): string | null {
if (typeof navigator === 'undefined') return null
const langs = Array.from(navigator.languages ?? [navigator.language])
for (const lang of langs) {
const code = lang.split('-')[0].toLowerCase()
if (code === 'fr' || code === 'ar' || code === 'en') return code
}
return null
}
type PreferencesContextValue = {
language: StorefrontLanguage
theme: Theme
dict: Dictionary
companyName: string | null
setLanguage: (language: StorefrontLanguage) => void
setTheme: (theme: Theme) => void
}
const PreferencesContext = createContext<PreferencesContextValue | null>(null)
export function useStorefrontPreferences() {
const context = useContext(PreferencesContext)
if (!context) throw new Error('useStorefrontPreferences must be used within StorefrontShell')
return context
}
export default function StorefrontShell({
children,
initialLanguage = 'en',
initialTheme = 'light',
}: {
children: React.ReactNode
initialLanguage?: StorefrontLanguage
initialTheme?: Theme
}) {
const router = useRouter()
const pathname = usePathname()
const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
const [language, setLanguageState] = useState<StorefrontLanguage>(initialLanguage)
const [theme, setThemeState] = useState<Theme>(initialTheme)
const [hydrated, setHydrated] = useState(false)
const previousLanguage = useRef<StorefrontLanguage>(initialLanguage)
const [companyName, setCompanyName] = useState<string | null>(null)
function applyLanguage(nextLanguage: StorefrontLanguage) {
if (nextLanguage === language) return
if (typeof window !== 'undefined') {
document.documentElement.lang = nextLanguage
document.documentElement.dir = nextLanguage === 'ar' ? 'rtl' : 'ltr'
try {
sessionStorage.setItem('storefront-language', nextLanguage)
} catch {}
writeScopedPreference(SHARED_LANGUAGE_KEY, nextLanguage, ['storefront-language'])
}
setLanguageState(nextLanguage)
}
function applyTheme(nextTheme: Theme) {
if (nextTheme === theme) return
if (typeof window !== 'undefined') {
document.documentElement.classList.toggle('dark', nextTheme === 'dark')
document.documentElement.style.colorScheme = nextTheme === 'light' ? 'light' : 'dark'
document.body.dataset.theme = nextTheme
writeScopedPreference(SHARED_THEME_KEY, nextTheme, ['storefront-theme'])
}
setThemeState(nextTheme)
}
async function syncCompanyBrand() {
if (!hasCachedEmployeeProfile()) {
setCompanyName(null)
return
}
try {
const response = await fetch(`${apiUrl}/companies/me/brand`, {
credentials: 'include',
})
if (!response.ok) {
if (response.status === 401) {
clearCachedEmployeeProfile()
}
setCompanyName(null)
return
}
const json = await response.json()
const name = json?.data?.displayName ?? json?.displayName ?? null
setCompanyName(name)
} catch {
setCompanyName(null)
}
}
function readSessionLanguage(): StorefrontLanguage | null {
try {
const val = sessionStorage.getItem('storefront-language')
return isStorefrontLanguage(val) ? val : null
} catch {
return null
}
}
function syncScopedPreferencesForSession() {
const sessionLang = readSessionLanguage()
if (sessionLang) {
if (sessionLang !== language) setLanguageState(sessionLang)
} else {
const scopedLanguage = readCurrentUserScopedPreference(SHARED_LANGUAGE_KEY)
if (isStorefrontLanguage(scopedLanguage) && scopedLanguage !== language) {
setLanguageState(scopedLanguage)
}
}
const scopedTheme = readCurrentUserScopedPreference(SHARED_THEME_KEY)
if ((scopedTheme === 'light' || scopedTheme === 'dark') && scopedTheme !== theme) {
setThemeState(scopedTheme)
} else {
writeScopedPreference(SHARED_THEME_KEY, theme, ['storefront-theme'])
}
}
useEffect(() => {
const sessionLang = readSessionLanguage()
if (sessionLang) {
setLanguageState(sessionLang)
writeScopedPreference(SHARED_LANGUAGE_KEY, sessionLang, ['storefront-language'])
} else {
const storedLanguage = readScopedPreference(SHARED_LANGUAGE_KEY, ['storefront-language'])
if (isStorefrontLanguage(storedLanguage)) {
setLanguageState(storedLanguage)
writeScopedPreference(SHARED_LANGUAGE_KEY, storedLanguage, ['storefront-language'])
} else {
const detected = detectBrowserLanguage()
if (isStorefrontLanguage(detected)) {
setLanguageState(detected)
writeScopedPreference(SHARED_LANGUAGE_KEY, detected, ['storefront-language'])
}
}
}
const storedTheme = readScopedPreference(SHARED_THEME_KEY, ['storefront-theme']) as Theme | null
if (storedTheme === 'light' || storedTheme === 'dark') {
setThemeState(storedTheme)
}
setHydrated(true)
void syncCompanyBrand()
}, [])
useEffect(() => {
function handleWindowFocus() {
syncScopedPreferencesForSession()
void syncCompanyBrand()
}
function handleAuthMessage(event: MessageEvent) {
if (!event.data || typeof event.data !== 'object') return
const message = event.data as { type?: string }
if (message.type === 'rentaldrivego:employee-login' || message.type === 'rentaldrivego:employee-logout') {
syncScopedPreferencesForSession()
void syncCompanyBrand()
}
}
window.addEventListener('focus', handleWindowFocus)
window.addEventListener('message', handleAuthMessage)
document.addEventListener('visibilitychange', handleWindowFocus)
return () => {
window.removeEventListener('focus', handleWindowFocus)
window.removeEventListener('message', handleAuthMessage)
document.removeEventListener('visibilitychange', handleWindowFocus)
}
}, [language, theme])
useEffect(() => {
document.documentElement.lang = language
document.documentElement.dir = language === 'ar' ? 'rtl' : 'ltr'
if (!hydrated) return
try { sessionStorage.setItem('storefront-language', language) } catch {}
writeScopedPreference(SHARED_LANGUAGE_KEY, language, ['storefront-language'])
if (previousLanguage.current !== language && pathname !== '/sign-in') {
router.refresh()
}
previousLanguage.current = language
}, [hydrated, language, pathname, router])
useEffect(() => {
document.documentElement.classList.toggle('dark', theme === 'dark')
document.documentElement.style.colorScheme = theme === 'dark' ? 'dark' : 'light'
document.body.dataset.theme = theme
if (hydrated) {
writeScopedPreference(SHARED_THEME_KEY, theme, ['storefront-theme'])
}
}, [theme, hydrated])
const value = useMemo(
() => ({ language, theme, dict: dictionaries[language], companyName, setLanguage: applyLanguage, setTheme: applyTheme }),
[language, theme, companyName],
)
return <PreferencesContext.Provider value={value}>{children}</PreferencesContext.Provider>
}
@@ -0,0 +1,48 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { buildFrameUrl, FRAME_HEIGHT, getDefaultFramePath } from './WorkspaceFrame'
afterEach(() => {
vi.unstubAllGlobals()
})
function stubBrowser({ cookie = '', origin = 'https://market.example' }: { cookie?: string; origin?: string } = {}) {
const parsed = new URL(origin)
vi.stubGlobal('window', {
location: { origin, hostname: parsed.hostname, protocol: parsed.protocol, replace: vi.fn() },
})
vi.stubGlobal('document', { cookie })
}
describe('WorkspaceFrame helpers', () => {
it('uses the dashboard sign-in path during server rendering', () => {
vi.stubGlobal('window', undefined)
expect(getDefaultFramePath('sign-in')).toBe('/dashboard/sign-in')
expect(buildFrameUrl('https://dashboard.example/dashboard', '/dashboard/sign-in')).toBe('/dashboard/sign-in')
})
it('keeps users with an employee token on the embedded sign-in path until the dashboard confirms login', () => {
stubBrowser({ cookie: 'other=1; employee_session=token_123' })
expect(getDefaultFramePath('sign-in')).toBe('/dashboard/sign-in')
})
it('keeps unauthenticated visitors on the embedded sign-in path', () => {
stubBrowser({ cookie: 'storefront-language=fr' })
expect(getDefaultFramePath('sign-in')).toBe('/dashboard/sign-in')
})
it('rewrites a configured app base to the requested embedded path and query', () => {
stubBrowser({ origin: 'https://market.example' })
expect(buildFrameUrl('https://dashboard.example/dashboard', '/dashboard/reset-password?token=abc')).toBe(
'https://market.example/dashboard/reset-password?token=abc',
)
})
it('documents the iframe height contract used by embedded dashboard surfaces', () => {
expect(FRAME_HEIGHT).toBe('calc(100vh - 56px)')
})
})
@@ -0,0 +1,111 @@
'use client'
import { useEffect, useState } from 'react'
import { resolveBrowserAppUrl } from '@/lib/appUrls'
import { useStorefrontPreferences } from './StorefrontShell'
export type FrameId = 'sign-in'
const frameConfig: Record<FrameId, { label: string; appUrl: string; path: string }> = {
'sign-in': {
label: 'Sign in',
appUrl: process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard',
path: '/dashboard/sign-in',
},
}
export function getDefaultFramePath(target: FrameId) {
if (typeof window === 'undefined') {
return frameConfig[target].path
}
return frameConfig[target].path
}
export function buildFrameUrl(appUrl: string, path: string) {
if (typeof window === 'undefined') return path
const resolvedAppUrl = resolveBrowserAppUrl(appUrl)
const appBase = new URL(resolvedAppUrl, window.location.origin)
const target = new URL(path, window.location.origin)
appBase.pathname = target.pathname
appBase.search = target.search
appBase.hash = ''
return appBase.toString()
}
export const FRAME_HEIGHT = 'calc(100vh - 56px)'
export default function WorkspaceFrame({ target }: { target: FrameId }) {
const { language, theme } = useStorefrontPreferences()
const [src, setSrc] = useState('')
const [framePath, setFramePath] = useState(() => getDefaultFramePath(target))
const config = frameConfig[target]
const loadingLabel = {
en: 'Loading',
fr: 'Chargement',
ar: 'جارٍ تحميل',
}[language]
useEffect(() => {
setFramePath(getDefaultFramePath(target))
}, [target])
useEffect(() => {
function handleFrameMessage(event: MessageEvent) {
if (!event.data || typeof event.data !== 'object') return
const message = event.data as { type?: string; path?: string }
if (target === 'sign-in' && message.type === 'rentaldrivego:employee-login' && typeof message.path === 'string') {
window.location.replace(message.path)
return
}
if ((message.type === 'rentaldrivego:embedded-path' || message.type === 'rentaldrivego:employee-login') && typeof message.path === 'string') {
setFramePath(message.path)
}
}
window.addEventListener('message', handleFrameMessage)
return () => window.removeEventListener('message', handleFrameMessage)
}, [target])
useEffect(() => {
if (target === 'sign-in' && src) return
const nextUrl = new URL(buildFrameUrl(config.appUrl, framePath))
nextUrl.searchParams.set('theme', theme)
nextUrl.searchParams.set('lang', language)
nextUrl.searchParams.set('embedded', '1')
setSrc(nextUrl.toString())
}, [config.appUrl, framePath, language, src, target, theme])
const content = src ? (
<iframe
title={config.label}
src={src}
className="block w-full border-0 bg-white dark:bg-blue-950"
style={{ height: FRAME_HEIGHT }}
/>
) : (
<div
className="flex items-center justify-center text-sm text-stone-500 dark:text-stone-300"
style={{ height: FRAME_HEIGHT }}
>
{loadingLabel} {config.label}
</div>
)
if (target === 'sign-in') {
return <main className="min-h-[calc(100vh-56px)]">{content}</main>
}
return (
<main className="site-page">
<div className="site-section py-6 sm:py-8 lg:py-10">
<section className="site-panel overflow-hidden p-0">{content}</section>
</div>
</main>
)
}
@@ -0,0 +1,4 @@
'use client'
// Public marketing footer used by the homepage and other storefront pages.
export { default } from '@/components/StorefrontFooter'
@@ -0,0 +1,4 @@
'use client'
// Public marketing navbar used by the homepage and other storefront pages.
export { default } from '@/components/StorefrontHeader'
@@ -0,0 +1,15 @@
import fs from 'node:fs'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
describe('storefront public page layout', () => {
it('keeps the navbar and footer in reusable component modules', () => {
const layoutPath = fileURLToPath(new URL('./SitePageLayout.tsx', import.meta.url))
const source = fs.readFileSync(layoutPath, 'utf8')
expect(source).toContain("import SiteNavbar from './SiteNavbar'")
expect(source).toContain("import SiteFooter from './SiteFooter'")
expect(source).toContain('<SiteNavbar')
expect(source).toContain('<SiteFooter')
})
})
@@ -0,0 +1,57 @@
'use client'
import type { ReactNode } from 'react'
import SiteFooter from './SiteFooter'
import SiteNavbar from './SiteNavbar'
import {
getFooterContent,
localeOptions,
useStorefrontPreferences,
} from '@/components/StorefrontShell'
import { resolveBrowserAppUrl } from '@/lib/appUrls'
export default function SitePageLayout({ children }: { children: ReactNode }) {
const { language, theme, dict, companyName, setLanguage, setTheme } = useStorefrontPreferences()
const dashboardUrl = resolveBrowserAppUrl(
process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard',
)
const footerContent = getFooterContent(language)
const available = localeOptions.filter((option) => option.value !== language)
const current = localeOptions.find((option) => option.value === language) ?? localeOptions[0]
return (
<div className="site-page flex min-h-screen flex-col" data-site-page-layout="true">
<SiteNavbar
dict={dict}
theme={theme}
setTheme={setTheme}
companyName={companyName}
dashboardUrl={dashboardUrl}
currentLanguage={{
value: current.value,
flag: current.flag,
shortLabel: current.value.toUpperCase(),
}}
localeOptions={available.map((option) => ({
value: option.value,
flag: option.flag,
shortLabel: option.value.toUpperCase(),
}))}
onSelectLanguage={setLanguage}
/>
<div className="flex flex-1 flex-col">
<div className="flex-1">{children}</div>
<SiteFooter
primaryItems={footerContent.primary}
secondaryItems={footerContent.secondary}
localeLabel={footerContent.localeLabel}
rightsLabel={footerContent.rightsLabel}
localeOptions={available}
currentLocale={current}
onSelectLanguage={setLanguage}
/>
</div>
</div>
)
}
@@ -0,0 +1,5 @@
'use client'
export { default as SiteFooter } from './SiteFooter'
export { default as SiteNavbar } from './SiteNavbar'
export { default as SitePageLayout } from './SitePageLayout'
+77
View File
@@ -0,0 +1,77 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { StorefrontApiError, storefrontFetch, storefrontFetchOrDefault, storefrontPost } from './api'
afterEach(() => {
delete process.env.NEXT_PUBLIC_API_URL
delete process.env.API_INTERNAL_URL
vi.restoreAllMocks()
Reflect.deleteProperty(globalThis, 'fetch')
})
describe('storefront API helpers', () => {
it('unwraps successful GET responses from the data envelope', async () => {
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ data: [{ id: 'vehicle_1' }] }) }))
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
await expect(storefrontFetch('/storefront/vehicles')).resolves.toEqual([{ id: 'vehicle_1' }])
expect(fetchMock).toHaveBeenCalledWith(expect.stringMatching(/\/api\/v1\/storefront\/vehicles$/), {
cache: 'no-store',
credentials: 'include',
})
})
it('throws rich storefront API errors', async () => {
Object.defineProperty(globalThis, 'fetch', {
configurable: true,
value: vi.fn(async () => ({
ok: false,
status: 409,
json: async () => ({ message: 'Vehicle unavailable', error: 'VEHICLE_UNAVAILABLE', nextAvailableAt: '2026-07-01T10:00:00.000Z' }),
})),
})
await expect(storefrontFetch('/storefront/book')).rejects.toMatchObject({
name: 'StorefrontApiError',
message: 'Vehicle unavailable',
status: 409,
code: 'VEHICLE_UNAVAILABLE',
nextAvailableAt: '2026-07-01T10:00:00.000Z',
})
})
it('returns fallbacks when GET requests fail', async () => {
Object.defineProperty(globalThis, 'fetch', {
configurable: true,
value: vi.fn(async () => ({ ok: false, status: 500, json: async () => ({ message: 'Down' }) })),
})
await expect(storefrontFetchOrDefault('/storefront/homepage', { hero: null })).resolves.toEqual({ hero: null })
})
it('posts JSON payloads and unwraps successful responses', async () => {
process.env.NEXT_PUBLIC_API_URL = 'https://api.example.com/api/v1'
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ data: { id: 'reservation_1' } }) }))
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
await expect(storefrontPost('/site/reservations', { vehicleId: 'vehicle_1' })).resolves.toEqual({ id: 'reservation_1' })
expect(fetchMock).toHaveBeenCalledWith('https://api.example.com/api/v1/site/reservations', expect.objectContaining({
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ vehicleId: 'vehicle_1' }),
}))
})
it('uses a generic error message when the response body is not JSON', async () => {
Object.defineProperty(globalThis, 'fetch', {
configurable: true,
value: vi.fn(async () => ({ ok: false, status: 502, json: async () => { throw new Error('bad json') } })),
})
await expect(storefrontFetch('/site/homepage')).rejects.toMatchObject({
name: 'StorefrontApiError',
message: 'Request failed',
status: 502,
})
})
})
+50
View File
@@ -0,0 +1,50 @@
const API_BASE =
(typeof window === 'undefined' ? process.env.API_INTERNAL_URL : process.env.NEXT_PUBLIC_API_URL)
?? process.env.NEXT_PUBLIC_API_URL
?? 'http://localhost:4000/api/v1'
export class StorefrontApiError extends Error {
status: number
code?: string
nextAvailableAt?: string | null
constructor(message: string, status: number, code?: string, nextAvailableAt?: string | null) {
super(message)
this.name = 'StorefrontApiError'
this.status = status
this.code = code
this.nextAvailableAt = nextAvailableAt
}
}
export async function storefrontFetch<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
cache: 'no-store',
credentials: 'include',
...init,
})
const json = await res.json().catch(() => null)
if (!res.ok) throw new StorefrontApiError(json?.message ?? 'Request failed', res.status, json?.error, json?.nextAvailableAt)
return json.data as T
}
export async function storefrontFetchOrDefault<T>(path: string, fallback: T): Promise<T> {
try {
return await storefrontFetch<T>(path)
} catch {
return fallback
}
}
export async function storefrontPost<T>(path: string, body: unknown): Promise<T> {
const base = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
const res = await fetch(`${base}${path}`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const json = await res.json().catch(() => null)
if (!res.ok) throw new StorefrontApiError(json?.message ?? 'Request failed', res.status, json?.error, json?.nextAvailableAt)
return json.data as T
}
+40
View File
@@ -0,0 +1,40 @@
import { afterEach, describe, expect, it } from 'vitest'
import { resolveBrowserAppUrl, resolveServerAppUrl } from './appUrls'
function installWindow(hostname: string, protocol = 'https:') {
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: { location: { hostname, protocol } },
})
}
afterEach(() => {
Reflect.deleteProperty(globalThis, 'window')
})
describe('storefront app URL resolution', () => {
it('leaves server-side browser fallback unchanged when window is unavailable', () => {
expect(resolveBrowserAppUrl('http://localhost:3000')).toBe('http://localhost:3000')
})
it('preserves localhost fallbacks but removes trailing slash', () => {
installWindow('localhost')
expect(resolveBrowserAppUrl('http://localhost:3000/')).toBe('http://localhost:3000')
})
it('rewrites production browser fallbacks to the active host and protocol', () => {
installWindow('www.rentaldrivego.ma', 'https:')
expect(resolveBrowserAppUrl('http://localhost:3000')).toBe('https://www.rentaldrivego.ma')
})
it('keeps path prefixes while rewriting the browser origin', () => {
installWindow('rental.example.com', 'https:')
expect(resolveBrowserAppUrl('http://localhost:3000/storefront')).toBe('https://rental.example.com/storefront')
})
it('resolves server URLs from forwarded host/proto and handles invalid fallbacks safely', () => {
expect(resolveServerAppUrl('http://localhost:3000', 'market.example.com', 'https')).toBe('https://market.example.com:3000')
expect(resolveServerAppUrl('http://localhost:3000', null)).toBe('http://localhost:3000')
expect(resolveServerAppUrl('/relative', 'market.example.com')).toBe('/relative')
})
})
+28
View File
@@ -0,0 +1,28 @@
export function resolveBrowserAppUrl(fallback: string): string {
if (typeof window === 'undefined') return fallback
const h = window.location.hostname
if (h === 'localhost' || h === '127.0.0.1') return fallback.replace(/\/$/, '')
try {
const target = new URL(fallback)
target.protocol = window.location.protocol
target.hostname = h
target.port = ''
return target.toString().replace(/\/$/, '')
} catch {
return fallback
}
}
export function resolveServerAppUrl(fallback: string, host: string | null, proto?: string | null): string {
if (!host) return fallback
try {
const target = new URL(fallback)
target.protocol = `${proto || target.protocol.replace(':', '')}:`
target.host = host
return target.toString().replace(/\/$/, '')
} catch {
return fallback
}
}

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