Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f16e1ab26b | |||
| f8d8f050f8 | |||
| 831cd8c7af | |||
| 2a79ac8e63 |
@@ -31,7 +31,7 @@ NEXT_PUBLIC_API_URL=https://api.example.com/api/v1
|
|||||||
|
|
||||||
# Frontend public URLs
|
# Frontend public URLs
|
||||||
NEXT_PUBLIC_HOMEPAGE_URL=https://example.com
|
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_DASHBOARD_URL=https://example.com/dashboard
|
||||||
NEXT_PUBLIC_ADMIN_URL=https://example.com/admin
|
NEXT_PUBLIC_ADMIN_URL=https://example.com/admin
|
||||||
NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=example.com
|
NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=example.com
|
||||||
@@ -45,7 +45,6 @@ CORS_ORIGINS=https://example.com,https://www.example.com
|
|||||||
JWT_SECRET=placeholder
|
JWT_SECRET=placeholder
|
||||||
JWT_EXPIRY=8h
|
JWT_EXPIRY=8h
|
||||||
RENTER_JWT_EXPIRY=7d
|
RENTER_JWT_EXPIRY=7d
|
||||||
SESSION_COOKIE_DOMAIN=.example.com
|
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
|
|
||||||
# File storage
|
# File storage
|
||||||
|
|||||||
+1
-4
@@ -21,9 +21,6 @@ NEXT_PUBLIC_API_URL=http://localhost:4000/api/v1
|
|||||||
JWT_SECRET=8bf96de20297c2c295a60e4040e937a7eb8ec7d7d2b83e79a1fc98463322a97c
|
JWT_SECRET=8bf96de20297c2c295a60e4040e937a7eb8ec7d7d2b83e79a1fc98463322a97c
|
||||||
JWT_EXPIRY=8h
|
JWT_EXPIRY=8h
|
||||||
RENTER_JWT_EXPIRY=7d
|
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) ────────────────────────────
|
# ─── Clerk (Company employee auth) ────────────────────────────
|
||||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
|
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_DASHBOARD_URL=http://localhost:3000/dashboard
|
||||||
NEXT_PUBLIC_ADMIN_URL=http://localhost:3000/admin
|
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
|
NEXT_PUBLIC_HOMEPAGE_URL=http://localhost:3000
|
||||||
# Public site is subdomain-based; use this for local dev:
|
# Public site is subdomain-based; use this for local dev:
|
||||||
NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=localhost:3003
|
NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=localhost:3003
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
name: Build & Push
|
name: Build & Deploy
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [fix_branch]
|
branches: [develop]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
@@ -15,7 +15,6 @@ env:
|
|||||||
GIT_SSL_NO_VERIFY: "true"
|
GIT_SSL_NO_VERIFY: "true"
|
||||||
REGISTRY_HOST: 192.168.3.80
|
REGISTRY_HOST: 192.168.3.80
|
||||||
DEPLOY_REGISTRY_HOST: 10.0.0.4
|
DEPLOY_REGISTRY_HOST: 10.0.0.4
|
||||||
DEPLOY_SSH_HOST: 10.0.0.1
|
|
||||||
DOCKERFILE_PATH: Dockerfile.production
|
DOCKERFILE_PATH: Dockerfile.production
|
||||||
DOCKER_PLATFORM: linux/amd64
|
DOCKER_PLATFORM: linux/amd64
|
||||||
DEPLOY_ROOT: /opt/rentaldrivego
|
DEPLOY_ROOT: /opt/rentaldrivego
|
||||||
@@ -28,45 +27,56 @@ jobs:
|
|||||||
image_repository: ${{ steps.image-meta.outputs.repository }}
|
image_repository: ${{ steps.image-meta.outputs.repository }}
|
||||||
docker_image: ${{ steps.image-meta.outputs.full }}
|
docker_image: ${{ steps.image-meta.outputs.full }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Check out repository
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
|
||||||
GITEA_SERVER_URL: ${{ gitea.server_url }}
|
|
||||||
GITEA_REPOSITORY: ${{ gitea.repository }}
|
|
||||||
GITEA_SHA: ${{ gitea.sha }}
|
|
||||||
CHECKOUT_TOKEN: ${{ gitea.token }}
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
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}"
|
if [ -z "$(find . -mindepth 1 -maxdepth 1 -print -quit)" ]; then
|
||||||
mkdir -p "$WORKSPACE"
|
REPOSITORY_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
|
||||||
cd "$WORKSPACE"
|
|
||||||
|
|
||||||
SERVER_URL="${GITEA_SERVER_URL:-${GITHUB_SERVER_URL:-}}"
|
git init .
|
||||||
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 remote add origin "$REPOSITORY_URL"
|
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
|
fi
|
||||||
|
|
||||||
git config --global --add safe.directory "$WORKSPACE"
|
git rev-parse --short HEAD
|
||||||
if [ -n "${CHECKOUT_TOKEN:-}" ]; then
|
|
||||||
git -c "http.extraHeader=Authorization: token ${CHECKOUT_TOKEN}" fetch --no-tags --depth=1 origin "$SHA"
|
- name: Ensure Docker CLI is available
|
||||||
else
|
run: |
|
||||||
git fetch --no-tags --depth=1 origin "$SHA"
|
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
|
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
|
- name: Docker image metadata
|
||||||
id: image-meta
|
id: image-meta
|
||||||
@@ -74,23 +84,16 @@ jobs:
|
|||||||
REPO="${{ gitea.repository }}"
|
REPO="${{ gitea.repository }}"
|
||||||
TAG="${{ gitea.sha }}"
|
TAG="${{ gitea.sha }}"
|
||||||
echo "repository=${REPO}" >> "$GITHUB_OUTPUT"
|
echo "repository=${REPO}" >> "$GITHUB_OUTPUT"
|
||||||
echo "full=${DEPLOY_REGISTRY_HOST}/${REPO}:${TAG}" >> "$GITHUB_OUTPUT"
|
echo "full=${REGISTRY_HOST}/${REPO}:${TAG}" >> "$GITHUB_OUTPUT"
|
||||||
echo "latest=${DEPLOY_REGISTRY_HOST}/${REPO}:latest" >> "$GITHUB_OUTPUT"
|
echo "latest=${REGISTRY_HOST}/${REPO}:latest" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Check Docker registry credentials
|
- name: Check Docker registry credentials
|
||||||
id: registry-check
|
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: |
|
run: |
|
||||||
REGISTRY_USERNAME="${REGISTRY_USERNAME:-${REGISTRY_USER:-}}"
|
if [ -n "${{ secrets.REGISTRY_USERNAME }}" ] && [ -n "${{ secrets.REGISTRY_PASSWORD }}" ]; then
|
||||||
REGISTRY_PASSWORD="${REGISTRY_PASSWORD:-${REGISTRY_TOKEN:-}}"
|
|
||||||
if [ -n "$REGISTRY_USERNAME" ] && [ -n "$REGISTRY_PASSWORD" ]; then
|
|
||||||
echo "available=true" >> "$GITHUB_OUTPUT"
|
echo "available=true" >> "$GITHUB_OUTPUT"
|
||||||
else
|
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"
|
echo "available=false" >> "$GITHUB_OUTPUT"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -104,258 +107,158 @@ jobs:
|
|||||||
NEXT_PUBLIC_ADMIN_URL: ${{ secrets.NEXT_PUBLIC_ADMIN_URL }}
|
NEXT_PUBLIC_ADMIN_URL: ${{ secrets.NEXT_PUBLIC_ADMIN_URL }}
|
||||||
SITE_ORIGIN: ${{ secrets.SITE_ORIGIN }}
|
SITE_ORIGIN: ${{ secrets.SITE_ORIGIN }}
|
||||||
run: |
|
run: |
|
||||||
validate_url() {
|
for variable in \
|
||||||
name="$1"
|
NEXT_PUBLIC_API_URL \
|
||||||
value="$2"
|
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
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
case "$value" in
|
case "$value" in
|
||||||
http://*|https://*) ;;
|
http://*|https://*) ;;
|
||||||
*)
|
*)
|
||||||
echo "::error::$name must be an absolute URL (got: $value)"
|
echo "::error::$variable must be an absolute URL (got: $value)"
|
||||||
exit 1
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
}
|
done
|
||||||
|
|
||||||
if [ -z "$NEXT_PUBLIC_CARPLACE_URL" ] && [ -n "$SITE_ORIGIN" ]; then
|
- name: Log in to Gitea Container Registry
|
||||||
NEXT_PUBLIC_CARPLACE_URL="${SITE_ORIGIN%/}/carplace"
|
if: steps.registry-check.outputs.available == 'true'
|
||||||
fi
|
shell: bash
|
||||||
|
|
||||||
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
|
|
||||||
env:
|
|
||||||
VPS_HOST: ${{ secrets.VPS_IP }}
|
|
||||||
VPS_SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
|
|
||||||
VPS_SSH_KEY_B64: ${{ secrets.VPS_SSH_KEY_B64 }}
|
|
||||||
run: |
|
run: |
|
||||||
VPS_HOST="${VPS_HOST:-$DEPLOY_SSH_HOST}"
|
printf '%s' "${{ secrets.REGISTRY_PASSWORD }}" | \
|
||||||
if [ -z "$VPS_SSH_KEY" ] && [ -z "$VPS_SSH_KEY_B64" ]; then
|
docker login "$REGISTRY_HOST" \
|
||||||
echo "::error::VPS_SSH_KEY_B64 or VPS_SSH_KEY is required to build on the remote Docker host"
|
--username "${{ secrets.REGISTRY_USERNAME }}" \
|
||||||
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
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Check SSH tools
|
|
||||||
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."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- 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 }}
|
|
||||||
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
|
|
||||||
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
|
|
||||||
chmod 644 ~/.ssh/known_hosts
|
|
||||||
|
|
||||||
- name: Test SSH authentication
|
|
||||||
env:
|
|
||||||
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"
|
|
||||||
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
|
|
||||||
|
|
||||||
- name: Sync build context to VPS
|
|
||||||
env:
|
|
||||||
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"
|
|
||||||
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" "
|
|
||||||
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
|
--password-stdin
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
BUILDKIT_NO_CLIENT_TOKEN: "true"
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
OUTPUT_ARG="--load"
|
||||||
|
if [ "${{ steps.registry-check.outputs.available }}" = "true" ]; then
|
||||||
|
OUTPUT_ARG="--push"
|
||||||
fi
|
fi
|
||||||
docker build \
|
|
||||||
--file '$DOCKERFILE_PATH' \
|
docker buildx build \
|
||||||
--platform '$DOCKER_PLATFORM' \
|
--file "$DOCKERFILE_PATH" \
|
||||||
--tag '$IMAGE_FULL' \
|
--platform "$DOCKER_PLATFORM" \
|
||||||
--tag '$IMAGE_LATEST' \
|
--tag "${{ steps.image-meta.outputs.full }}" \
|
||||||
|
--tag "${{ steps.image-meta.outputs.latest }}" \
|
||||||
--build-arg API_INTERNAL_URL=http://api:4000/api/v1 \
|
--build-arg API_INTERNAL_URL=http://api:4000/api/v1 \
|
||||||
--build-arg DASHBOARD_INTERNAL_URL=http://dashboard:3001 \
|
--build-arg DASHBOARD_INTERNAL_URL=http://dashboard:3001 \
|
||||||
--build-arg ADMIN_INTERNAL_URL=http://admin:3002 \
|
--build-arg ADMIN_INTERNAL_URL=http://admin:3002 \
|
||||||
--build-arg NEXT_PUBLIC_API_URL='$NEXT_PUBLIC_API_URL' \
|
--build-arg NEXT_PUBLIC_API_URL="${{ secrets.NEXT_PUBLIC_API_URL }}" \
|
||||||
--build-arg NEXT_PUBLIC_HOMEPAGE_URL='$NEXT_PUBLIC_HOMEPAGE_URL' \
|
--build-arg NEXT_PUBLIC_HOMEPAGE_URL="${{ secrets.NEXT_PUBLIC_HOMEPAGE_URL }}" \
|
||||||
--build-arg NEXT_PUBLIC_CARPLACE_URL='$NEXT_PUBLIC_CARPLACE_URL' \
|
--build-arg NEXT_PUBLIC_CARPLACE_URL="${{ secrets.NEXT_PUBLIC_CARPLACE_URL }}" \
|
||||||
--build-arg NEXT_PUBLIC_DASHBOARD_URL='$NEXT_PUBLIC_DASHBOARD_URL' \
|
--build-arg NEXT_PUBLIC_DASHBOARD_URL="${{ secrets.NEXT_PUBLIC_DASHBOARD_URL }}" \
|
||||||
--build-arg NEXT_PUBLIC_ADMIN_URL='$NEXT_PUBLIC_ADMIN_URL' \
|
--build-arg NEXT_PUBLIC_ADMIN_URL="${{ secrets.NEXT_PUBLIC_ADMIN_URL }}" \
|
||||||
--build-arg SITE_ORIGIN='$SITE_ORIGIN' \
|
--build-arg SITE_ORIGIN="${{ secrets.SITE_ORIGIN }}" \
|
||||||
|
"$OUTPUT_ARG" \
|
||||||
.
|
.
|
||||||
if [ '$PUSH_IMAGE' = 'true' ]; then
|
|
||||||
docker push '$IMAGE_FULL'
|
|
||||||
docker push '$IMAGE_LATEST'
|
|
||||||
fi
|
|
||||||
"
|
|
||||||
|
|
||||||
- name: Deploy production stack on VPS
|
deploy:
|
||||||
|
name: Deploy to VPS
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [build-image]
|
||||||
env:
|
env:
|
||||||
IMAGE_REPOSITORY: ${{ steps.image-meta.outputs.repository }}
|
DOCKER_IMAGE: ${{ needs.build-image.outputs.docker_image }}
|
||||||
IMAGE_TAG: ${{ gitea.sha }}
|
IMAGE_REPOSITORY: ${{ needs.build-image.outputs.image_repository }}
|
||||||
ENV_DOCKER_PRODUCTION: ${{ secrets.ENV_DOCKER_PRODUCTION }}
|
steps:
|
||||||
ENV_DOCKER_PRODUCTION_B64: ${{ secrets.ENV_DOCKER_PRODUCTION_B64 }}
|
- name: Check out repository
|
||||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
shell: bash
|
||||||
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: |
|
run: |
|
||||||
VPS_HOST="${VPS_HOST:-$DEPLOY_SSH_HOST}"
|
set -euo pipefail
|
||||||
if [ -z "$VPS_HOST" ]; then
|
|
||||||
echo "::error::VPS_IP secret or DEPLOY_SSH_HOST is required"
|
if [ -z "$(find . -mindepth 1 -maxdepth 1 -print -quit)" ]; then
|
||||||
exit 1
|
REPOSITORY_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
|
||||||
fi
|
|
||||||
SSH_OPTIONS="-i $HOME/.ssh/id_rsa -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=$HOME/.ssh/known_hosts"
|
git init .
|
||||||
ENV_DOCKER_PRODUCTION_B64_CLEAN="$(printf '%s' "$ENV_DOCKER_PRODUCTION_B64" | tr -d '[:space:]')"
|
git remote add origin "$REPOSITORY_URL"
|
||||||
ENV_DOCKER_PRODUCTION_RAW_B64="$(printf '%s' "$ENV_DOCKER_PRODUCTION" | base64 | tr -d '\n')"
|
if [ -n "${{ secrets.GITHUB_TOKEN }}" ]; then
|
||||||
REGISTRY_USERNAME="${REGISTRY_USERNAME:-${REGISTRY_USER:-}}"
|
AUTH_HEADER="$(printf '%s:%s' "${{ gitea.actor }}" "${{ secrets.GITHUB_TOKEN }}" | base64 | tr -d '\n')"
|
||||||
REGISTRY_PASSWORD="${REGISTRY_PASSWORD:-${REGISTRY_TOKEN:-}}"
|
git -c http.extraheader="Authorization: Basic ${AUTH_HEADER}" \
|
||||||
REGISTRY_PASSWORD_B64="$(printf '%s' "$REGISTRY_PASSWORD" | base64 | tr -d '\n')"
|
fetch --depth 1 origin "${{ gitea.ref }}"
|
||||||
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
|
else
|
||||||
echo 'Using existing $DEPLOY_ROOT/.env.docker.production on the VPS'
|
git fetch --depth 1 origin "${{ gitea.ref }}"
|
||||||
fi
|
fi
|
||||||
cp '$DEPLOY_ROOT/.env.docker.production' '$REMOTE_BUILD_DIR/.env.docker.production'
|
git checkout --detach "${{ gitea.sha }}" || git checkout --detach FETCH_HEAD
|
||||||
chmod 600 '$REMOTE_BUILD_DIR/.env.docker.production'
|
fi
|
||||||
cd '$REMOTE_BUILD_DIR'
|
|
||||||
export APP_IMAGE='$DEPLOY_REGISTRY_HOST/$IMAGE_REPOSITORY'
|
git rev-parse --short HEAD
|
||||||
export IMAGE_TAG='$IMAGE_TAG'
|
|
||||||
export REGISTRY_HOST='$DEPLOY_REGISTRY_HOST'
|
- name: Check deploy credentials
|
||||||
export REGISTRY_USER='$REGISTRY_USERNAME'
|
id: check-creds
|
||||||
export REGISTRY_PASSWORD=\"\$(printf '%s' '$REGISTRY_PASSWORD_B64' | base64 -d)\"
|
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
|
||||||
|
if: steps.check-creds.outputs.skip == 'false'
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.ssh && chmod 700 ~/.ssh
|
||||||
|
echo "${{ secrets.VPS_SSH_KEY }}" > ~/.ssh/id_rsa
|
||||||
|
chmod 600 ~/.ssh/id_rsa
|
||||||
|
ssh-keyscan -H "${{ secrets.VPS_IP }}" >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
|
chmod 644 ~/.ssh/known_hosts
|
||||||
|
|
||||||
|
- name: Sync deployment assets
|
||||||
|
if: steps.check-creds.outputs.skip == 'false'
|
||||||
|
run: |
|
||||||
|
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: Run deploy script on VPS
|
||||||
|
if: steps.check-creds.outputs.skip == 'false'
|
||||||
|
run: |
|
||||||
|
REGISTRY_PASSWORD_B64="$(printf '%s' "${{ secrets.REGISTRY_PASSWORD }}" | base64 | tr -d '\n')"
|
||||||
|
ssh "${{ secrets.VPS_USER }}@${{ secrets.VPS_IP }}" "
|
||||||
|
set -e
|
||||||
|
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
|
bash scripts/docker-prod-deploy.sh
|
||||||
"
|
"
|
||||||
|
|||||||
+147
-28
@@ -24,10 +24,27 @@ jobs:
|
|||||||
name: Type Check (all packages)
|
name: Type Check (all packages)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- name: Check out repository
|
||||||
- uses: actions/setup-node@v4
|
shell: bash
|
||||||
with:
|
run: |
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
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
|
- run: corepack enable && corepack prepare npm@10.5.0 --activate
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
@@ -62,10 +79,27 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: type-check
|
needs: type-check
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- name: Check out repository
|
||||||
- uses: actions/setup-node@v4
|
shell: bash
|
||||||
with:
|
run: |
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
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
|
- run: corepack enable && corepack prepare npm@10.5.0 --activate
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
@@ -100,10 +134,27 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: type-check
|
needs: type-check
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- name: Check out repository
|
||||||
- uses: actions/setup-node@v4
|
shell: bash
|
||||||
with:
|
run: |
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
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
|
- run: corepack enable && corepack prepare npm@10.5.0 --activate
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
@@ -138,10 +189,27 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: type-check
|
needs: type-check
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- name: Check out repository
|
||||||
- uses: actions/setup-node@v4
|
shell: bash
|
||||||
with:
|
run: |
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
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
|
- run: corepack enable && corepack prepare npm@10.5.0 --activate
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
@@ -176,10 +244,27 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: type-check
|
needs: type-check
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- name: Check out repository
|
||||||
- uses: actions/setup-node@v4
|
shell: bash
|
||||||
with:
|
run: |
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
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
|
- run: corepack enable && corepack prepare npm@10.5.0 --activate
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
@@ -213,10 +298,27 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: type-check
|
needs: type-check
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- name: Check out repository
|
||||||
- uses: actions/setup-node@v4
|
shell: bash
|
||||||
with:
|
run: |
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
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
|
- run: corepack enable && corepack prepare npm@10.5.0 --activate
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
@@ -277,10 +379,27 @@ jobs:
|
|||||||
JWT_EXPIRY: 8h
|
JWT_EXPIRY: 8h
|
||||||
FILE_STORAGE_ROOT: /tmp/rentaldrivego-test-storage
|
FILE_STORAGE_ROOT: /tmp/rentaldrivego-test-storage
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- name: Check out repository
|
||||||
- uses: actions/setup-node@v4
|
shell: bash
|
||||||
with:
|
run: |
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
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
|
- run: corepack enable && corepack prepare npm@10.5.0 --activate
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
+2
-2
@@ -28,14 +28,14 @@ api_tests:
|
|||||||
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
||||||
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
|
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
|
||||||
|
|
||||||
carplace_tests:
|
storefront_tests:
|
||||||
stage: test
|
stage: test
|
||||||
image: node:20-bookworm
|
image: node:20-bookworm
|
||||||
before_script:
|
before_script:
|
||||||
- npm ci
|
- npm ci
|
||||||
- npm run build --workspace @rentaldrivego/types
|
- npm run build --workspace @rentaldrivego/types
|
||||||
script:
|
script:
|
||||||
- npm run test:carplace
|
- npm run test:storefront
|
||||||
rules:
|
rules:
|
||||||
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
||||||
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
|
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
|
||||||
|
|||||||
@@ -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.
|
- `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.
|
- `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.
|
- `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.
|
- `api`: intentionally unchanged. A visual migration should not casually rewrite business logic because that is how weekends disappear.
|
||||||
|
|
||||||
## Design rules applied
|
## 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`
|
- `admin/src/styles/phase16-tokens.css`
|
||||||
- `dashboard/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`
|
- `scripts/validate-phase16-design.mjs`
|
||||||
- `DESIGN_MIGRATION_REPORT.md`
|
- `DESIGN_MIGRATION_REPORT.md`
|
||||||
- `PHASE16_DESIGN_MIGRATION_MANIFEST.json`
|
- `PHASE16_DESIGN_MIGRATION_MANIFEST.json`
|
||||||
|
|||||||
@@ -7,14 +7,14 @@
|
|||||||
3. Added `PublicPageLayout` as the reusable composition layer for authentication and onboarding pages.
|
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.
|
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.
|
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.
|
7. Added active-page semantics to dashboard sign-in and create-account navbar actions.
|
||||||
8. Added focused tests and implementation documentation.
|
8. Added focused tests and implementation documentation.
|
||||||
|
|
||||||
## Deliberately unchanged
|
## Deliberately unchanged
|
||||||
|
|
||||||
- Authentication requests and API endpoints
|
- 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
|
- Language and theme persistence
|
||||||
- Embedded workspace behavior
|
- Embedded workspace behavior
|
||||||
- Authenticated dashboard and admin navigation
|
- Authenticated dashboard and admin navigation
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
- Verified all relative and `@/` local imports resolve.
|
- Verified all relative and `@/` local imports resolve.
|
||||||
- Verified every `use client` directive remains the first statement.
|
- Verified every `use client` directive remains the first statement.
|
||||||
- Verified sign-in and create-account import and render `PublicPageLayout`.
|
- 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`.
|
- Verified embedded password-recovery navigation preserves `embedded=1`.
|
||||||
|
|
||||||
## Validation limitation
|
## Validation limitation
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
"homepage": "retained as embedded Phase 16 source of truth",
|
"homepage": "retained as embedded Phase 16 source of truth",
|
||||||
"admin": "migrated",
|
"admin": "migrated",
|
||||||
"dashboard": "migrated",
|
"dashboard": "migrated",
|
||||||
"carplace": "migrated",
|
"storefront": "migrated",
|
||||||
"api": "unchanged"
|
"api": "unchanged"
|
||||||
},
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
@@ -37,37 +37,37 @@
|
|||||||
"sha256": "7a35fad2750bccc09c24f24c1d57594048b040f24d4cde7639fa1a2ce3d63e64"
|
"sha256": "7a35fad2750bccc09c24f24c1d57594048b040f24d4cde7639fa1a2ce3d63e64"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "carplace/src/app/layout.tsx",
|
"path": "storefront/src/app/layout.tsx",
|
||||||
"status": "modified",
|
"status": "modified",
|
||||||
"bytes": 1833,
|
"bytes": 1833,
|
||||||
"sha256": "6fed4ef50a886e1c551b6456b46c5fc785b02a4be988aef06fa95c28443bbd27"
|
"sha256": "6fed4ef50a886e1c551b6456b46c5fc785b02a4be988aef06fa95c28443bbd27"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "carplace/src/app/globals.css",
|
"path": "storefront/src/app/globals.css",
|
||||||
"status": "modified",
|
"status": "modified",
|
||||||
"bytes": 7525,
|
"bytes": 7525,
|
||||||
"sha256": "49e03206558248eb5de2b9a8463be6e0b43e01df518bc0b625fe2953a18185c6"
|
"sha256": "49e03206558248eb5de2b9a8463be6e0b43e01df518bc0b625fe2953a18185c6"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "carplace/src/components/CarplaceFooter.tsx",
|
"path": "storefront/src/components/StorefrontFooter.tsx",
|
||||||
"status": "modified",
|
"status": "modified",
|
||||||
"bytes": 4632,
|
"bytes": 4632,
|
||||||
"sha256": "169d04dcf0976d2637ef5371ca295e9c362bd089d8d6ec313b4e8b4131d5bdd3"
|
"sha256": "169d04dcf0976d2637ef5371ca295e9c362bd089d8d6ec313b4e8b4131d5bdd3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "carplace/src/components/CarplaceHeader.tsx",
|
"path": "storefront/src/components/StorefrontHeader.tsx",
|
||||||
"status": "modified",
|
"status": "modified",
|
||||||
"bytes": 10438,
|
"bytes": 10438,
|
||||||
"sha256": "25afc57ff9893f255eddf19580e84b510e6032764daaa5727e2e8d669dca4213"
|
"sha256": "25afc57ff9893f255eddf19580e84b510e6032764daaa5727e2e8d669dca4213"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "carplace/src/styles/phase16-tokens.css",
|
"path": "storefront/src/styles/phase16-tokens.css",
|
||||||
"status": "added",
|
"status": "added",
|
||||||
"bytes": 4214,
|
"bytes": 4214,
|
||||||
"sha256": "0e7fb35fb2833ff564c1188e39735349a8df901938fa87f2dc1b931dd4f04cd0"
|
"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",
|
"status": "modified",
|
||||||
"bytes": 13979,
|
"bytes": 13979,
|
||||||
"sha256": "9edf8da13e50cabcb2a126b300c71cb0b7a096a31d11d364e37dc09eeb625d3b"
|
"sha256": "9edf8da13e50cabcb2a126b300c71cb0b7a096a31d11d364e37dc09eeb625d3b"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
],
|
],
|
||||||
"includedApplications": [
|
"includedApplications": [
|
||||||
"homepage",
|
"homepage",
|
||||||
"carplace",
|
"storefront",
|
||||||
"dashboard",
|
"dashboard",
|
||||||
"admin",
|
"admin",
|
||||||
"api"
|
"api"
|
||||||
|
|||||||
@@ -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:
|
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`
|
- `homepage`
|
||||||
- `carplace`
|
- `storefront`
|
||||||
- `dashboard`
|
- `dashboard`
|
||||||
- `admin`
|
- `admin`
|
||||||
- `api`
|
- `api`
|
||||||
|
|||||||
@@ -17,9 +17,9 @@
|
|||||||
| Light/dark and Arabic RTL token mappings present | Passed |
|
| Light/dark and Arabic RTL token mappings present | Passed |
|
||||||
| Reduced-motion and forced-colors rules present | Passed |
|
| Reduced-motion and forced-colors rules present | Passed |
|
||||||
| Shared sign-in/create-account public layout retained | 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 |
|
| 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
|
## Reproducible design gate
|
||||||
|
|
||||||
|
|||||||
@@ -60,15 +60,15 @@ Changed files:
|
|||||||
|
|
||||||
- `apps/api/src/app.ts`
|
- `apps/api/src/app.ts`
|
||||||
- `apps/dashboard/src/middleware.ts`
|
- `apps/dashboard/src/middleware.ts`
|
||||||
- `apps/carplace/src/middleware.ts`
|
- `apps/storefront/src/middleware.ts`
|
||||||
- `apps/admin/src/middleware.ts`
|
- `apps/admin/src/middleware.ts`
|
||||||
- `apps/dashboard/src/middleware.test.ts`
|
- `apps/dashboard/src/middleware.test.ts`
|
||||||
- `apps/carplace/src/middleware.test.ts`
|
- `apps/storefront/src/middleware.test.ts`
|
||||||
|
|
||||||
What changed:
|
What changed:
|
||||||
|
|
||||||
- API now rejects requests containing `x-middleware-subrequest` before route handling.
|
- 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.
|
- Added/updated middleware tests for the rejection path.
|
||||||
|
|
||||||
Security effect:
|
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 [dropoffMode, setDropoffMode] = useState<'same' | 'different'>(initial.dropoffMode === 'different' ? 'different' : 'same')
|
||||||
const [dateError, setDateError] = useState<string | null>(null)
|
const [dateError, setDateError] = useState<string | null>(null)
|
||||||
const today = new Date().toISOString().slice(0, 10)
|
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>) {
|
function validateDates(event: FormEvent<HTMLFormElement>) {
|
||||||
const data = new FormData(event.currentTarget)
|
const data = new FormData(event.currentTarget)
|
||||||
@@ -40,7 +37,7 @@ export default function CarplaceSearchForm({ cities, copy, initial = {}, compact
|
|||||||
return (
|
return (
|
||||||
<form action={carplaceHref('/search')} method="get" onSubmit={validateDates} className={compact ? 'carplace-search carplace-search-compact' : 'carplace-search'}>
|
<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}
|
{!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}>
|
<SearchField icon={MapPin} label={copy.search.pickup}>
|
||||||
<input list="carplace-cities" name="pickupLocation" defaultValue={initial.pickupLocation ?? ''} placeholder={copy.search.locationPlaceholder} required className="carplace-input" />
|
<input list="carplace-cities" name="pickupLocation" defaultValue={initial.pickupLocation ?? ''} placeholder={copy.search.locationPlaceholder} required className="carplace-input" />
|
||||||
</SearchField>
|
</SearchField>
|
||||||
@@ -52,20 +49,20 @@ export default function CarplaceSearchForm({ cities, copy, initial = {}, compact
|
|||||||
)}
|
)}
|
||||||
</SearchField>
|
</SearchField>
|
||||||
<SearchField icon={CalendarDays} label={copy.search.pickupDate}>
|
<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="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} />
|
<input type="time" name="pickupTime" defaultValue={initial.pickupTime ?? '10:00'} required className="carplace-input min-w-0" aria-label={copy.search.pickupTime} />
|
||||||
</div>
|
</div>
|
||||||
</SearchField>
|
</SearchField>
|
||||||
<SearchField icon={CalendarDays} label={copy.search.returnDate}>
|
<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="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} />
|
<input type="time" name="returnTime" defaultValue={initial.returnTime ?? '10:00'} required className="carplace-input min-w-0" aria-label={copy.search.returnTime} />
|
||||||
</div>
|
</div>
|
||||||
</SearchField>
|
</SearchField>
|
||||||
</div>
|
</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}>
|
<div className="flex flex-wrap gap-2" role="group" aria-label={copy.search.returnLocation}>
|
||||||
<label className={`carplace-choice ${dropoffMode === 'same' ? 'carplace-choice-active' : ''}`}>
|
<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" />
|
<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>
|
</label>
|
||||||
</div>
|
</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">
|
<label className="grid gap-1 text-xs font-bold text-stone-600 dark:text-slate-300">
|
||||||
{copy.search.age}
|
{copy.search.age}
|
||||||
<select name="driverAge" defaultValue={initial.driverAge ?? '25'} className="carplace-input">
|
<select name="driverAge" defaultValue={initial.driverAge ?? '25'} className="carplace-input">
|
||||||
@@ -90,7 +87,7 @@ export default function CarplaceSearchForm({ cities, copy, initial = {}, compact
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" className={submitButtonClass}>
|
<button type="submit" className="carplace-primary-button lg:ms-auto">
|
||||||
<Search className="h-4 w-4" />
|
<Search className="h-4 w-4" />
|
||||||
{copy.actions.search}
|
{copy.actions.search}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import TopBar from '@/components/layout/TopBar'
|
|||||||
|
|
||||||
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
|
<DashboardAccessGuard>
|
||||||
<div className="fleet-dashboard-shell flex min-h-screen transition-colors print:block print:min-h-0 print:bg-white">
|
<div className="fleet-dashboard-shell flex min-h-screen transition-colors print:block print:min-h-0 print:bg-white">
|
||||||
<div className="print:hidden">
|
<div className="print:hidden">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
@@ -13,9 +14,10 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
|||||||
<TopBar />
|
<TopBar />
|
||||||
</div>
|
</div>
|
||||||
<main className="flex-1 overflow-y-auto p-6 print:p-0 print:overflow-visible">
|
<main className="flex-1 overflow-y-auto p-6 print:p-0 print:overflow-visible">
|
||||||
<DashboardAccessGuard>{children}</DashboardAccessGuard>
|
{children}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</DashboardAccessGuard>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
labels:
|
labels:
|
||||||
- traefik.enable=true
|
- 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.entrypoints=websecure
|
||||||
- traefik.http.routers.api.tls=true
|
- traefik.http.routers.api.tls=true
|
||||||
- traefik.http.routers.api.tls.certresolver=letsencrypt
|
- traefik.http.routers.api.tls.certresolver=letsencrypt
|
||||||
@@ -169,7 +169,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
labels:
|
labels:
|
||||||
- traefik.enable=true
|
- 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.entrypoints=websecure
|
||||||
- traefik.http.routers.homepage.tls=true
|
- traefik.http.routers.homepage.tls=true
|
||||||
- traefik.http.routers.homepage.tls.certresolver=letsencrypt
|
- traefik.http.routers.homepage.tls.certresolver=letsencrypt
|
||||||
@@ -255,7 +255,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
labels:
|
labels:
|
||||||
- traefik.enable=true
|
- 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.entrypoints=websecure
|
||||||
- traefik.http.routers.dashboard.tls=true
|
- traefik.http.routers.dashboard.tls=true
|
||||||
- traefik.http.routers.dashboard.tls.certresolver=letsencrypt
|
- traefik.http.routers.dashboard.tls.certresolver=letsencrypt
|
||||||
@@ -298,7 +298,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
labels:
|
labels:
|
||||||
- traefik.enable=true
|
- 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.entrypoints=websecure
|
||||||
- traefik.http.routers.admin.tls=true
|
- traefik.http.routers.admin.tls=true
|
||||||
- traefik.http.routers.admin.tls.certresolver=letsencrypt
|
- traefik.http.routers.admin.tls.certresolver=letsencrypt
|
||||||
|
|||||||
@@ -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:
|
Replace the current booking form with the revised version in:
|
||||||
|
|
||||||
`apps/carplace/src/components/BookingForm.tsx`
|
`apps/storefront/src/components/BookingForm.tsx`
|
||||||
|
|
||||||
Acceptance criteria:
|
Acceptance criteria:
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ Recommended behavior:
|
|||||||
|
|
||||||
Backend candidate:
|
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.
|
- 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
|
### Phase 3: Booking Status Transparency
|
||||||
|
|||||||
+8
-116
@@ -151,7 +151,6 @@ docker network create traefik-proxy
|
|||||||
cp .env.docker.production.example .env.docker.production
|
cp .env.docker.production.example .env.docker.production
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
Open `.env.docker.production` and fill in every value. The minimum required secrets are:
|
Open `.env.docker.production` and fill in every value. The minimum required secrets are:
|
||||||
|
|
||||||
| Variable | What to set |
|
| 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) |
|
| `RESEND_API_KEY` | Resend API key (or configure SMTP vars instead) |
|
||||||
| `PGMANAGE_DOMAIN` | Hostname for pgManage, e.g. `pgmanage.rentaldrivego.ma` |
|
| `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 `/`.
|
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`.
|
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>
|
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
|
```text
|
||||||
VPS_IP=<server-ip-or-hostname>
|
VPS_IP=<server-ip-or-hostname>
|
||||||
VPS_USER=<ssh-user>
|
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.
|
- GitLab `File` variable containing the private key
|
||||||
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`.
|
- Standard multiline key pasted directly into the variable value
|
||||||
|
- Single-line key with literal `\n` newline escapes
|
||||||
Supported private key formats:
|
- Base64-encoded private key stored as a single line
|
||||||
|
|
||||||
- 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`
|
|
||||||
|
|
||||||
If your key is passphrase-protected, generate a dedicated deploy key without a passphrase for CI instead of reusing an interactive workstation key.
|
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:
|
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.
|
||||||
|
|
||||||
```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 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`.
|
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
|
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:
|
Then on the VPS restart Traefik:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+12
-12
@@ -1,10 +1,10 @@
|
|||||||
# 🚗 RentalDriveGo— Multi-Tenant Car Rental SaaS + Carplace
|
# 🚗 RentalDriveGo— Multi-Tenant Car Rental SaaS + Storefront
|
||||||
|
|
||||||
## Platform Model
|
## Platform Model
|
||||||
|
|
||||||
**Rental Companies (B2B)** — pay RentalDriveGoa subscription (AmanPay or PayPal), get:
|
**Rental Companies (B2B)** — pay RentalDriveGoa subscription (AmanPay or PayPal), get:
|
||||||
- A fully private dashboard (zero data overlap with any other company)
|
- 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
|
- Promotional offers management
|
||||||
- A white-label site at `company.RentalDriveGo.com` where renters book and pay
|
- 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
|
Company uploads photos in /dashboard/fleet
|
||||||
→ Stored in Cloudinary
|
→ Stored in Cloudinary
|
||||||
→ Vehicle.photos[] holds the URLs
|
→ Vehicle.photos[] holds the URLs
|
||||||
→ First photo = cover image on carplace cards
|
→ First photo = cover image on storefront cards
|
||||||
→ All photos shown in carplace vehicle gallery
|
→ All photos shown in storefront vehicle gallery
|
||||||
→ isPublished=true makes vehicle appear on carplace
|
→ 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 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 2: app.RentalDriveGo.com — Company dashboard (private, subscription-gated)
|
||||||
Layer 3: {slug}.RentalDriveGo.com — Company branded site (booking + payment here)
|
Layer 3: {slug}.RentalDriveGo.com — Company branded site (booking + payment here)
|
||||||
Layer 4: api.RentalDriveGo.com — REST API
|
Layer 4: api.RentalDriveGo.com — REST API
|
||||||
@@ -66,9 +66,9 @@ Layer 4: api.RentalDriveGo.com — REST API
|
|||||||
rental-car-site/
|
rental-car-site/
|
||||||
├── README.md
|
├── README.md
|
||||||
├── docs/
|
├── docs/
|
||||||
│ ├── ARCHITECTURE.md ← Payment providers, carplace redirect model, photo flow
|
│ ├── ARCHITECTURE.md ← Payment providers, storefront redirect model, photo flow
|
||||||
│ ├── DESIGN_SYSTEM.md
|
│ ├── 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
|
│ ├── FEATURES.md
|
||||||
│ └── DEPLOYMENT.md
|
│ └── DEPLOYMENT.md
|
||||||
└── skills/
|
└── skills/
|
||||||
@@ -80,7 +80,7 @@ rental-car-site/
|
|||||||
│ ├── api-routes.md ← All routes
|
│ ├── api-routes.md ← All routes
|
||||||
│ ├── payment-service.md ← AmanPay + PayPal implementation ← KEY FILE
|
│ ├── payment-service.md ← AmanPay + PayPal implementation ← KEY FILE
|
||||||
│ ├── subscription-service.md ← Manual renewal, plan prices in MAD/USD/EUR
|
│ ├── 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
|
│ └── notification-service.md ← All 5 channels
|
||||||
└── rental-car-i18n/
|
└── rental-car-i18n/
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -9,13 +9,13 @@
|
|||||||
|
|
||||||
## 1. Purpose
|
## 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
|
### 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.**
|
**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.
|
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.
|
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.
|
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.
|
8. Preserve full feature and content parity across English, French, and Arabic, including proper Arabic RTL behavior.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Findings From the Current Code
|
## 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:
|
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
|
### 2.2 Carplace and the homepage need one visual foundation
|
||||||
|
|
||||||
The legacy carplace currently renders:
|
The legacy storefront currently renders:
|
||||||
|
|
||||||
- `CarplaceHeader`
|
- `StorefrontHeader`
|
||||||
- `CarplaceFooter`
|
- `StorefrontFooter`
|
||||||
|
|
||||||
The homepage renders:
|
The homepage renders:
|
||||||
|
|
||||||
@@ -108,13 +108,13 @@ unless the backend and company settings later support those guarantees.
|
|||||||
|
|
||||||
### 2.5 Renter account authentication is disabled
|
### 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.
|
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
|
### 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
|
- Start trial
|
||||||
- Create workspace
|
- Create workspace
|
||||||
@@ -217,7 +217,7 @@ Secondary conversions are:
|
|||||||
- Search-engine metadata
|
- Search-engine metadata
|
||||||
- Accessibility baseline
|
- Accessibility baseline
|
||||||
- Funnel analytics
|
- Funnel analytics
|
||||||
- Safe cutover from the legacy carplace
|
- Safe cutover from the legacy storefront
|
||||||
|
|
||||||
### 4.2 Explicitly excluded from the first launch
|
### 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. 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:
|
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:
|
Recommended structure:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
apps/carplace/src/
|
apps/storefront/src/
|
||||||
app/
|
app/
|
||||||
(public)/
|
(public)/
|
||||||
page.tsx
|
page.tsx
|
||||||
@@ -299,9 +299,9 @@ Use a server-side release setting such as:
|
|||||||
CARPLACE_EXPERIENCE_VERSION=v2
|
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
|
### 5.3 Introduce Carplace routes without breaking legacy URLs
|
||||||
|
|
||||||
@@ -314,7 +314,7 @@ Keep these routes working:
|
|||||||
/carplace/[company-slug]/vehicles/[vehicle-id]
|
/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
|
### 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
|
### 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:
|
Suggested fields:
|
||||||
|
|
||||||
@@ -893,7 +893,7 @@ POST /api/v1/carplace/reservations
|
|||||||
GET /api/v1/carplace/requests/:token
|
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
|
### 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.
|
1. Explicit valid language selected by the user.
|
||||||
2. Existing `rentaldrivego-language` cookie.
|
2. Existing `rentaldrivego-language` cookie.
|
||||||
3. Migrated legacy `carplace-language` cookie.
|
3. Migrated legacy `storefront-language` cookie.
|
||||||
4. Supported browser `Accept-Language` value.
|
4. Supported browser `Accept-Language` value.
|
||||||
5. English fallback.
|
5. English fallback.
|
||||||
|
|
||||||
@@ -1038,7 +1038,7 @@ The language selector must:
|
|||||||
- Preserve the current pathname and query string.
|
- Preserve the current pathname and query string.
|
||||||
- Preserve search dates, locations, filters, sorting, offer codes, and attribution parameters.
|
- Preserve search dates, locations, filters, sorting, offer codes, and attribution parameters.
|
||||||
- Preserve completed reservation-form fields when switching language during the request flow.
|
- 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
|
### 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.
|
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
|
└── 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:
|
Example menu definition:
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ The target design must:
|
|||||||
The system will seed seven settings menu items:
|
The system will seed seven settings menu items:
|
||||||
|
|
||||||
1. Company Profile
|
1. Company Profile
|
||||||
2. Branding and Carplace
|
2. Branding and Storefront
|
||||||
3. Payment Methods
|
3. Payment Methods
|
||||||
4. Rental Policies
|
4. Rental Policies
|
||||||
5. Insurance Policies
|
5. Insurance Policies
|
||||||
@@ -74,7 +74,7 @@ Protected section routes:
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
/dashboard/settings?section=company
|
/dashboard/settings?section=company
|
||||||
/dashboard/settings?section=carplace
|
/dashboard/settings?section=storefront
|
||||||
/dashboard/settings?section=payments
|
/dashboard/settings?section=payments
|
||||||
/dashboard/settings?section=rental-policies
|
/dashboard/settings?section=rental-policies
|
||||||
/dashboard/settings?section=insurance
|
/dashboard/settings?section=insurance
|
||||||
@@ -149,10 +149,10 @@ Legend:
|
|||||||
| Company profile | Edit | Edit | Edit |
|
| Company profile | Edit | Edit | Edit |
|
||||||
| Public contact information | Edit | Edit | Edit |
|
| Public contact information | Edit | Edit | Edit |
|
||||||
| Default language and currency | 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 |
|
| Basic logo and public identity | Edit | Edit | Edit |
|
||||||
| Custom brand colors | Locked | 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 |
|
| Renter payment providers | Locked | Edit | Edit |
|
||||||
| Basic fuel and damage policies | Edit | Edit | Edit |
|
| Basic fuel and damage policies | Edit | Edit | Edit |
|
||||||
| Automated additional-driver fees | Locked | Edit | Edit |
|
| Automated additional-driver fees | Locked | Edit | Edit |
|
||||||
@@ -178,7 +178,7 @@ Recommended keys:
|
|||||||
settings.company_profile
|
settings.company_profile
|
||||||
settings.public_contact
|
settings.public_contact
|
||||||
settings.locale_currency
|
settings.locale_currency
|
||||||
settings.carplace_listing
|
settings.storefront_listing
|
||||||
settings.branding_basic
|
settings.branding_basic
|
||||||
settings.branding_custom
|
settings.branding_custom
|
||||||
settings.branding_hero
|
settings.branding_hero
|
||||||
@@ -232,26 +232,26 @@ Rules:
|
|||||||
- It remains available during subscription recovery.
|
- It remains available during subscription recovery.
|
||||||
- Use the shared Moroccan city selector where applicable.
|
- Use the shared Moroccan city selector where applicable.
|
||||||
- Validate email, phone, website, locale, and currency.
|
- 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
|
### STARTER
|
||||||
|
|
||||||
Available:
|
Available:
|
||||||
|
|
||||||
- Carplace listing
|
- Storefront listing
|
||||||
- Company logo
|
- Company logo
|
||||||
- Public identity preview
|
- Public identity preview
|
||||||
- Basic carplace information
|
- Basic storefront information
|
||||||
|
|
||||||
Locked:
|
Locked:
|
||||||
|
|
||||||
- Custom primary and accent colors
|
- Custom primary and accent colors
|
||||||
- Hero image
|
- Hero image
|
||||||
- Advanced carplace appearance controls
|
- Advanced storefront appearance controls
|
||||||
|
|
||||||
### GROWTH and PRO
|
### GROWTH and PRO
|
||||||
|
|
||||||
@@ -260,7 +260,7 @@ Available:
|
|||||||
- All STARTER branding capabilities
|
- All STARTER branding capabilities
|
||||||
- Custom brand colors
|
- Custom brand colors
|
||||||
- Hero image
|
- Hero image
|
||||||
- Full carplace branding controls
|
- Full storefront branding controls
|
||||||
|
|
||||||
### UX behavior
|
### UX behavior
|
||||||
|
|
||||||
@@ -675,7 +675,7 @@ Enforcement must cover:
|
|||||||
- Reservation price calculation
|
- Reservation price calculation
|
||||||
- Insurance selection
|
- Insurance selection
|
||||||
- Additional-driver fee calculation
|
- Additional-driver fee calculation
|
||||||
- Carplace branding response
|
- Storefront branding response
|
||||||
- Online payment processing
|
- Online payment processing
|
||||||
- Scheduled report jobs
|
- Scheduled report jobs
|
||||||
|
|
||||||
|
|||||||
@@ -60,15 +60,15 @@ Changed files:
|
|||||||
|
|
||||||
- `apps/api/src/app.ts`
|
- `apps/api/src/app.ts`
|
||||||
- `apps/dashboard/src/middleware.ts`
|
- `apps/dashboard/src/middleware.ts`
|
||||||
- `apps/carplace/src/middleware.ts`
|
- `apps/storefront/src/middleware.ts`
|
||||||
- `apps/admin/src/middleware.ts`
|
- `apps/admin/src/middleware.ts`
|
||||||
- `apps/dashboard/src/middleware.test.ts`
|
- `apps/dashboard/src/middleware.test.ts`
|
||||||
- `apps/carplace/src/middleware.test.ts`
|
- `apps/storefront/src/middleware.test.ts`
|
||||||
|
|
||||||
What changed:
|
What changed:
|
||||||
|
|
||||||
- API now rejects requests containing `x-middleware-subrequest` before route handling.
|
- 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.
|
- Added/updated middleware tests for the rejection path.
|
||||||
|
|
||||||
Security effect:
|
Security effect:
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ Creating an account registers a new rental company on the platform. The flow has
|
|||||||
|
|
||||||
## 1. Accessing the Sign-up Page
|
## 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 **"Create Agency Space"** in the header navigation.
|
||||||
- Click **"Get Started"** on any pricing plan card (`/pricing` page).
|
- Click **"Get Started"** on any pricing plan card (`/pricing` page).
|
||||||
@@ -26,7 +26,7 @@ Both actions navigate to:
|
|||||||
http://localhost:3000/dashboard/sign-up
|
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)
|
### URL parameters (optional)
|
||||||
|
|
||||||
@@ -111,7 +111,7 @@ Select one of three plans:
|
|||||||
| Plan | Description |
|
| Plan | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **STARTER** | Core fleet, offers, and bookings |
|
| **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 |
|
| **PRO** | Full white-label and premium controls |
|
||||||
|
|
||||||
Additional options:
|
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:
|
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.
|
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.
|
3. **Step 3 — Completion**: Redirect to the dashboard home.
|
||||||
|
|
||||||
### Subscription
|
### Subscription
|
||||||
@@ -228,7 +228,7 @@ New accounts start on a **30-day free trial** with the selected plan. The subscr
|
|||||||
|
|
||||||
| File | Purpose |
|
| 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/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/ui/BilingualInput.tsx` | Bilingual (FR/AR) input component |
|
||||||
| `apps/dashboard/src/components/layout/PublicShell.tsx` | Public layout wrapper for the sign-up page |
|
| `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 |
|
| File | Location |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `apps/carplace/src/components/CarplaceHeader.tsx` | "Create Agency Space" button |
|
| `apps/storefront/src/components/StorefrontHeader.tsx` | "Create Agency Space" button |
|
||||||
| `apps/carplace/src/components/WorkspaceTabs.tsx` | "Owner sign in" link |
|
| `apps/storefront/src/components/WorkspaceTabs.tsx` | "Owner sign in" link |
|
||||||
| `apps/carplace/src/app/(public)/pricing/PricingClient.tsx` | "Get Started" pricing CTA |
|
| `apps/storefront/src/app/(public)/pricing/PricingClient.tsx` | "Get Started" pricing CTA |
|
||||||
| `apps/carplace/src/app/(public)/HomeContent.tsx` | Homepage CTA |
|
| `apps/storefront/src/app/(public)/HomeContent.tsx` | Homepage CTA |
|
||||||
|
|
||||||
### Backend
|
### Backend
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ docker compose -f docker-compose.dev.yml up -d redis
|
|||||||
|
|
||||||
# app services
|
# app services
|
||||||
docker compose -f docker-compose.dev.yml --profile api up --build
|
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 dashboard up --build
|
||||||
docker compose -f docker-compose.dev.yml --profile admin up --build
|
docker compose -f docker-compose.dev.yml --profile admin up --build
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
|
|
||||||
## 3. What to Redesign (Inspired by isysolution.com)
|
## 3. What to Redesign (Inspired by isysolution.com)
|
||||||
|
|
||||||
### 3.1 Carplace — Public Pages
|
### 3.1 Storefront — Public Pages
|
||||||
|
|
||||||
#### Home Page (`/`)
|
#### Home Page (`/`)
|
||||||
Current issues to fix:
|
Current issues to fix:
|
||||||
@@ -70,7 +70,7 @@ Stats Strip
|
|||||||
How It Works
|
How It Works
|
||||||
├── Step 1: Create your workspace
|
├── Step 1: Create your workspace
|
||||||
├── Step 2: Add your fleet
|
├── Step 2: Add your fleet
|
||||||
├── Step 3: Go live on carplace
|
├── Step 3: Go live on storefront
|
||||||
└── Step 4: Manage everything from dashboard
|
└── Step 4: Manage everything from dashboard
|
||||||
|
|
||||||
Services / Features Grid
|
Services / Features Grid
|
||||||
@@ -154,8 +154,8 @@ Redesign:
|
|||||||
## 4. Component Redesign Checklist
|
## 4. Component Redesign Checklist
|
||||||
|
|
||||||
### Shared Components
|
### Shared Components
|
||||||
- [ ] `CarplaceHeader` — add mobile hamburger menu, improve nav spacing
|
- [ ] `StorefrontHeader` — add mobile hamburger menu, improve nav spacing
|
||||||
- [ ] `CarplaceFooter` — add logo, social links, newsletter input
|
- [ ] `StorefrontFooter` — add logo, social links, newsletter input
|
||||||
- [ ] `BookingForm` — improve step layout, add progress indicator
|
- [ ] `BookingForm` — improve step layout, add progress indicator
|
||||||
- [ ] `WorkspaceTabs` — improve active tab styling
|
- [ ] `WorkspaceTabs` — improve active tab styling
|
||||||
|
|
||||||
@@ -187,7 +187,7 @@ Redesign:
|
|||||||
| Numbered step process | Home "How it works" |
|
| Numbered step process | Home "How it works" |
|
||||||
| Stats/counters strip | Home page |
|
| Stats/counters strip | Home page |
|
||||||
| Testimonials carousel | Home page |
|
| Testimonials carousel | Home page |
|
||||||
| Mega footer with columns | Carplace footer |
|
| Mega footer with columns | Storefront footer |
|
||||||
| Sidebar with grouped nav | Dashboard |
|
| Sidebar with grouped nav | Dashboard |
|
||||||
| KPI metric cards | Dashboard home |
|
| KPI metric cards | Dashboard home |
|
||||||
|
|
||||||
@@ -202,7 +202,7 @@ Redesign:
|
|||||||
- Wide: `xl: 1280px`, `2xl: 1536px`
|
- Wide: `xl: 1280px`, `2xl: 1536px`
|
||||||
|
|
||||||
### Mobile Improvements Needed
|
### Mobile Improvements Needed
|
||||||
- [ ] Hamburger menu for carplace header
|
- [ ] Hamburger menu for storefront header
|
||||||
- [ ] Collapsible sidebar for dashboard
|
- [ ] Collapsible sidebar for dashboard
|
||||||
- [ ] Stack pricing cards vertically
|
- [ ] Stack pricing cards vertically
|
||||||
- [ ] Full-width booking form
|
- [ ] Full-width booking form
|
||||||
@@ -228,7 +228,7 @@ Redesign:
|
|||||||
|
|
||||||
## 8. Implementation Phases
|
## 8. Implementation Phases
|
||||||
|
|
||||||
### Phase 1 — Carplace Public Pages (Priority)
|
### Phase 1 — Storefront Public Pages (Priority)
|
||||||
1. Home page redesign
|
1. Home page redesign
|
||||||
- Stats strip
|
- Stats strip
|
||||||
- How it works section
|
- How it works section
|
||||||
@@ -254,15 +254,15 @@ Redesign:
|
|||||||
|
|
||||||
## 9. Files to Modify
|
## 9. Files to Modify
|
||||||
|
|
||||||
### Carplace
|
### Storefront
|
||||||
| File | Change |
|
| File | Change |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `src/app/(public)/HomeContent.tsx` | Add stats, how-it-works, testimonials sections |
|
| `src/app/(public)/HomeContent.tsx` | Add stats, how-it-works, testimonials sections |
|
||||||
| `src/app/(public)/features/page.tsx` | Alternating layout |
|
| `src/app/(public)/features/page.tsx` | Alternating layout |
|
||||||
| `src/app/(public)/pricing/PricingPageContent.tsx` | Toggle + improved cards |
|
| `src/app/(public)/pricing/PricingPageContent.tsx` | Toggle + improved cards |
|
||||||
| `src/app/(public)/explore/ExploreVehicleGrid.tsx` | Improved vehicle cards |
|
| `src/app/(public)/explore/ExploreVehicleGrid.tsx` | Improved vehicle cards |
|
||||||
| `src/components/CarplaceHeader.tsx` | Mobile menu |
|
| `src/components/StorefrontHeader.tsx` | Mobile menu |
|
||||||
| `src/components/CarplaceFooter.tsx` | Mega footer |
|
| `src/components/StorefrontFooter.tsx` | Mega footer |
|
||||||
| `src/app/globals.css` | No changes needed |
|
| `src/app/globals.css` | No changes needed |
|
||||||
|
|
||||||
### Dashboard
|
### Dashboard
|
||||||
|
|||||||
@@ -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 |
|
| `DRAFT` | Step 0 above | Browsing dashboard UI, exploring settings, reading docs |
|
||||||
| `IDENTIFIED` | Owner first/last name + company display name | Personalizing UI, inviting teammates |
|
| `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** |
|
| `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 |
|
| `PAYABLE` | Payment provider config (AmanPay/PayPal) + responsible person | Accepting bookings/payments |
|
||||||
| `SUBSCRIBED` | Plan + billing period selection | Past the 30-day trial, billed account |
|
| `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.
|
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 |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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:
|
Integration tests exist, but the heaviest workflows still benefit from deeper coverage:
|
||||||
|
|
||||||
- subscription billing transitions
|
- subscription billing transitions
|
||||||
- carplace reservation intake
|
- storefront reservation intake
|
||||||
- public booking and payment initialization
|
- public booking and payment initialization
|
||||||
- reservation inspection and close flows
|
- reservation inspection and close flows
|
||||||
- admin billing operations
|
- admin billing operations
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ We do not use analytics cookies, advertising cookies, or third-party tracking co
|
|||||||
| **Scope** | All pages (`path=/`) |
|
| **Scope** | All pages (`path=/`) |
|
||||||
| **Third-party** | No |
|
| **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.
|
**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.
|
**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 |
|
| Field | Value |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Names** | `dashboard-language`, `carplace-language` |
|
| **Names** | `dashboard-language`, `storefront-language` |
|
||||||
| **Category** | Functional / preference |
|
| **Category** | Functional / preference |
|
||||||
| **Duration** | 1 year |
|
| **Duration** | 1 year |
|
||||||
| **Scope** | All pages (`path=/`) |
|
| **Scope** | All pages (`path=/`) |
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ This document lists the features that are active in the current codebase.
|
|||||||
|
|
||||||
Source of truth:
|
Source of truth:
|
||||||
|
|
||||||
- `apps/carplace`
|
- `apps/storefront`
|
||||||
- `apps/dashboard`
|
- `apps/dashboard`
|
||||||
- `apps/admin`
|
- `apps/admin`
|
||||||
- `apps/api`
|
- `apps/api`
|
||||||
@@ -14,7 +14,7 @@ Source of truth:
|
|||||||
|
|
||||||
The current workspace contains four active apps:
|
The current workspace contains four active apps:
|
||||||
|
|
||||||
- `apps/carplace`
|
- `apps/storefront`
|
||||||
- `apps/dashboard`
|
- `apps/dashboard`
|
||||||
- `apps/admin`
|
- `apps/admin`
|
||||||
- `apps/api`
|
- `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
|
- Notification inbox and preferences
|
||||||
- Subscription management
|
- Subscription management
|
||||||
|
|
||||||
### Carplace and public platform
|
### Storefront and public platform
|
||||||
|
|
||||||
- Public marketing homepage
|
- Public marketing homepage
|
||||||
- Public pricing page
|
- Public pricing page
|
||||||
- Public features page
|
- Public features page
|
||||||
- Public carplace/explore flow
|
- Public storefront/explore flow
|
||||||
- Explore company profile pages under `/explore/[slug]`
|
- Explore company profile pages under `/explore/[slug]`
|
||||||
- Public review submission page via review token
|
- Public review submission page via review token
|
||||||
- Public legal/app policy pages
|
- 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
|
- Billing operations
|
||||||
- Pricing configuration and promotions
|
- Pricing configuration and promotions
|
||||||
- Notification review
|
- Notification review
|
||||||
- Carplace/site config management
|
- Storefront/site config management
|
||||||
|
|
||||||
## Present But Not Active Product Features
|
## 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
|
- standalone about/contact page set
|
||||||
- Google renter auth
|
- Google renter auth
|
||||||
- automatic custom-domain provisioning
|
- 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
|
## Notes
|
||||||
|
|
||||||
- The database still contains legacy fields such as `Employee.clerkUserId`, but those fields are no longer evidence of active Clerk integration.
|
- 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.
|
||||||
|
|||||||
@@ -4,15 +4,15 @@ This document lists the pages that are actually present in the current frontend
|
|||||||
|
|
||||||
Source of truth:
|
Source of truth:
|
||||||
|
|
||||||
- `apps/carplace/src/app`
|
- `apps/storefront/src/app`
|
||||||
- `apps/dashboard/src/app`
|
- `apps/dashboard/src/app`
|
||||||
- `apps/admin/src/app`
|
- `apps/admin/src/app`
|
||||||
|
|
||||||
## Carplace App
|
## Storefront App
|
||||||
|
|
||||||
App:
|
App:
|
||||||
|
|
||||||
- `apps/carplace`
|
- `apps/storefront`
|
||||||
|
|
||||||
### Public routes
|
### Public routes
|
||||||
|
|
||||||
@@ -37,11 +37,11 @@ App:
|
|||||||
|
|
||||||
- `/` is the public marketing home.
|
- `/` is the public marketing home.
|
||||||
- `/pricing` consumes platform pricing from `/site/platform/pricing`.
|
- `/pricing` consumes platform pricing from `/site/platform/pricing`.
|
||||||
- `/explore` is the public carplace search/discovery page.
|
- `/explore` is the public storefront search/discovery page.
|
||||||
- `/explore/[slug]` is the company carplace profile page.
|
- `/explore/[slug]` is the company storefront profile page.
|
||||||
- `/review` is the review submission page driven by a reservation review token.
|
- `/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`
|
- `/about`
|
||||||
- `/contact`
|
- `/contact`
|
||||||
@@ -51,7 +51,7 @@ App:
|
|||||||
|
|
||||||
### Renter routes present in code but not active product entrypoints
|
### 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/dashboard`
|
||||||
- `/renter/notifications`
|
- `/renter/notifications`
|
||||||
@@ -114,7 +114,7 @@ Base path:
|
|||||||
- `/dashboard` shows KPIs and booking-source analytics.
|
- `/dashboard` shows KPIs and booking-source analytics.
|
||||||
- `/dashboard/fleet*` manages vehicles, maintenance, and calendar blocks.
|
- `/dashboard/fleet*` manages vehicles, maintenance, and calendar blocks.
|
||||||
- `/dashboard/reservations*` manages the booking lifecycle and inspection workflows.
|
- `/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/customers` is the company CRM view.
|
||||||
- `/dashboard/offers` manages promotions.
|
- `/dashboard/offers` manages promotions.
|
||||||
- `/dashboard/team` manages employees.
|
- `/dashboard/team` manages employees.
|
||||||
@@ -164,7 +164,7 @@ App:
|
|||||||
- `/dashboard/billing` manages platform billing operations.
|
- `/dashboard/billing` manages platform billing operations.
|
||||||
- `/dashboard/pricing` edits platform pricing, features, and promotions.
|
- `/dashboard/pricing` edits platform pricing, features, and promotions.
|
||||||
- `/dashboard/notifications` inspects platform notifications.
|
- `/dashboard/notifications` inspects platform notifications.
|
||||||
- `/dashboard/site-config` edits carplace/site configuration content.
|
- `/dashboard/site-config` edits storefront/site configuration content.
|
||||||
|
|
||||||
## Deliberately Not Listed
|
## Deliberately Not Listed
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
## Summary
|
## 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 |
|
| # | Severity | Category | Status |
|
||||||
|---|----------|----------|--------|
|
|---|----------|----------|--------|
|
||||||
@@ -127,7 +127,7 @@ An authenticated employee from Company A could fetch the full maintenance histor
|
|||||||
|
|
||||||
### Exploit Scenario
|
### 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
|
### Fix
|
||||||
|
|
||||||
@@ -156,7 +156,7 @@ An employee of Company A calls `GET /api/v1/vehicles/<vehicleId_from_company_B>/
|
|||||||
|
|
||||||
## Frontend Scan Results
|
## 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)
|
### 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.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ This is the default for dashboard/company management routes.
|
|||||||
|
|
||||||
### Subscription Guard
|
### 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
|
### Role-Based Access Control
|
||||||
|
|
||||||
@@ -90,7 +90,7 @@ Admin roles are hierarchical:
|
|||||||
|
|
||||||
`requireRenterAuth` validates a Bearer token with `type === "renter"` and attaches `req.renterId`.
|
`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
|
### 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/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/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/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/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/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 |
|
| `/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.
|
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.
|
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
|
- featured/public offers
|
||||||
- carplace cities
|
- storefront cities
|
||||||
- listed companies
|
- listed companies
|
||||||
- vehicle search
|
- vehicle search
|
||||||
- carplace reservation intake
|
- storefront reservation intake
|
||||||
- review submission by token
|
- review submission by token
|
||||||
- company public pages under `/:slug`
|
- 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
|
- billing account and billing invoice operations
|
||||||
- pricing config, plan features, and promotions
|
- pricing config, plan features, and promotions
|
||||||
- subscription overrides and extensions
|
- subscription overrides and extensions
|
||||||
- carplace homepage configuration
|
- storefront homepage configuration
|
||||||
|
|
||||||
This is the only route group allowed to work across tenants.
|
This is the only route group allowed to work across tenants.
|
||||||
|
|
||||||
|
|||||||
@@ -243,7 +243,7 @@ Purpose:
|
|||||||
- public contact and social metadata
|
- public contact and social metadata
|
||||||
- default locale and currency
|
- default locale and currency
|
||||||
- company-owned payment provider settings for renter payments
|
- company-owned payment provider settings for renter payments
|
||||||
- carplace visibility and rating
|
- storefront visibility and rating
|
||||||
- homepage/menu JSON configuration
|
- homepage/menu JSON configuration
|
||||||
|
|
||||||
Important uniqueness:
|
Important uniqueness:
|
||||||
|
|||||||
Generated
+26
@@ -466,6 +466,32 @@
|
|||||||
"url": "https://github.com/sponsors/colinhacks"
|
"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": {
|
"node_modules/@adobe/css-tools": {
|
||||||
"version": "4.5.0",
|
"version": "4.5.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
"docker:prod:start:api": "bash scripts/docker-prod-up-api.sh",
|
"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:homepage": "bash scripts/docker-prod-up-homepage.sh",
|
||||||
"docker:prod:start:carplace": "bash scripts/docker-prod-up-carplace.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:dashboard": "bash scripts/docker-prod-up-dashboard.sh",
|
||||||
"docker:prod:start:admin": "bash scripts/docker-prod-up-admin.sh",
|
"docker:prod:start:admin": "bash scripts/docker-prod-up-admin.sh",
|
||||||
"docker:prod:start:frontends": "bash scripts/docker-prod-up-frontends.sh",
|
"docker:prod:start:frontends": "bash scripts/docker-prod-up-frontends.sh",
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -7,7 +7,6 @@ PROD_COMPOSE_FILE="${ROOT_DIR}/docker-compose.production.yml"
|
|||||||
TRAEFIK_COMPOSE_FILE="${ROOT_DIR}/traefik.yaml"
|
TRAEFIK_COMPOSE_FILE="${ROOT_DIR}/traefik.yaml"
|
||||||
ENV_FILE="${ROOT_DIR}/.env.docker.production"
|
ENV_FILE="${ROOT_DIR}/.env.docker.production"
|
||||||
PROJECT_NAME="${DOCKER_PROD_PROJECT_NAME:-rentaldrivego-prod}"
|
PROJECT_NAME="${DOCKER_PROD_PROJECT_NAME:-rentaldrivego-prod}"
|
||||||
TRAEFIK_PROJECT_NAME="${DOCKER_TRAEFIK_PROJECT_NAME:-rentaldrivego}"
|
|
||||||
TRAEFIK_NETWORK="${DOCKER_PROD_TRAEFIK_NETWORK:-traefik-proxy}"
|
TRAEFIK_NETWORK="${DOCKER_PROD_TRAEFIK_NETWORK:-traefik-proxy}"
|
||||||
VOLUME_BACKUP_IMAGE="${DOCKER_VOLUME_BACKUP_IMAGE:-postgres:16-alpine}"
|
VOLUME_BACKUP_IMAGE="${DOCKER_VOLUME_BACKUP_IMAGE:-postgres:16-alpine}"
|
||||||
API_UPLOADS_VOLUME="${DOCKER_PROD_API_UPLOADS_VOLUME:-${PROJECT_NAME}_api_uploads}"
|
API_UPLOADS_VOLUME="${DOCKER_PROD_API_UPLOADS_VOLUME:-${PROJECT_NAME}_api_uploads}"
|
||||||
@@ -105,7 +104,9 @@ export_compose_env_from_file() {
|
|||||||
done < "${ENV_FILE}"
|
done < "${ENV_FILE}"
|
||||||
|
|
||||||
if [[ -z "${NEXT_PUBLIC_CARPLACE_URL:-}" ]]; then
|
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"
|
NEXT_PUBLIC_CARPLACE_URL="${SITE_ORIGIN%/}/carplace"
|
||||||
fi
|
fi
|
||||||
export NEXT_PUBLIC_CARPLACE_URL
|
export NEXT_PUBLIC_CARPLACE_URL
|
||||||
@@ -250,7 +251,7 @@ prod_compose() {
|
|||||||
|
|
||||||
traefik_compose() {
|
traefik_compose() {
|
||||||
export_compose_env_from_file
|
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() {
|
start_prod_services() {
|
||||||
@@ -454,13 +455,6 @@ wait_for_healthy() {
|
|||||||
start_traefik() {
|
start_traefik() {
|
||||||
ensure_env_file
|
ensure_env_file
|
||||||
ensure_traefik_network
|
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
|
traefik_compose up -d
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+11
@@ -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
@@ -1,6 +1,8 @@
|
|||||||
services:
|
services:
|
||||||
traefik:
|
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:
|
command:
|
||||||
- --api.dashboard=false
|
- --api.dashboard=false
|
||||||
- --api.insecure=false
|
- --api.insecure=false
|
||||||
@@ -29,6 +31,51 @@ services:
|
|||||||
- ./dynamic:/etc/traefik/dynamic:ro
|
- ./dynamic:/etc/traefik/dynamic:ro
|
||||||
networks:
|
networks:
|
||||||
- traefik-proxy
|
- 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:
|
networks:
|
||||||
traefik-proxy:
|
traefik-proxy:
|
||||||
external: true
|
external: true
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ ENV_FILE="${ROOT_DIR}/.env.docker.production"
|
|||||||
DYNAMIC_DIR="${ROOT_DIR}/dynamic"
|
DYNAMIC_DIR="${ROOT_DIR}/dynamic"
|
||||||
REGISTRY_DYNAMIC_FILE="${DYNAMIC_DIR}/registry-upstream.yml"
|
REGISTRY_DYNAMIC_FILE="${DYNAMIC_DIR}/registry-upstream.yml"
|
||||||
PROJECT_NAME="${DOCKER_PROD_PROJECT_NAME:-rentaldrivego-prod}"
|
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}"
|
PORTAINER_PROJECT_NAME="${DOCKER_PORTAINER_PROJECT_NAME:-rentaldrivego-portainer-prod}"
|
||||||
REGISTRY_PROJECT_NAME="${DOCKER_REGISTRY_PROJECT_NAME:-rentaldrivego-registry-prod}"
|
REGISTRY_PROJECT_NAME="${DOCKER_REGISTRY_PROJECT_NAME:-rentaldrivego-registry-prod}"
|
||||||
TRAEFIK_NETWORK="${DOCKER_PROD_TRAEFIK_NETWORK:-traefik-proxy}"
|
TRAEFIK_NETWORK="${DOCKER_PROD_TRAEFIK_NETWORK:-traefik-proxy}"
|
||||||
@@ -100,7 +99,9 @@ export_compose_env_from_file() {
|
|||||||
done < "${ENV_FILE}"
|
done < "${ENV_FILE}"
|
||||||
|
|
||||||
if [[ -z "${NEXT_PUBLIC_CARPLACE_URL:-}" ]]; then
|
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"
|
NEXT_PUBLIC_CARPLACE_URL="${SITE_ORIGIN%/}/carplace"
|
||||||
fi
|
fi
|
||||||
export NEXT_PUBLIC_CARPLACE_URL
|
export NEXT_PUBLIC_CARPLACE_URL
|
||||||
@@ -263,7 +264,7 @@ registry_compose() {
|
|||||||
|
|
||||||
traefik_compose() {
|
traefik_compose() {
|
||||||
export_compose_env_from_file
|
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() {
|
start_prod_services() {
|
||||||
@@ -348,12 +349,6 @@ pull_prod_release_images() {
|
|||||||
require_release_image
|
require_release_image
|
||||||
ensure_registry_transport_configured
|
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
|
if load_release_image_archives; then
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
@@ -365,7 +360,7 @@ pull_prod_release_images() {
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
prod_compose pull migrate api homepage carplace dashboard admin
|
prod_compose pull migrate api carplace dashboard admin
|
||||||
}
|
}
|
||||||
|
|
||||||
load_release_image_archives() {
|
load_release_image_archives() {
|
||||||
@@ -501,13 +496,6 @@ start_traefik() {
|
|||||||
ensure_env_file
|
ensure_env_file
|
||||||
ensure_traefik_network
|
ensure_traefik_network
|
||||||
render_registry_proxy_config
|
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
|
traefik_compose up -d
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,9 +26,8 @@ echo "Running database migrations"
|
|||||||
run_prod_migrations
|
run_prod_migrations
|
||||||
|
|
||||||
echo "Starting application services"
|
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 api 180
|
||||||
wait_for_healthy homepage 180
|
|
||||||
wait_for_healthy carplace 180
|
wait_for_healthy carplace 180
|
||||||
wait_for_healthy dashboard 180
|
wait_for_healthy dashboard 180
|
||||||
wait_for_healthy admin 180
|
wait_for_healthy admin 180
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ echo "Applying migrations"
|
|||||||
prod_compose run --rm migrate
|
prod_compose run --rm migrate
|
||||||
|
|
||||||
echo "Starting application services"
|
echo "Starting application services"
|
||||||
app_services=(api homepage carplace dashboard admin)
|
app_services=(api carplace dashboard admin)
|
||||||
|
|
||||||
if [[ "${INCLUDE_PGMANAGE}" == "1" ]]; then
|
if [[ "${INCLUDE_PGMANAGE}" == "1" ]]; then
|
||||||
prod_compose --profile private-tools up -d "${app_services[@]}" pgmanage
|
prod_compose --profile private-tools up -d "${app_services[@]}" pgmanage
|
||||||
@@ -98,7 +98,6 @@ else
|
|||||||
prod_compose up -d "${app_services[@]}"
|
prod_compose up -d "${app_services[@]}"
|
||||||
fi
|
fi
|
||||||
wait_for_healthy api 180
|
wait_for_healthy api 180
|
||||||
wait_for_healthy homepage 180
|
|
||||||
wait_for_healthy carplace 180
|
wait_for_healthy carplace 180
|
||||||
wait_for_healthy dashboard 180
|
wait_for_healthy dashboard 180
|
||||||
wait_for_healthy admin 180
|
wait_for_healthy admin 180
|
||||||
|
|||||||
Executable
+11
@@ -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."
|
||||||
Vendored
+6
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
@@ -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 à l’exploration',
|
||||||
|
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} />
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
|
||||||
|
export default function StorefrontHomePage() {
|
||||||
|
redirect('/explore')
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 à l’exploration',
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 l’email 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 d’enregistrer le profil.',
|
||||||
|
prefFailed: 'Impossible d’enregistrer 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 n’avez 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)}`)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 l’essentiel maintenant. Les documents conducteur peuvent être ajoutés plus tard si l’agence 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. L’agence 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 d’expiration',
|
||||||
|
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 l’agence ? 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 l’agence.',
|
||||||
|
unavailableWithDate: 'Indisponible pour ces dates. Prochaine disponibilité probable : {date}.',
|
||||||
|
unavailableWithoutDate: 'Indisponible pour ces dates. Choisissez d’autres 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: 'L’agence reçoit d’abord vos dates et vos coordonnées.',
|
||||||
|
nextStepAvailability: 'Confirmation de disponibilité',
|
||||||
|
nextStepAvailabilityBody: 'L’agence vérifie la demande et l’accepte si la voiture est toujours libre.',
|
||||||
|
nextStepDocuments: 'Documents conducteur si nécessaires',
|
||||||
|
nextStepDocumentsBody: 'Le permis et les pièces d’identité peuvent être demandés après l’acceptation 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
© {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">
|
||||||
|
© {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'
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
Reference in New Issue
Block a user