diff --git a/.env.docker.dev b/.env.docker.dev index 3e0fd19..0126804 100644 --- a/.env.docker.dev +++ b/.env.docker.dev @@ -11,6 +11,10 @@ DASHBOARD_URL=http://localhost:3000/dashboard ADMIN_URL=http://localhost:3000/admin DASHBOARD_INTERNAL_URL=http://host.docker.internal:3001 ADMIN_INTERNAL_URL=http://host.docker.internal:3002 +# Asset prefix for the dashboard/admin dev servers so browsers load JS chunks from the +# correct port rather than through the marketplace proxy (which doesn't have those chunks). +DASHBOARD_ASSET_PREFIX=http://localhost:3001/dashboard +ADMIN_ASSET_PREFIX=http://localhost:3002/admin JWT_SECRET=dev-secret JWT_EXPIRY=8h RENTER_JWT_EXPIRY=7d diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 8d4fce5..63bc74b 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -157,6 +157,9 @@ deploy_to_vps: echo '-- Pulling pre-built image --' docker pull $DOCKER_IMAGE + echo '-- Ensuring uploads volume exists --' + docker volume create rentaldrivego-prod_api_uploads >/dev/null + echo '-- Restarting services --' APP_IMAGE=$IMAGE_REPOSITORY \ APP_VERSION=$CI_COMMIT_SHORT_SHA \ diff --git a/DOCKER.md b/DOCKER.md index d33a1c2..5c32855 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -235,6 +235,8 @@ docker compose -p rentaldrivego-portainer-prod --env-file .env.docker.production Uploaded assets handled by the API, including vehicle photos and company branding images, are persisted in the named Docker volume `api_uploads`, mounted inside the API container at `/var/lib/rentaldrivego/storage`. Rebuilding or redeploying the API no longer clears those files as long as that volume is kept. +The production helper scripts and the GitLab deploy job now explicitly create the Docker volume `${DOCKER_PROD_PROJECT_NAME:-rentaldrivego-prod}_api_uploads` before starting or redeploying the API stack, so a fresh server bootstrap does not accidentally start the API without its upload storage volume. + #### Updating after a code change Pull the latest code and rebuild only the changed service: diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index d7c8ae4..f6a8348 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -78,6 +78,15 @@ export function createApp() { res.status(404).end() }) + // Public storage assets (logos, vehicle photos, etc.) must be loadable cross-origin + // by the browser. Without this header, helmet's default CORP: same-origin reaches + // 404 responses (when express.static calls next() on a miss) and the browser blocks + // the response even for broken-image cases, producing ERR_BLOCKED_BY_RESPONSE. + app.use('/storage', (_req, res, next) => { + res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin') + next() + }) + // Serve uploaded assets from the configured storage root, with a legacy fallback // for older files that were written under the previous API-local path. app.use('/storage', express.static(getStorageRoot())) @@ -88,7 +97,10 @@ export function createApp() { // Webhook must use raw body BEFORE express.json() app.use('/api/v1/webhooks', express.raw({ type: 'application/json' }), webhookRouter) - app.use(helmet()) + // Let /storage responses manage CORP explicitly so missing files still return + // a normal cross-origin 404 instead of being blocked by Helmet's default + // same-origin policy. + app.use(helmet({ crossOriginResourcePolicy: false })) if (process.env.NODE_ENV !== 'test') app.use(morgan('combined')) app.use(express.json({ limit: '10mb' })) diff --git a/apps/api/src/modules/admin/admin.routes.ts b/apps/api/src/modules/admin/admin.routes.ts index 641c3b5..c05ba07 100644 --- a/apps/api/src/modules/admin/admin.routes.ts +++ b/apps/api/src/modules/admin/admin.routes.ts @@ -23,7 +23,7 @@ router.post('/auth/login', async (req, res, next) => { if (!result) return res.status(401).json({ error: 'invalid_credentials', message: 'Invalid email or password', statusCode: 401 }) if ('totpRequired' in result) return res.status(401).json({ error: 'totp_required', message: '2FA code required', statusCode: 401 }) if ('invalidTotp' in result) return res.status(401).json({ error: 'invalid_totp', message: 'Invalid 2FA code', statusCode: 401 }) - ok(res, { data: result }) + ok(res, result) } catch (err) { next(err) } }) @@ -31,7 +31,7 @@ router.post('/auth/forgot-password', async (req, res, next) => { try { const { email } = parseBody(forgotPasswordSchema, req) await service.forgotPassword(email) - ok(res, { data: { message: 'If that email is registered, a reset link has been sent.' } }) + ok(res, { message: 'If that email is registered, a reset link has been sent.' }) } catch (err) { next(err) } }) @@ -40,19 +40,19 @@ router.post('/auth/reset-password', async (req, res, next) => { const { token, password } = parseBody(resetPasswordSchema, req) const ok2 = await service.resetPassword(token, password) if (!ok2) return res.status(400).json({ error: 'invalid_token', message: 'Reset link is invalid or has expired.', statusCode: 400 }) - ok(res, { data: { message: 'Password updated successfully. You can now sign in.' } }) + ok(res, { message: 'Password updated successfully. You can now sign in.' }) } catch (err) { next(err) } }) // ─── Auth (protected) ────────────────────────────────────────── router.get('/auth/me', requireAdminAuth, (req, res) => { - ok(res, { data: presentAdminUser(req.admin as any) }) + ok(res, presentAdminUser(req.admin as any)) }) router.post('/auth/2fa/setup', requireAdminAuth, async (req, res, next) => { try { - ok(res, { data: await service.setupTotp(req.admin.id, req.admin.email) }) + ok(res, await service.setupTotp(req.admin.id, req.admin.email)) } catch (err) { next(err) } }) @@ -61,7 +61,7 @@ router.post('/auth/2fa/verify', requireAdminAuth, async (req, res, next) => { const { code } = parseBody(totpVerifySchema, req) const valid = await service.verifyTotp(req.admin.id, code) if (!valid) return res.status(400).json({ error: 'invalid_code', message: 'Invalid 2FA code', statusCode: 400 }) - ok(res, { data: { success: true } }) + ok(res, { success: true }) } catch (err) { next(err) } }) @@ -76,7 +76,7 @@ router.get('/companies', requireAdminAuth, async (req, res, next) => { router.get('/companies/:id', requireAdminAuth, async (req, res, next) => { try { const { id } = parseParams(idParamSchema, req) - ok(res, { data: await service.getCompany(id) }) + ok(res, await service.getCompany(id)) } catch (err) { next(err) } }) @@ -84,7 +84,7 @@ router.patch('/companies/:id', requireAdminAuth, requireAdminRole('SUPPORT'), as try { const { id } = parseParams(idParamSchema, req) const body = parseBody(adminCompanyUpdateSchema, req) - ok(res, { data: await service.updateCompany(id, body, req.admin.id, req.ip) }) + ok(res, await service.updateCompany(id, body, req.admin.id, req.ip)) } catch (err) { next(err) } }) @@ -92,7 +92,7 @@ router.patch('/companies/:id/status', requireAdminAuth, requireAdminRole('SUPPOR try { const { id } = parseParams(idParamSchema, req) const { status, reason } = parseBody(companyStatusSchema, req) - ok(res, { data: await service.setCompanyStatus(id, status, reason, req.admin.id, req.ip) }) + ok(res, await service.setCompanyStatus(id, status, reason, req.admin.id, req.ip)) } catch (err) { next(err) } }) @@ -100,14 +100,14 @@ router.delete('/companies/:id', requireAdminAuth, requireAdminRole('ADMIN'), asy try { const { id } = parseParams(idParamSchema, req) await service.deleteCompany(id, req.admin.id, req.ip) - ok(res, { data: { success: true } }) + ok(res, { success: true }) } catch (err) { next(err) } }) router.post('/companies/:id/impersonate', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => { try { const { id } = parseParams(idParamSchema, req) - ok(res, { data: await service.impersonateCompany(id, req.admin.id, req.ip) }) + ok(res, await service.impersonateCompany(id, req.admin.id, req.ip)) } catch (err) { next(err) } }) @@ -123,7 +123,7 @@ router.post('/renters/:id/block', requireAdminAuth, requireAdminRole('SUPPORT'), try { const { id } = parseParams(idParamSchema, req) await service.setRenterActive(id, false) - ok(res, { data: { success: true } }) + ok(res, { success: true }) } catch (err) { next(err) } }) @@ -131,7 +131,7 @@ router.post('/renters/:id/unblock', requireAdminAuth, requireAdminRole('SUPPORT' try { const { id } = parseParams(idParamSchema, req) await service.setRenterActive(id, true) - ok(res, { data: { success: true } }) + ok(res, { success: true }) } catch (err) { next(err) } }) @@ -139,7 +139,7 @@ router.post('/renters/:id/unblock', requireAdminAuth, requireAdminRole('SUPPORT' router.get('/metrics', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => { try { - ok(res, { data: await service.getPlatformMetrics() }) + ok(res, await service.getPlatformMetrics()) } catch (err) { next(err) } }) @@ -155,7 +155,7 @@ router.get('/audit-logs', requireAdminAuth, requireAdminRole('ADMIN'), async (re router.get('/admins', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), async (req, res, next) => { try { - ok(res, { data: await service.listAdmins() }) + ok(res, await service.listAdmins()) } catch (err) { next(err) } }) @@ -170,7 +170,7 @@ router.patch('/admins/:id/role', requireAdminAuth, requireAdminRole('SUPER_ADMIN const { id } = parseParams(idParamSchema, req) const { role } = parseBody(adminRoleSchema, req) await service.updateAdminRole(id, role) - ok(res, { data: { success: true } }) + ok(res, { success: true }) } catch (err) { next(err) } }) @@ -178,7 +178,7 @@ router.patch('/admins/:id/permissions', requireAdminAuth, requireAdminRole('SUPE try { const { id } = parseParams(idParamSchema, req) const { permissions } = parseBody(adminPermissionsSchema, req) - ok(res, { data: await service.updateAdminPermissions(id, permissions) }) + ok(res, await service.updateAdminPermissions(id, permissions)) } catch (err) { next(err) } }) @@ -212,14 +212,14 @@ router.get('/billing/invoices/:invoiceId/pdf', requireAdminAuth, requireAdminRol router.get('/site-config/marketplace-homepage', requireAdminAuth, async (req, res, next) => { try { - ok(res, { data: await service.getMarketplaceHomepage() }) + ok(res, await service.getMarketplaceHomepage()) } catch (err) { next(err) } }) router.patch('/site-config/marketplace-homepage', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => { try { const { homepage } = parseBody(homepageUpdateSchema, req) - ok(res, { data: await service.updateMarketplaceHomepage(homepage, req.admin.id, req.ip) }) + ok(res, await service.updateMarketplaceHomepage(homepage, req.admin.id, req.ip)) } catch (err) { next(err) } }) diff --git a/apps/api/src/modules/analytics/analytics.routes.ts b/apps/api/src/modules/analytics/analytics.routes.ts index 6e8fb6e..5872054 100644 --- a/apps/api/src/modules/analytics/analytics.routes.ts +++ b/apps/api/src/modules/analytics/analytics.routes.ts @@ -15,19 +15,19 @@ router.use(requireCompanyAuth, requireTenant, requireSubscription) router.get('/summary', async (req, res, next) => { try { const { period } = parseQuery(summaryQuerySchema, req) - ok(res, { data: await service.getSummary(req.companyId, period) }) + ok(res, await service.getSummary(req.companyId, period)) } catch (err) { next(err) } }) router.get('/dashboard', async (req, res, next) => { try { - ok(res, { data: await service.getDashboard(req.companyId) }) + ok(res, await service.getDashboard(req.companyId)) } catch (err) { next(err) } }) router.get('/sources', async (req, res, next) => { try { - ok(res, { data: await service.getSources(req.companyId) }) + ok(res, await service.getSources(req.companyId)) } catch (err) { next(err) } }) @@ -42,7 +42,7 @@ router.get('/report', requireRole('MANAGER'), async (req, res, next) => { res.setHeader('Content-Disposition', `attachment; filename="report-${start}-${end}.csv"`) return res.send(result.csv) } - ok(res, { data: result.report }) + ok(res, result.report) } catch (err) { next(err) } }) diff --git a/apps/api/src/modules/customers/customer.repo.ts b/apps/api/src/modules/customers/customer.repo.ts index a90bd6a..2553100 100644 --- a/apps/api/src/modules/customers/customer.repo.ts +++ b/apps/api/src/modules/customers/customer.repo.ts @@ -1,27 +1,91 @@ import { prisma } from '../../lib/prisma' +let customerLicenseImageColumnSupported: boolean | null = null + +async function hasCustomerLicenseImageColumn() { + if (customerLicenseImageColumnSupported !== null) { + return customerLicenseImageColumnSupported + } + + const rows = await prisma.$queryRawUnsafe>( + "select exists(select 1 from information_schema.columns where table_schema = 'public' and table_name = 'customers' and column_name = 'licenseImageUrl') as exists" + ) + customerLicenseImageColumnSupported = Boolean(rows[0]?.exists) + return customerLicenseImageColumnSupported +} + +function buildCustomerSelect(includeLicenseImageUrl: boolean) { + return { + id: true, + companyId: true, + renterId: true, + firstName: true, + lastName: true, + email: true, + phone: true, + driverLicense: true, + dateOfBirth: true, + nationality: true, + address: true, + notes: true, + flagged: true, + flagReason: true, + licenseExpiry: true, + licenseIssuedAt: true, + licenseCountry: true, + licenseNumber: true, + licenseCategory: true, + licenseExpired: true, + licenseExpiringSoon: true, + licenseValidationStatus: true, + licenseApprovedBy: true, + licenseApprovedAt: true, + licenseApprovalNote: true, + ...(includeLicenseImageUrl ? { licenseImageUrl: true } : {}), + createdAt: true, + updatedAt: true, + } +} + +async function getCustomerSelect() { + return buildCustomerSelect(await hasCustomerLicenseImageColumn()) +} + +async function sanitizeCustomerData(data: Record) { + if (await hasCustomerLicenseImageColumn()) { + return data + } + + const { licenseImageUrl, ...rest } = data + void licenseImageUrl + return rest as Record +} + export async function findMany(where: any, skip: number, take: number) { + const select = await getCustomerSelect() return Promise.all([ - prisma.customer.findMany({ where, skip, take, orderBy: { createdAt: 'desc' } }), + prisma.customer.findMany({ where, skip, take, orderBy: { createdAt: 'desc' }, select }), prisma.customer.count({ where }), ]) } export async function findById(id: string, companyId: string) { + const select = await getCustomerSelect() return prisma.customer.findFirst({ where: { id, companyId }, - include: { + select: { + ...select, reservations: { include: { vehicle: true }, orderBy: { createdAt: 'desc' }, take: 20 }, }, }) } export async function findByIdSimple(id: string, companyId: string) { - return prisma.customer.findFirst({ where: { id, companyId } }) + return prisma.customer.findFirst({ where: { id, companyId }, select: await getCustomerSelect() }) } export async function create(data: any) { - return prisma.customer.create({ data }) + return prisma.customer.create({ data: (await sanitizeCustomerData(data)) as any, select: await getCustomerSelect() }) } export async function updateMany(id: string, companyId: string, data: any) { @@ -29,9 +93,13 @@ export async function updateMany(id: string, companyId: string, data: any) { } export async function updateById(id: string, data: any) { - return prisma.customer.update({ where: { id }, data }) + return prisma.customer.update({ + where: { id }, + data: (await sanitizeCustomerData(data)) as any, + select: await getCustomerSelect(), + }) } export async function findUniqueOrThrow(id: string) { - return prisma.customer.findUniqueOrThrow({ where: { id } }) + return prisma.customer.findUniqueOrThrow({ where: { id }, select: await getCustomerSelect() }) } diff --git a/apps/api/src/modules/notifications/notification.routes.ts b/apps/api/src/modules/notifications/notification.routes.ts index 03444ed..b2f52f6 100644 --- a/apps/api/src/modules/notifications/notification.routes.ts +++ b/apps/api/src/modules/notifications/notification.routes.ts @@ -17,13 +17,13 @@ const companyAuth = [requireCompanyAuth, requireTenant, requireSubscription] as router.get('/company', ...companyAuth, async (req, res, next) => { try { const { unread } = parseQuery(unreadQuerySchema, req) - ok(res, { data: await service.listCompany(req.companyId, unread) }) + ok(res, await service.listCompany(req.companyId, unread)) } catch (err) { next(err) } }) router.get('/unread-count', ...companyAuth, async (req, res, next) => { try { - ok(res, { data: { unread: await service.countUnread(req.companyId) } }) + ok(res, { unread: await service.countUnread(req.companyId) }) } catch (err) { next(err) } }) @@ -31,20 +31,20 @@ router.post('/company/:id/read', ...companyAuth, async (req, res, next) => { try { const { id } = parseParams(idParamSchema, req) await service.markRead(id, req.companyId) - ok(res, { data: { success: true } }) + ok(res, { success: true }) } catch (err) { next(err) } }) router.post('/company/read-all', ...companyAuth, async (req, res, next) => { try { await service.markAllRead(req.companyId) - ok(res, { data: { success: true } }) + ok(res, { success: true }) } catch (err) { next(err) } }) router.get('/company/preferences', ...companyAuth, async (req, res, next) => { try { - ok(res, { data: await service.getPreferences(req.employee.id) }) + ok(res, await service.getPreferences(req.employee.id)) } catch (err) { next(err) } }) @@ -52,7 +52,7 @@ router.patch('/company/preferences', ...companyAuth, async (req, res, next) => { try { const prefs = parseBody(preferencesSchema, req) await service.setPreferences(req.employee.id, prefs) - ok(res, { data: { success: true } }) + ok(res, { success: true }) } catch (err) { next(err) } }) @@ -60,7 +60,7 @@ router.patch('/company/preferences', ...companyAuth, async (req, res, next) => { router.get('/renter', requireRenterAuth, async (req, res, next) => { try { - ok(res, { data: await service.listRenter(req.renterId) }) + ok(res, await service.listRenter(req.renterId)) } catch (err) { next(err) } }) @@ -68,20 +68,20 @@ router.post('/renter/:id/read', requireRenterAuth, async (req, res, next) => { try { const { id } = parseParams(idParamSchema, req) await service.markRenterRead(id, req.renterId) - ok(res, { data: { success: true } }) + ok(res, { success: true }) } catch (err) { next(err) } }) router.post('/renter/read-all', requireRenterAuth, async (req, res, next) => { try { await service.markAllRenterRead(req.renterId) - ok(res, { data: { success: true } }) + ok(res, { success: true }) } catch (err) { next(err) } }) router.get('/renter/preferences', requireRenterAuth, async (req, res, next) => { try { - ok(res, { data: await service.getRenterPreferences(req.renterId) }) + ok(res, await service.getRenterPreferences(req.renterId)) } catch (err) { next(err) } }) @@ -89,7 +89,7 @@ router.patch('/renter/preferences', requireRenterAuth, async (req, res, next) => try { const prefs = parseBody(preferencesSchema, req) await service.setRenterPreferences(req.renterId, prefs) - ok(res, { data: { success: true } }) + ok(res, { success: true }) } catch (err) { next(err) } }) diff --git a/apps/api/src/modules/offers/offer.routes.ts b/apps/api/src/modules/offers/offer.routes.ts index 598ff6f..abd6bf9 100644 --- a/apps/api/src/modules/offers/offer.routes.ts +++ b/apps/api/src/modules/offers/offer.routes.ts @@ -15,21 +15,21 @@ router.use(requireCompanyAuth, requireTenant, requireSubscription) router.get('/', async (req, res, next) => { try { const query = parseQuery(listQuerySchema, req) - ok(res, { data: await service.listOffers(req.companyId, query) }) + ok(res, await service.listOffers(req.companyId, query)) } catch (err) { next(err) } }) router.post('/', requireRole('MANAGER'), async (req, res, next) => { try { const { vehicleIds, ...body } = parseBody(offerSchema, req) - created(res, { data: await service.createOffer(req.companyId, body, vehicleIds) }) + created(res, await service.createOffer(req.companyId, body, vehicleIds)) } catch (err) { next(err) } }) router.get('/:id', async (req, res, next) => { try { const { id } = parseParams(idParamSchema, req) - ok(res, { data: await service.getOffer(id, req.companyId) }) + ok(res, await service.getOffer(id, req.companyId)) } catch (err) { next(err) } }) @@ -37,7 +37,7 @@ router.patch('/:id', requireRole('MANAGER'), async (req, res, next) => { try { const { id } = parseParams(idParamSchema, req) const { vehicleIds, ...body } = parseBody(offerSchema.partial(), req) - ok(res, { data: await service.updateOffer(id, req.companyId, body, vehicleIds) }) + ok(res, await service.updateOffer(id, req.companyId, body, vehicleIds)) } catch (err) { next(err) } }) @@ -45,7 +45,7 @@ router.delete('/:id', requireRole('MANAGER'), async (req, res, next) => { try { const { id } = parseParams(idParamSchema, req) await service.deleteOffer(id, req.companyId) - ok(res, { data: { success: true } }) + ok(res, { success: true }) } catch (err) { next(err) } }) @@ -53,7 +53,7 @@ router.post('/:id/activate', requireRole('MANAGER'), async (req, res, next) => { try { const { id } = parseParams(idParamSchema, req) await service.setOfferActive(id, req.companyId, true) - ok(res, { data: { success: true } }) + ok(res, { success: true }) } catch (err) { next(err) } }) @@ -61,14 +61,14 @@ router.post('/:id/deactivate', requireRole('MANAGER'), async (req, res, next) => try { const { id } = parseParams(idParamSchema, req) await service.setOfferActive(id, req.companyId, false) - ok(res, { data: { success: true } }) + ok(res, { success: true }) } catch (err) { next(err) } }) router.get('/:id/stats', async (req, res, next) => { try { const { id } = parseParams(idParamSchema, req) - ok(res, { data: await service.getOfferStats(id, req.companyId) }) + ok(res, await service.getOfferStats(id, req.companyId)) } catch (err) { next(err) } }) diff --git a/apps/api/src/modules/payments/payment.routes.ts b/apps/api/src/modules/payments/payment.routes.ts index 0b5df60..7faa14a 100644 --- a/apps/api/src/modules/payments/payment.routes.ts +++ b/apps/api/src/modules/payments/payment.routes.ts @@ -42,14 +42,14 @@ router.use(requireCompanyAuth, requireTenant, requireSubscription) router.get('/company', async (req, res, next) => { try { - ok(res, { data: await service.listByCompany(req.companyId) }) + ok(res, await service.listByCompany(req.companyId)) } catch (err) { next(err) } }) router.get('/reservations/:id', async (req, res, next) => { try { const { id } = parseParams(reservationParamSchema, req) - ok(res, { data: await service.listByReservation(id, req.companyId) }) + ok(res, await service.listByReservation(id, req.companyId)) } catch (err) { next(err) } }) @@ -57,7 +57,7 @@ router.post('/reservations/:id/charge', requireRole('MANAGER'), async (req, res, try { const { id } = parseParams(reservationParamSchema, req) const body = parseBody(chargeSchema, req) - ok(res, { data: await service.initCharge(id, req.companyId, body) }) + ok(res, await service.initCharge(id, req.companyId, body)) } catch (err) { next(err) } }) @@ -65,7 +65,7 @@ router.post('/reservations/:id/capture-paypal', requireRole('MANAGER'), async (r try { const { id } = parseParams(reservationParamSchema, req) const { paypalOrderId } = parseBody(capturePaypalSchema, req) - ok(res, { data: await service.capturePaypal(id, req.companyId, paypalOrderId) }) + ok(res, await service.capturePaypal(id, req.companyId, paypalOrderId)) } catch (err) { next(err) } }) @@ -73,7 +73,7 @@ router.post('/reservations/:id/manual', requireRole('MANAGER'), async (req, res, try { const { id } = parseParams(reservationParamSchema, req) const body = parseBody(manualPaymentSchema, req) - ok(res, { data: await service.recordManualPayment(id, req.companyId, body) }) + ok(res, await service.recordManualPayment(id, req.companyId, body)) } catch (err) { next(err) } }) @@ -81,7 +81,7 @@ router.post('/reservations/:reservationId/payments/:paymentId/refund', requireRo try { const { reservationId, paymentId } = parseParams(paymentParamSchema, req) const { amount, reason } = parseBody(refundSchema, req) - ok(res, { data: await service.refundPayment(reservationId, paymentId, req.companyId, amount, reason) }) + ok(res, await service.refundPayment(reservationId, paymentId, req.companyId, amount, reason)) } catch (err) { next(err) } }) diff --git a/apps/api/src/modules/subscriptions/subscription.routes.ts b/apps/api/src/modules/subscriptions/subscription.routes.ts index ecb43f9..a8c00e6 100644 --- a/apps/api/src/modules/subscriptions/subscription.routes.ts +++ b/apps/api/src/modules/subscriptions/subscription.routes.ts @@ -14,11 +14,11 @@ const router = Router() // ─── Public ──────────────────────────────────────────────────── router.get('/plans', (_req, res) => { - ok(res, { data: service.getPlans() }) + ok(res, service.getPlans()) }) router.get('/providers', (_req, res) => { - ok(res, { data: service.getProviders() }) + ok(res, service.getProviders()) }) // ─── Webhooks (no auth) ──────────────────────────────────────── @@ -50,7 +50,7 @@ router.post('/webhooks/paypal', async (req, res, next) => { router.post('/capture-paypal', requireCompanyAuth, requireTenant, async (req, res, next) => { try { const { paypalOrderId } = parseBody(capturePaypalSchema, req) - ok(res, { data: await service.capturePaypal(req.companyId, paypalOrderId) }) + ok(res, await service.capturePaypal(req.companyId, paypalOrderId)) } catch (err) { next(err) } }) @@ -60,39 +60,39 @@ router.use(requireCompanyAuth, requireTenant) router.get('/me', async (req, res, next) => { try { - ok(res, { data: await service.getSubscription(req.companyId) }) + ok(res, await service.getSubscription(req.companyId)) } catch (err) { next(err) } }) router.get('/invoices', async (req, res, next) => { try { - ok(res, { data: await service.getInvoices(req.companyId) }) + ok(res, await service.getInvoices(req.companyId)) } catch (err) { next(err) } }) router.post('/checkout', requireRole('OWNER'), async (req, res, next) => { try { const body = parseBody(checkoutSchema, req) - ok(res, { data: await service.checkout(req.companyId, body) }) + ok(res, await service.checkout(req.companyId, body)) } catch (err) { next(err) } }) router.post('/change-plan', requireRole('OWNER'), async (req, res, next) => { try { const body = parseBody(changePlanSchema, req) - ok(res, { data: await service.changePlan(req.companyId, body) }) + ok(res, await service.changePlan(req.companyId, body)) } catch (err) { next(err) } }) router.post('/cancel', requireRole('OWNER'), async (req, res, next) => { try { - ok(res, { data: await service.cancel(req.companyId) }) + ok(res, await service.cancel(req.companyId)) } catch (err) { next(err) } }) router.post('/resume', requireRole('OWNER'), async (req, res, next) => { try { - ok(res, { data: await service.resume(req.companyId) }) + ok(res, await service.resume(req.companyId)) } catch (err) { next(err) } }) diff --git a/apps/api/src/modules/team/team.routes.ts b/apps/api/src/modules/team/team.routes.ts index 1751341..9aa4b33 100644 --- a/apps/api/src/modules/team/team.routes.ts +++ b/apps/api/src/modules/team/team.routes.ts @@ -14,7 +14,7 @@ router.use(requireCompanyAuth, requireTenant, requireSubscription) router.get('/', async (req, res, next) => { try { - ok(res, { data: await service.getMembers(req.companyId) }) + ok(res, await service.getMembers(req.companyId)) } catch (err) { next(err) } }) @@ -35,28 +35,28 @@ router.patch('/:id/role', requireRole('OWNER'), async (req, res, next) => { try { const { id } = parseParams(idParamSchema, req) const { role } = parseBody(roleSchema, req) - ok(res, { data: await service.updateEmployeeRole(req.companyId, req.employee.id, req.employee.role, id, { role }) }) + ok(res, await service.updateEmployeeRole(req.companyId, req.employee.id, req.employee.role, id, { role })) } catch (err) { next(err) } }) router.post('/:id/deactivate', requireRole('OWNER'), async (req, res, next) => { try { const { id } = parseParams(idParamSchema, req) - ok(res, { data: await service.deactivateEmployee(req.companyId, req.employee.role, id) }) + ok(res, await service.deactivateEmployee(req.companyId, req.employee.role, id)) } catch (err) { next(err) } }) router.post('/:id/reactivate', requireRole('OWNER'), async (req, res, next) => { try { const { id } = parseParams(idParamSchema, req) - ok(res, { data: await service.reactivateEmployee(req.companyId, req.employee.role, id) }) + ok(res, await service.reactivateEmployee(req.companyId, req.employee.role, id)) } catch (err) { next(err) } }) router.delete('/:id', requireRole('OWNER'), async (req, res, next) => { try { const { id } = parseParams(idParamSchema, req) - ok(res, { data: await service.removeEmployee(req.companyId, req.employee.role, id) }) + ok(res, await service.removeEmployee(req.companyId, req.employee.role, id)) } catch (err) { next(err) } }) diff --git a/apps/dashboard/next.config.js b/apps/dashboard/next.config.js index 6ea4e37..d4f4415 100644 --- a/apps/dashboard/next.config.js +++ b/apps/dashboard/next.config.js @@ -23,8 +23,14 @@ const storagePatterns = [ .filter(Boolean) .filter((p, i, arr) => arr.findIndex((q) => q.hostname === p.hostname && q.port === p.port) === i) +// When the dashboard is accessed via the marketplace proxy (localhost:3000/dashboard), +// the browser resolves _next/static chunk URLs relative to localhost:3000 — but those +// chunks only exist on the dashboard's own server (localhost:3001). DASHBOARD_ASSET_PREFIX +// makes Next.js embed absolute chunk URLs so the browser fetches them from the right port. +// Set in .env.docker.dev; unset for local/production (where no cross-port proxy is used). const nextConfig = { basePath: '/dashboard', + ...(process.env.DASHBOARD_ASSET_PREFIX ? { assetPrefix: process.env.DASHBOARD_ASSET_PREFIX } : {}), images: { remotePatterns: [ { diff --git a/apps/dashboard/src/app/(dashboard)/dashboard/offers/page.tsx b/apps/dashboard/src/app/(dashboard)/dashboard/offers/page.tsx index 7b27bf1..b4887c0 100644 --- a/apps/dashboard/src/app/(dashboard)/dashboard/offers/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/dashboard/offers/page.tsx @@ -324,7 +324,10 @@ export default function OffersPage() { function load() { apiFetch('/offers') - .then((data) => setOffers(data ?? [])) + .then((data) => { + setOffers(Array.isArray(data) ? data : []) + setError(null) + }) .catch((err) => setError(err.message)) } @@ -334,7 +337,7 @@ export default function OffersPage() { setLoadingFleet(true) try { const data = await apiFetch('/vehicles?pageSize=200') - setFleet(data ?? []) + setFleet(Array.isArray(data) ? data : []) } catch { // non-fatal; vehicle picker just stays empty } finally { @@ -363,7 +366,7 @@ export default function OffersPage() { const detail = await apiFetch(`/offers/${offer.id}`) setForm((prev) => ({ ...prev, - vehicleIds: detail.vehicles?.map((v) => v.vehicleId) ?? [], + vehicleIds: Array.isArray(detail?.vehicles) ? detail.vehicles.map((v) => v.vehicleId) : [], })) } catch { // leave vehicleIds as empty if fetch fails diff --git a/apps/dashboard/src/app/sign-in/[[...sign-in]]/SignInPageClient.tsx b/apps/dashboard/src/app/sign-in/[[...sign-in]]/SignInPageClient.tsx index 5b6c2ae..e62f6f3 100644 --- a/apps/dashboard/src/app/sign-in/[[...sign-in]]/SignInPageClient.tsx +++ b/apps/dashboard/src/app/sign-in/[[...sign-in]]/SignInPageClient.tsx @@ -117,11 +117,19 @@ export default function SignInPageClient({ embedded = false }: { embedded?: bool changed = true } + // useSearchParams() can return empty params during hydration without a Suspense boundary. + // Always preserve embedded=1 when the component was server-rendered as embedded so + // the router.replace doesn't strip it and trigger an unintended redirect. + if (embedded && params.get('embedded') !== '1') { + params.set('embedded', '1') + changed = true + } + if (!changed) return const nextQuery = params.toString() router.replace(nextQuery ? `${pathname}?${nextQuery}` : pathname, { scroll: false }) - }, [language, pathname, router, searchParams, theme]) + }, [embedded, language, pathname, router, searchParams, theme]) useEffect(() => { const currentPath = window.location.pathname + (window.location.search || '') diff --git a/apps/dashboard/src/app/sign-in/[[...sign-in]]/page.tsx b/apps/dashboard/src/app/sign-in/[[...sign-in]]/page.tsx index a20ab14..78da4e8 100644 --- a/apps/dashboard/src/app/sign-in/[[...sign-in]]/page.tsx +++ b/apps/dashboard/src/app/sign-in/[[...sign-in]]/page.tsx @@ -1,7 +1,16 @@ +import { Suspense } from 'react' import { headers } from 'next/headers' import { redirect } from 'next/navigation' import SignInPageClient from './SignInPageClient' +const MARKETPLACE_URL = process.env.NEXT_PUBLIC_MARKETPLACE_URL ?? 'http://localhost:3000' + +function isInternalHost(host: string | null): boolean { + if (!host) return false + const hostname = host.split(':')[0]?.toLowerCase() + return ['host.docker.internal', 'dashboard', 'admin', 'api', 'marketplace'].includes(hostname) +} + export default async function SignInPage({ searchParams, }: { @@ -13,6 +22,7 @@ export default async function SignInPage({ if (!embedded) { const requestHeaders = headers() const host = requestHeaders.get('host') + const forwardedHost = requestHeaders.get('x-forwarded-host') const proto = requestHeaders.get('x-forwarded-proto') ?? 'http' const query = new URLSearchParams() @@ -25,12 +35,22 @@ export default async function SignInPage({ } } - const destination = host - ? `${proto}://${host}/sign-in${query.size ? `?${query.toString()}` : ''}` - : `${process.env.NEXT_PUBLIC_MARKETPLACE_URL ?? 'http://localhost:3000'}/sign-in${query.size ? `?${query.toString()}` : ''}` + const marketplaceOrigin = new URL(MARKETPLACE_URL) + const publicHost = forwardedHost && !isInternalHost(forwardedHost) + ? forwardedHost + : host && !isInternalHost(host) + ? host + : null - redirect(destination) + if (!publicHost || publicHost === marketplaceOrigin.host) { + const destination = `${MARKETPLACE_URL}/sign-in${query.size ? `?${query.toString()}` : ''}` + redirect(destination) + } } - return + return ( + + + + ) } diff --git a/apps/dashboard/src/components/layout/PublicFooter.tsx b/apps/dashboard/src/components/layout/PublicFooter.tsx index 2440529..4cf7e83 100644 --- a/apps/dashboard/src/components/layout/PublicFooter.tsx +++ b/apps/dashboard/src/components/layout/PublicFooter.tsx @@ -14,6 +14,12 @@ type FooterItem = { href?: string } +const appPrivacyHref = { + en: `${marketplaceUrl}/app-privacy-en`, + fr: `${marketplaceUrl}/app-privacy-fr`, + ar: `${marketplaceUrl}/app-privacy-ar`, +} as const + const localeOptions: Array<{ value: DashboardLanguage; label: string; flag: string }> = [ { value: 'en', label: 'Global (English)', flag: '🇺🇸' }, { value: 'ar', label: 'North Africa (Arabic)', flag: '🇲🇦' }, @@ -34,13 +40,13 @@ function getFooterContent(language: DashboardLanguage): { { label: 'Conditions d’utilisation', href: `${marketplaceUrl}/footer/terms-of-service` }, { label: 'Sécurité', href: `${marketplaceUrl}/footer/security` }, { label: 'Conformité', href: `${marketplaceUrl}/footer/compliance` }, - { label: 'Politique de confidentialité', href: `${marketplaceUrl}/footer/privacy-policy` }, + { label: 'Politique de confidentialité', href: appPrivacyHref.fr }, { label: 'Politique relative aux cookies', href: `${marketplaceUrl}/footer/cookie-policy` }, ], secondary: [ { label: 'Newsletter', href: `${marketplaceUrl}/footer/newsletter` }, { label: 'Contacter les ventes', href: `${marketplaceUrl}/footer/contact-sales` }, - { label: 'Nos bureaux', href: `${marketplaceUrl}/footer/our-offices` }, + { label: 'Conditions générales', href: `${marketplaceUrl}/footer/general-conditions` }, ], localeLabel: 'Europe (French)', rightsLabel: 'Tous droits réservés.', @@ -52,13 +58,13 @@ function getFooterContent(language: DashboardLanguage): { { label: 'شروط الاستخدام', href: `${marketplaceUrl}/footer/terms-of-service` }, { label: 'الأمان', href: `${marketplaceUrl}/footer/security` }, { label: 'الامتثال', href: `${marketplaceUrl}/footer/compliance` }, - { label: 'سياسة الخصوصية', href: `${marketplaceUrl}/footer/privacy-policy` }, + { label: 'سياسة الخصوصية', href: appPrivacyHref.ar }, { label: 'سياسة ملفات تعريف الارتباط', href: `${marketplaceUrl}/footer/cookie-policy` }, ], secondary: [ { label: 'النشرة الإخبارية', href: `${marketplaceUrl}/footer/newsletter` }, { label: 'تواصل مع المبيعات', href: `${marketplaceUrl}/footer/contact-sales` }, - { label: 'مكاتبنا', href: `${marketplaceUrl}/footer/our-offices` }, + { label: 'الشروط العامة', href: `${marketplaceUrl}/footer/general-conditions` }, ], localeLabel: 'North Africa (Arabic)', rightsLabel: 'جميع الحقوق محفوظة.', @@ -71,13 +77,13 @@ function getFooterContent(language: DashboardLanguage): { { label: 'Terms of Service', href: `${marketplaceUrl}/footer/terms-of-service` }, { label: 'Security', href: `${marketplaceUrl}/footer/security` }, { label: 'Compliance', href: `${marketplaceUrl}/footer/compliance` }, - { label: 'Privacy Policy', href: `${marketplaceUrl}/footer/privacy-policy` }, + { label: 'Privacy Policy', href: appPrivacyHref.en }, { label: 'Cookie Policy', href: `${marketplaceUrl}/footer/cookie-policy` }, ], secondary: [ { label: 'Newsletter', href: `${marketplaceUrl}/footer/newsletter` }, { label: 'Contact Sales', href: `${marketplaceUrl}/footer/contact-sales` }, - { label: 'Our Offices', href: `${marketplaceUrl}/footer/our-offices` }, + { label: 'General Conditions', href: `${marketplaceUrl}/footer/general-conditions` }, ], localeLabel: 'Global (English)', rightsLabel: 'All rights reserved.', diff --git a/apps/dashboard/src/middleware.ts b/apps/dashboard/src/middleware.ts index c0034cb..d6e63df 100644 --- a/apps/dashboard/src/middleware.ts +++ b/apps/dashboard/src/middleware.ts @@ -1,18 +1,41 @@ import { NextResponse } from 'next/server' import type { NextRequest } from 'next/server' +const PUBLIC_DASHBOARD_PREFIXES = ['/dashboard/sign-in', '/dashboard/forgot-password'] +const MARKETPLACE_URL = process.env.NEXT_PUBLIC_MARKETPLACE_URL ?? 'http://localhost:3000' + +function isInternalHost(host: string | null): boolean { + if (!host) return false + const hostname = host.split(':')[0]?.toLowerCase() + return ['host.docker.internal', 'dashboard', 'admin', 'api', 'marketplace'].includes(hostname) +} + function isProtectedRoute(req: NextRequest) { - return req.nextUrl.pathname === '/dashboard' || req.nextUrl.pathname.startsWith('/dashboard/') + const { pathname } = req.nextUrl + if (PUBLIC_DASHBOARD_PREFIXES.some((p) => pathname === p || pathname.startsWith(p + '/'))) return false + return pathname === '/dashboard' || pathname.startsWith('/dashboard/') } function localJwtMiddleware(req: NextRequest): NextResponse { if (!isProtectedRoute(req)) return NextResponse.next() - // Check for employee_token cookie (set on login) OR allow if coming from sign-in const token = req.cookies.get('employee_token')?.value if (!token) { + const forwardedHost = req.headers.get('x-forwarded-host') + const forwardedProto = req.headers.get('x-forwarded-proto') + const marketplaceOrigin = new URL(MARKETPLACE_URL) + const signInUrl = req.nextUrl.clone() - signInUrl.pathname = '/sign-in' + if (forwardedHost && !isInternalHost(forwardedHost)) { + signInUrl.host = forwardedHost + signInUrl.protocol = (forwardedProto ?? 'http') + ':' + } else if (isInternalHost(signInUrl.host)) { + signInUrl.host = marketplaceOrigin.host + signInUrl.protocol = marketplaceOrigin.protocol + } + + const isMarketplaceHost = signInUrl.host === marketplaceOrigin.host + signInUrl.pathname = isMarketplaceHost ? '/sign-in' : '/dashboard/sign-in' signInUrl.searchParams.set('redirect', req.nextUrl.pathname) return NextResponse.redirect(signInUrl) } diff --git a/apps/marketplace/src/app/(public)/app-privacy-ar/page.tsx b/apps/marketplace/src/app/(public)/app-privacy-ar/page.tsx new file mode 100644 index 0000000..40784fe --- /dev/null +++ b/apps/marketplace/src/app/(public)/app-privacy-ar/page.tsx @@ -0,0 +1,5 @@ +import FooterContentPage from '@/components/FooterContentPage' + +export default function AppPrivacyArPage() { + return +} diff --git a/apps/marketplace/src/app/(public)/app-privacy-en/page.tsx b/apps/marketplace/src/app/(public)/app-privacy-en/page.tsx new file mode 100644 index 0000000..11a1dc8 --- /dev/null +++ b/apps/marketplace/src/app/(public)/app-privacy-en/page.tsx @@ -0,0 +1,5 @@ +import FooterContentPage from '@/components/FooterContentPage' + +export default function AppPrivacyEnPage() { + return +} diff --git a/apps/marketplace/src/app/(public)/app-privacy-fr/page.tsx b/apps/marketplace/src/app/(public)/app-privacy-fr/page.tsx new file mode 100644 index 0000000..081bb9d --- /dev/null +++ b/apps/marketplace/src/app/(public)/app-privacy-fr/page.tsx @@ -0,0 +1,5 @@ +import FooterContentPage from '@/components/FooterContentPage' + +export default function AppPrivacyFrPage() { + return +} diff --git a/apps/marketplace/src/app/(public)/app-tc-ar/page.tsx b/apps/marketplace/src/app/(public)/app-tc-ar/page.tsx new file mode 100644 index 0000000..13d1ea4 --- /dev/null +++ b/apps/marketplace/src/app/(public)/app-tc-ar/page.tsx @@ -0,0 +1,5 @@ +import FooterContentPage from '@/components/FooterContentPage' + +export default function AppTermsArPage() { + return +} diff --git a/apps/marketplace/src/app/(public)/app-tc-en/page.tsx b/apps/marketplace/src/app/(public)/app-tc-en/page.tsx new file mode 100644 index 0000000..2546807 --- /dev/null +++ b/apps/marketplace/src/app/(public)/app-tc-en/page.tsx @@ -0,0 +1,5 @@ +import FooterContentPage from '@/components/FooterContentPage' + +export default function AppTermsEnPage() { + return +} diff --git a/apps/marketplace/src/app/(public)/app-tc-fr/page.tsx b/apps/marketplace/src/app/(public)/app-tc-fr/page.tsx new file mode 100644 index 0000000..73cc5b2 --- /dev/null +++ b/apps/marketplace/src/app/(public)/app-tc-fr/page.tsx @@ -0,0 +1,5 @@ +import FooterContentPage from '@/components/FooterContentPage' + +export default function AppTermsFrPage() { + return +} diff --git a/apps/marketplace/src/components/FooterContentPage.tsx b/apps/marketplace/src/components/FooterContentPage.tsx index ac2d62a..8c3d220 100644 --- a/apps/marketplace/src/components/FooterContentPage.tsx +++ b/apps/marketplace/src/components/FooterContentPage.tsx @@ -2,6 +2,7 @@ import { useMarketplacePreferences } from '@/components/MarketplaceShell' import { type FooterPageSlug, getFooterPageContent } from '@/lib/footerContent' +import { type MarketplaceLanguage } from '@/lib/i18n' const pageMeta = { en: { @@ -21,11 +22,41 @@ const pageMeta = { }, } as const -export default function FooterContentPage({ slug }: { slug: FooterPageSlug }) { +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 ( + + + {href} + + {trailing} + + ) + } + + urlPattern.lastIndex = 0 + return {part} + }) +} + +export default function FooterContentPage({ slug, forcedLanguage }: { slug: FooterPageSlug; forcedLanguage?: MarketplaceLanguage }) { const { language } = useMarketplacePreferences() - const content = getFooterPageContent(language, slug) - const meta = pageMeta[language] - const isArabic = language === 'ar' + const activeLanguage = forcedLanguage ?? language + const content = getFooterPageContent(activeLanguage, slug) + const meta = pageMeta[activeLanguage] + const isArabic = activeLanguage === 'ar' return (
@@ -43,10 +74,23 @@ export default function FooterContentPage({ slug }: { slug: FooterPageSlug }) {
{content.paragraphs.map((paragraph) => (

- {paragraph} + {renderParagraphText(paragraph)}

))} + {content.sections?.map((section) => ( +
+

+ {section.heading} +

+ {section.paragraphs.map((paragraph) => ( +

+ {renderParagraphText(paragraph)} +

+ ))} +
+ ))} +

{meta.cta} diff --git a/apps/marketplace/src/components/MarketplaceShell.tsx b/apps/marketplace/src/components/MarketplaceShell.tsx index 516d3d3..fb36f5b 100644 --- a/apps/marketplace/src/components/MarketplaceShell.tsx +++ b/apps/marketplace/src/components/MarketplaceShell.tsx @@ -2,7 +2,7 @@ import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react' import { useRouter } from 'next/navigation' -import { footerPageHref } from '@/lib/footerContent' +import { appPrivacyHref, footerPageHref } from '@/lib/footerContent' import { MARKETPLACE_LANGUAGE_COOKIE, SHARED_LANGUAGE_COOKIE, isMarketplaceLanguage, type MarketplaceLanguage } from '@/lib/i18n' import { SHARED_LANGUAGE_KEY, SHARED_THEME_KEY, readCurrentUserScopedPreference, readScopedPreference, writeScopedPreference } from '@/lib/preferences' @@ -76,13 +76,13 @@ export function getFooterContent(language: MarketplaceLanguage): { { 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: footerPageHref['privacy-policy'] }, + { 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: 'Nos bureaux', href: footerPageHref['our-offices'] }, + { label: 'Conditions générales', href: footerPageHref['general-conditions'] }, ], localeLabel: 'Europe (French)', rightsLabel: 'Tous droits réservés.', @@ -94,13 +94,13 @@ export function getFooterContent(language: MarketplaceLanguage): { { label: 'شروط الاستخدام', href: footerPageHref['terms-of-service'] }, { label: 'الأمان', href: footerPageHref.security }, { label: 'الامتثال', href: footerPageHref.compliance }, - { label: 'سياسة الخصوصية', href: footerPageHref['privacy-policy'] }, + { label: 'سياسة الخصوصية', href: appPrivacyHref.ar }, { label: 'سياسة ملفات تعريف الارتباط', href: footerPageHref['cookie-policy'] }, ], secondary: [ { label: 'النشرة الإخبارية', href: footerPageHref.newsletter }, { label: 'تواصل مع المبيعات', href: footerPageHref['contact-sales'] }, - { label: 'مكاتبنا', href: footerPageHref['our-offices'] }, + { label: 'الشروط العامة', href: footerPageHref['general-conditions'] }, ], localeLabel: 'North Africa (Arabic)', rightsLabel: 'جميع الحقوق محفوظة.', @@ -113,13 +113,13 @@ export function getFooterContent(language: MarketplaceLanguage): { { label: 'Terms of Service', href: footerPageHref['terms-of-service'] }, { label: 'Security', href: footerPageHref.security }, { label: 'Compliance', href: footerPageHref.compliance }, - { label: 'Privacy Policy', href: footerPageHref['privacy-policy'] }, + { 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: 'Our Offices', href: footerPageHref['our-offices'] }, + { label: 'General Conditions', href: footerPageHref['general-conditions'] }, ], localeLabel: 'Global (English)', rightsLabel: 'All rights reserved.', diff --git a/apps/marketplace/src/lib/footerContent.ts b/apps/marketplace/src/lib/footerContent.ts index c19ba07..b3698a3 100644 --- a/apps/marketplace/src/lib/footerContent.ts +++ b/apps/marketplace/src/lib/footerContent.ts @@ -9,14 +9,30 @@ export const footerPageSlugs = [ 'cookie-policy', 'newsletter', 'contact-sales', - 'our-offices', + 'general-conditions', ] as const export type FooterPageSlug = (typeof footerPageSlugs)[number] +export const appPrivacyHref = { + en: '/app-privacy-en', + fr: '/app-privacy-fr', + ar: '/app-privacy-ar', +} as const + +export const appTermsHref = { + en: '/app-tc-en', + fr: '/app-tc-fr', + ar: '/app-tc-ar', +} as const + export type FooterPageContent = { title: string paragraphs: string[] + sections?: Array<{ + heading: string + paragraphs: string[] + }> } export const footerPageHref: Record = { @@ -28,7 +44,7 @@ export const footerPageHref: Record = { 'cookie-policy': '/footer/cookie-policy', newsletter: '/footer/newsletter', 'contact-sales': '/footer/contact-sales', - 'our-offices': '/footer/our-offices', + 'general-conditions': '/footer/general-conditions', } const footerContent: Record> = { @@ -63,15 +79,112 @@ const footerContent: Record/dev/null 2>&1; then + echo "Creating Docker volume: ${API_UPLOADS_VOLUME}" + docker volume create "${API_UPLOADS_VOLUME}" >/dev/null + fi +} + prod_compose() { docker compose -p "${PROJECT_NAME}" --env-file "${ENV_FILE}" -f "${PROD_COMPOSE_FILE}" "$@" } @@ -41,6 +49,7 @@ traefik_compose() { start_prod_services() { ensure_env_file + ensure_api_uploads_volume prod_compose up --build -d "$@" }