diff --git a/.env.example b/.env.example index 19dee58..1c79749 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,9 @@ PLATFORM_BASE_URL="http://localhost:3000" PORTAL_BASE_URL="http://localhost:3000" DEFAULT_COMPANY_SLUG="acme-realty" ESTATEOS_ENABLE_DEV_BYPASS="false" +# CSP escape hatch: "true" ships Content-Security-Policy-Report-Only instead of +# the enforced header (see src/lib/security/csp.ts). Leave false in production. +ESTATEOS_CSP_REPORT_ONLY="false" # Safety guard. Set these to your live production database's Supabase project # ref and pooler host. When a non-production runtime's DATABASE_URL/DIRECT_URL # points at this database, dev bypass is force-disabled and demo/seed writes diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..509fa24 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,53 @@ +# Continuous integration — runs the same gate as `npm run check` on every PR +# and every push to main: tests, typecheck, lint, and a production build. +# +# Notes: +# - The build intentionally runs WITHOUT secrets: scripts/run-build.mjs is +# designed so builds never require database access, and src/lib/config.ts +# treats all integration env vars as optional (features degrade gracefully). +# Database migrations are a separate, controlled release step +# (`npm run db:migrate:deploy`), never part of CI. +# - `npm run encoding:check` is NOT included: it currently fails on many +# pre-existing files (em dashes etc.). Add it here after a cleanup pass. +name: CI + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + name: Test, typecheck, lint, build + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + # postinstall runs `prisma generate`, so the client exists for tests + # and the typecheck without any database connection. + - name: Install dependencies + run: npm ci + + - name: Tests + run: npm run test + + - name: Typecheck + run: npm run typecheck + + - name: Lint + run: npm run lint + + - name: Build + run: npm run build diff --git a/COMMIT-PROMPT.md b/COMMIT-PROMPT.md deleted file mode 100644 index 991ce38..0000000 --- a/COMMIT-PROMPT.md +++ /dev/null @@ -1,47 +0,0 @@ -# Prompt for Claude Code — commit & push all updates - -Copy everything in the fenced block below and paste it into Claude Code, run from the -repository root (`C:\Users\HP\Desktop\Realestate saas`). - -``` -Commit and push all current work in this repository. Follow these steps carefully and stop if any gate fails. - -CONTEXT -- Branch should be `feat/design-system-v2`, tracking `origin/feat/design-system-v2`. -- There is a large amount of uncommitted work (~120 modified files, ~70 new files), including: - - Prisma migrations 0042–0047 (site-content CMS, team staff-code unique, front-desk visitor/call log, property invoices, in-app messaging, announcements). - - New features: in-app messaging, announcements/broadcast banner, property invoices, front-desk logbook + dashboard, CEO executive overview, finance board, reservations board, site-content CMS. - - Design-system v2: tokens, dark mode, page transitions, command palette, loading/empty primitives, scroll-aware header, marketing/tenant redesigns. - - WhatsApp click-to-chat: a shared `WhatsAppButton` wired into clients, front-desk visitor log, leads/inquiries, inspections, schedule, team, transactions, and payment requests. Two of these (transactions, payment requests) also plumb a `buyerPhone` field through the queries in `src/modules/admin/operations.ts` and `src/modules/admin/control-center.ts`. - -STEPS -1. Run `git status` and `git rev-parse --abbrev-ref HEAD`. Confirm the branch is `feat/design-system-v2`. If it is not, stop and tell me before doing anything else. -2. Make sure no stray build artifacts are staged or present: `tc.out`, `tc_result.log`, `tsconfig.scope.json`, or any files matching `_wa_*`, `_cav*`, `_orig*`, `*_test.tsx` scratch files. If any exist, delete them. (`RECAP.md` is intentional — keep it.) -3. Run the full verification gate: `npm run check`. This runs test + typecheck + lint + build. Do NOT commit if it fails — instead, show me the failing output and stop. -4. If the check passes, stage everything: `git add -A`. -5. Show me `git status` and `git diff --cached --stat` so I can see exactly what will be committed. -6. Commit with this message (a single commit is fine): - - feat: WhatsApp click-to-chat across operator surfaces + session feature set - - - Add shared WhatsAppButton (wa.me click-to-chat, no wallet/credentials) and wire it into - clients, front-desk visitor log, leads/inquiries, inspections, schedule, team, - transactions, and payment requests. Plumb buyerPhone through transactions and - payment-request queries (company-guarded). - - In-app messaging, announcements/broadcast banner, property invoices, front-desk - logbook + dashboard, CEO executive overview, finance board, reservations board, - site-content CMS (migrations 0042–0047). - - Design-system v2: tokens, dark mode, page transitions, command palette, loading/empty - primitives, scroll-aware header, marketing + tenant redesigns. - -7. Push: `git push origin feat/design-system-v2`. If the branch has no upstream yet, use `git push -u origin feat/design-system-v2`. -8. Report the final commit hash and confirm the push succeeded. - -Do not open a pull request unless I ask. Do not rebase or force-push. -``` - -## Notes -- The database-touching tests only pass on your Windows machine (Prisma Client is generated - for Windows). That's why `npm run check` is the right gate to run there. -- Migrations 0046 and 0047 were already applied per the session recap; committing them just - records the migration files in git. diff --git a/DEPLOYMENT-CHECKLIST.md b/DEPLOYMENT-CHECKLIST.md new file mode 100644 index 0000000..da65a06 --- /dev/null +++ b/DEPLOYMENT-CHECKLIST.md @@ -0,0 +1,102 @@ +# Deployment Checklist — Modernization batch (2026-07-06) + +Covers everything shipped in this session series: enforced CSP, grouped nav, +UI primitives + DataTable migrations, Select/Dialog unification, system-aware +dark mode, PWA, CI, next/image for R2 media, conditional-polling realtime, +webhook idempotency hardening, buyer bottom tab bar — plus the pre-existing +Users tab + MARKETER role batch that ships with it. + +--- + +## 1 · Ship the code + +- [ ] `npm run check` green locally (already confirmed). +- [ ] Commit & push via `COMMIT-PROMPT.md` (Claude Code). +- [ ] Open PR `feat/design-system-v2` → `main`. The new GitHub Actions CI + (`.github/workflows/ci.yml`) runs the same gate on the PR — its first + ever run, so watch it. If the env-less CI build fails on a missing + variable, fix the default in `src/lib/config.ts`, not with CI secrets. + +## 2 · Pre-deploy environment (Vercel) + +- [ ] `R2_PUBLIC_BASE_URL` is present in the **build** environment (not just + runtime) — the image-optimizer host allowlist is baked at build time. +- [ ] `UPSTASH_REDIS_REST_URL` / `UPSTASH_REDIS_REST_TOKEN` present + (conditional polling + rate limits). +- [ ] `ESTATEOS_CSP_REPORT_ONLY` is **unset or "false"** — CSP enforces. +- [ ] Paystack/Clerk/R2/Resend secrets unchanged — nothing in this batch + rotates credentials. + +## 3 · Database migrations (before deploying app code) + +Run against production per the runbook (`PROD-MIGRATION-RECONCILIATION.md`): + +- [ ] `npm run db:migrate:deploy` applies: + - `0048_marketer_role` (AppRole MARKETER — already applied to dev) + - `20260701000111_…` (reconciliation placeholder) + - `0049_webhook_event_dedup_unique` — deletes historical duplicate + webhook rows (keeps oldest per event id), then adds the unique index + on `WebhookEvent(companyId, provider, providerEventId)`. Additive and + safe to run while the old code is live. +- [ ] Verify: `SELECT indexname FROM pg_indexes WHERE tablename='WebhookEvent';` + shows `WebhookEvent_companyId_provider_providerEventId_key`. + +## 4 · Deploy + +- [ ] Merge to `main`; let Vercel build and promote. +- [ ] Build log: confirm `prisma generate` + `next build` complete and the + route list includes `/manifest.webmanifest` and `/api/realtime/version`. + +## 5 · Post-deploy verification (15 minutes, in order) + +**CSP (highest risk — first enforced deploy)** +- [ ] Open the admin dashboard and buyer portal with DevTools console open: + zero CSP violation errors. +- [ ] Sign out/in (Clerk widget loads), open a property page (Mapbox tiles), + open an uploaded image/receipt (R2), start a test Paystack checkout + (iframe loads). All are CSP-sensitive surfaces. +- [ ] Rollback lever: set `ESTATEOS_CSP_REPORT_ONLY=true` + redeploy → + instantly back to report-only. + +**Payments (idempotency hardening)** +- [ ] Make one real/demo payment end-to-end: webhook reconciles, receipt + generated, balance decremented once, buyer email + WhatsApp sent once. +- [ ] Paystack Dashboard → resend the same webhook event: response is + `duplicate: true`, no second receipt, balance unchanged. + +**Realtime (conditional polling)** +- [ ] Two browser tabs (admin + portal): create a lead or payment; both + surfaces refresh within ~15 s. +- [ ] Network tab shows `/api/realtime/version` every 15 s returning + `{ enabled: true, version: n }`. + +**Images** +- [ ] Property photos load via `/_next/image?...` (network tab) with + `content-type: image/avif` or `webp` — not full-size originals. + +**PWA + mobile** +- [ ] `/manifest.webmanifest` resolves; Android Chrome offers install; icon + and splash look right. +- [ ] On a phone: buyer portal shows the bottom tab bar (badges work), the + drawer still opens, dark mode follows system on first visit. + +**Nav + tables** +- [ ] Sidebar shows grouped sections for each role (check a STAFF or FINANCE + user, not just ADMIN); collapse state persists across reloads. +- [ ] Payments / Invoices / Contracts / Marketers tables sort, search, and + paginate; contract "Send to buyer" and "Regenerate" actions work. + +## 6 · Watch for 48 hours + +- [ ] Sentry: no new error signatures (especially CSP-adjacent or webhook). +- [ ] Upstash: command volume roughly `dashboards × 4/min` — flat and tiny. +- [ ] Vercel: image-optimization usage rises modestly (expected trade for + the bandwidth win); function duration stable. +- [ ] Paystack webhook logs: 200s, no retry storms. + +## Rollback levers (in escalation order) + +1. CSP only: `ESTATEOS_CSP_REPORT_ONLY=true` + redeploy. +2. App: Vercel → promote previous deployment. +3. Migration 0049 is additive — safe to leave in place even when rolling the + app back; old code simply doesn't rely on the index. diff --git a/next.config.ts b/next.config.ts index 5fb6e09..059bca1 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,70 +1,32 @@ import type { NextConfig } from "next"; /** - * Content-Security-Policy — REPORT-ONLY (not enforced). + * Content-Security-Policy is ENFORCED and lives in src/proxy.ts + + * src/lib/security/csp.ts (a static header cannot carry a per-request nonce, + * so it moved out of this file). The previous report-only phase (2026-06) + * informed the allowlist there. Only nonce-free security headers remain below. * - * This is intentionally shipped as `Content-Security-Policy-Report-Only` so the - * browser REPORTS violations (visible in DevTools → Console / Network) WITHOUT - * blocking anything. Nothing here can break the live app. The goal is to observe - * what real traffic actually loads/connects to for a while, tune this allowlist - * to remove anything unused and add anything missing, and only THEN switch the - * header key to `Content-Security-Policy` to enforce it. - * - * Allowlist is derived from the third-party services the app actually uses: - * - Clerk (auth UI + FAPI + Cloudflare Turnstile bot challenge) - * - Paystack (inline checkout script + API + checkout iframe) - * - Mapbox GL JS (bundled script; loads tiles/styles + uses blob workers) - * - PostHog (custom fetch client → NEXT_PUBLIC_POSTHOG_HOST/capture/) - * - Sentry (bundled SDK; sends events to *.ingest.sentry.io) - * - Cloudflare R2 (browser presigned PUT/GET to *.r2.cloudflarestorage.com) - * - Unsplash (marketing imagery) - * - Vercel (vercel.live preview toolbar on preview deployments) - * Server-only integrations (Gemini, Resend, Twilio) never touch the browser, so - * they need no CSP entries. - * - * KNOWN LOOSENESS to tighten before enforcement: - * - script-src includes 'unsafe-inline' and 'unsafe-eval'. Next.js/React inject - * inline bootstrap scripts and this config cannot emit a per-request nonce - * (static headers only). Before enforcing, move to a nonce-based script-src - * with 'strict-dynamic' (requires middleware) and drop 'unsafe-eval'. - * - style-src includes 'unsafe-inline' for Tailwind/inline styles + Mapbox. - * - * NOTE: if the production Clerk instance uses a custom FAPI domain - * (e.g. https://clerk.your-domain.com), add it to script-src and connect-src. + * Escape hatch: ESTATEOS_CSP_REPORT_ONLY=true reverts to a report-only header. + */ +/** + * Host of the public R2 media domain (custom domain or *.r2.dev), derived at + * build time from R2_PUBLIC_BASE_URL. Used twice: + * - added to images.remotePatterns so next/image may optimize R2 media; + * - inlined into the client bundle (NEXT_PUBLIC_R2_PUBLIC_HOST) so + * shouldUseUnoptimizedImage() can whitelist the same host. + * Presigned *.r2.cloudflarestorage.com URLs are intentionally NOT optimized: + * their signatures change per request (cache-busting) and can expire before + * the optimizer fetches them. */ -const contentSecurityPolicyReportOnly = [ - // Lock everything down by default; specific resource types are opened up below. - "default-src 'self'", - // Scripts: self + inline/eval (see looseness note) + external SDK script hosts. - "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://*.clerk.accounts.dev https://*.clerk.com https://challenges.cloudflare.com https://js.paystack.co https://vercel.live", - // Some browsers consult script-src-elem/attr separately; keep them aligned. - "script-src-elem 'self' 'unsafe-inline' https://*.clerk.accounts.dev https://*.clerk.com https://challenges.cloudflare.com https://js.paystack.co https://vercel.live", - // Styles: Tailwind + inline style attributes + Mapbox-injected styles. - "style-src 'self' 'unsafe-inline'", - "style-src-elem 'self' 'unsafe-inline'", - // Images: self, data/blob URIs, Unsplash, R2 media, Clerk avatars, Mapbox tiles. - "img-src 'self' data: blob: https://images.unsplash.com https://*.r2.cloudflarestorage.com https://img.clerk.com https://*.clerk.com https://api.mapbox.com https://*.tiles.mapbox.com", - // Fonts: self-hosted + data URIs (no Google Fonts CDN is used). - "font-src 'self' data:", - // XHR/fetch/websocket targets: own origin + every third-party API the browser calls. - "connect-src 'self' https://*.clerk.accounts.dev https://*.clerk.com https://clerk-telemetry.com https://api.paystack.co https://api.mapbox.com https://events.mapbox.com https://*.tiles.mapbox.com https://*.posthog.com https://*.i.posthog.com https://*.sentry.io https://*.ingest.sentry.io https://*.ingest.us.sentry.io https://*.ingest.de.sentry.io https://*.r2.cloudflarestorage.com https://vercel.live wss://ws-us3.pusher.com", - // Iframes the app embeds: Clerk components, Cloudflare Turnstile, Paystack checkout, Vercel toolbar. - "frame-src 'self' https://*.clerk.accounts.dev https://challenges.cloudflare.com https://checkout.paystack.com https://*.paystack.com https://vercel.live", - // Web/Service workers (Mapbox GL spawns blob: workers). - "worker-src 'self' blob:", - "child-src 'self' blob:", - // Media (audio/video) — self + blob + presigned R2 objects. - "media-src 'self' blob: https://*.r2.cloudflarestorage.com", - // App manifest. - "manifest-src 'self'", - // Form submissions only to our own origin (+ Paystack redirect target, defensively). - "form-action 'self' https://checkout.paystack.com", - // Who may frame us — mirrors the existing X-Frame-Options: SAMEORIGIN header. - "frame-ancestors 'self'", - // Restrict and disallow plugins. - "base-uri 'self'", - "object-src 'none'", -].join("; "); +const r2PublicHost = (() => { + try { + return process.env.R2_PUBLIC_BASE_URL + ? new URL(process.env.R2_PUBLIC_BASE_URL).hostname + : null; + } catch { + return null; + } +})(); const nextConfig: NextConfig = { reactCompiler: true, @@ -72,12 +34,32 @@ const nextConfig: NextConfig = { turbopack: { root: __dirname, }, + env: { + NEXT_PUBLIC_R2_PUBLIC_HOST: r2PublicHost ?? "", + }, images: { + // Serve AVIF/WebP to browsers that accept them — the single biggest + // payload win for property photos on mobile data. + formats: ["image/avif", "image/webp"], remotePatterns: [ { protocol: "https", hostname: "images.unsplash.com", }, + // Default public R2 bucket domains. + { + protocol: "https", + hostname: "**.r2.dev", + }, + // Tenant media domain when R2_PUBLIC_BASE_URL is configured. + ...(r2PublicHost + ? [ + { + protocol: "https" as const, + hostname: r2PublicHost, + }, + ] + : []), ], }, async headers() { @@ -89,13 +71,6 @@ const nextConfig: NextConfig = { { key: "X-Content-Type-Options", value: "nosniff" }, { key: "X-Frame-Options", value: "SAMEORIGIN" }, { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" }, - // Report-only: surfaces CSP violations in DevTools without blocking. - // Monitor reports and tune the allowlist before switching this to the - // enforcing "Content-Security-Policy" header (see note above). - { - key: "Content-Security-Policy-Report-Only", - value: contentSecurityPolicyReportOnly, - }, ], }, ]; diff --git a/package.json b/package.json index bb0a566..22c5e8d 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "start": "next start", "lint": "node --max-old-space-size=4096 ./node_modules/eslint/bin/eslint.js .", "encoding:check": "node scripts/check-utf8.mjs", - "test": "tsx --test src/**/*.test.ts", + "test": "tsx --test \"src/**/*.test.ts\"", "typecheck": "node --max-old-space-size=4096 ./node_modules/typescript/bin/tsc --noEmit --incremental false", "db:generate": "prisma generate", "db:validate": "prisma validate", diff --git a/prisma/migrations/0048_marketer_role/migration.sql b/prisma/migrations/0048_marketer_role/migration.sql new file mode 100644 index 0000000..7cb7504 --- /dev/null +++ b/prisma/migrations/0048_marketer_role/migration.sql @@ -0,0 +1,2 @@ +-- Add MARKETER to the AppRole enum so marketers can have their own login role. +ALTER TYPE "AppRole" ADD VALUE IF NOT EXISTS 'MARKETER'; diff --git a/prisma/migrations/0049_webhook_event_dedup_unique/migration.sql b/prisma/migrations/0049_webhook_event_dedup_unique/migration.sql new file mode 100644 index 0000000..cb8ccad --- /dev/null +++ b/prisma/migrations/0049_webhook_event_dedup_unique/migration.sql @@ -0,0 +1,22 @@ +-- Webhook idempotency hardening. +-- +-- Before creating the unique index, remove any historical duplicate rows +-- (keep the OLDEST row per (companyId, provider, providerEventId) group — +-- the one whose side effects actually ran first). Rows with NULL +-- providerEventId are untouched: Postgres unique indexes permit multiple +-- NULLs, and legacy rows may legitimately lack an event id. +DELETE FROM "WebhookEvent" w +USING "WebhookEvent" keeper +WHERE w."providerEventId" IS NOT NULL + AND keeper."providerEventId" IS NOT NULL + AND w."companyId" IS NOT DISTINCT FROM keeper."companyId" + AND w."provider" = keeper."provider" + AND w."providerEventId" = keeper."providerEventId" + AND keeper."createdAt" < w."createdAt"; + +-- Hard idempotency guarantee: the same provider event can only be recorded +-- once per tenant. Reconciliation inserts this row inside the same DB +-- transaction as the balance/receipt mutations, so a concurrent duplicate +-- delivery aborts atomically with a unique violation. +CREATE UNIQUE INDEX "WebhookEvent_companyId_provider_providerEventId_key" + ON "WebhookEvent"("companyId", "provider", "providerEventId"); diff --git a/prisma/migrations/20260701000111_applies_migration_0044_regenerates_the_prisma_client/migration.sql b/prisma/migrations/20260701000111_applies_migration_0044_regenerates_the_prisma_client/migration.sql new file mode 100644 index 0000000..0241c17 --- /dev/null +++ b/prisma/migrations/20260701000111_applies_migration_0044_regenerates_the_prisma_client/migration.sql @@ -0,0 +1 @@ +-- Reconciliation placeholder for a migration already applied to the dev database. diff --git a/prisma/schema.prisma b/prisma/schema.prisma index dd0e946..6f18a9f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -14,6 +14,7 @@ enum AppRole { ADMIN LEGAL FINANCE + MARKETER SUPER_ADMIN } @@ -2064,6 +2065,12 @@ model WebhookEvent { payment Payment? @relation(fields: [paymentId], references: [id]) createdAt DateTime @default(now()) + // Hard idempotency: the same provider event can only ever be recorded once + // per tenant. The reconciliation creates this row INSIDE the same DB + // transaction as the money mutations, so a concurrent duplicate delivery + // hits P2002 and the entire duplicate pipeline rolls back atomically. + // (Postgres allows multiple NULL providerEventIds, so legacy rows are safe.) + @@unique([companyId, provider, providerEventId]) @@index([companyId, provider, eventType]) @@index([companyId, paymentId]) } diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 0000000..f9fbe68 Binary files /dev/null and b/public/apple-touch-icon.png differ diff --git a/public/icon-192.png b/public/icon-192.png new file mode 100644 index 0000000..0e8fb92 Binary files /dev/null and b/public/icon-192.png differ diff --git a/public/icon-512.png b/public/icon-512.png new file mode 100644 index 0000000..b2f86fe Binary files /dev/null and b/public/icon-512.png differ diff --git a/public/icon-maskable-512.png b/public/icon-maskable-512.png new file mode 100644 index 0000000..ad80731 Binary files /dev/null and b/public/icon-maskable-512.png differ diff --git a/src/app/(admin)/admin/audit-logs/page.tsx b/src/app/(admin)/admin/audit-logs/page.tsx index 73fb054..b19dd3d 100644 --- a/src/app/(admin)/admin/audit-logs/page.tsx +++ b/src/app/(admin)/admin/audit-logs/page.tsx @@ -13,6 +13,7 @@ export default async function AdminAuditLogsPage() { title="Audit trail" columns={["Actor", "Action", "Target", "Time"]} rows={rows} + searchPlaceholder="Search audit trail…" /> ); diff --git a/src/app/(admin)/admin/contracts/generate-contract-form.tsx b/src/app/(admin)/admin/contracts/generate-contract-form.tsx index 62c90fc..9a1394d 100644 --- a/src/app/(admin)/admin/contracts/generate-contract-form.tsx +++ b/src/app/(admin)/admin/contracts/generate-contract-form.tsx @@ -8,6 +8,7 @@ import { generateContractFormAction, type GenerateContractActionState, } from "./actions"; +import { Select } from "@/components/ui/select"; type ContractReadiness = { ceoName: boolean; @@ -80,11 +81,10 @@ export function GenerateContractForm({ - +
- - )} - {row.status === "ACTIVE" && ( - Awaiting acceptance - )} - {row.status === "COMPLETED" && ( - - Accepted {row.acceptedByIp ? `· ${row.acceptedByIp}` : ""} - - )} - - - ); +function toGeneratedContractRow(row: GeneratedContractRow) { + return { + id: row.id, + contractNumber: row.contractNumber, + buyer: + [row.buyer.firstName, row.buyer.lastName].filter(Boolean).join(" ") || + row.buyer.email || + "Buyer", + property: row.property?.title ?? "Unlinked", + status: row.status, + versionLabel: `v${row.version} - ${formatDate(row.generatedAt, "PP")}`, + templateNote: row.templateVersion ? `Template v${row.templateVersion}` : null, + documentId: row.documentId, + transactionId: row.transactionId, + templateId: row.templateId, + canRegenerate: row.status !== "REGENERATED", + }; } // ─── Upload form for a transaction without a contract ───────────────────────── @@ -99,10 +84,9 @@ function UploadContractForm({ transactions }: { transactions: TransactionWithout - +

All contracts

- {contracts.length === 0 ? ( -
- No contracts uploaded yet. Use the form above to link a contract PDF to a transaction. -
- ) : ( -
- - - - {["Reference", "Buyer", "Property", "File", "Status", "Date", ""].map((h) => ( - - ))} - - - - {contracts.map((row) => ( - - ))} - -
- {h} -
-
- )} +
); diff --git a/src/app/(admin)/admin/documents/page.tsx b/src/app/(admin)/admin/documents/page.tsx index 6f09378..4e4e6b0 100644 --- a/src/app/(admin)/admin/documents/page.tsx +++ b/src/app/(admin)/admin/documents/page.tsx @@ -25,6 +25,7 @@ export default async function AdminDocumentsPage() { title="Document register" columns={["File", "Owner", "Type", "Status"]} rows={rows} + searchPlaceholder="Search documents…" /> diff --git a/src/app/(admin)/admin/marketer/page.tsx b/src/app/(admin)/admin/marketer/page.tsx new file mode 100644 index 0000000..83ac8c8 --- /dev/null +++ b/src/app/(admin)/admin/marketer/page.tsx @@ -0,0 +1,20 @@ +import { DashboardShell } from "@/components/portal/dashboard-shell"; +import { MarketerDashboardView } from "@/components/admin/marketer-dashboard"; +import { requireAdminSession } from "@/lib/auth/guards"; +import { rolesForAdminPath } from "@/lib/auth/admin-sections"; +import { getMarketerDashboard } from "@/modules/marketer/dashboard"; + +export default async function AdminMarketerPage() { + const tenant = await requireAdminSession(rolesForAdminPath("/admin/marketer")); + const data = await getMarketerDashboard(tenant); + + return ( + + + + ); +} diff --git a/src/app/(admin)/admin/marketers/page.tsx b/src/app/(admin)/admin/marketers/page.tsx index 7095a5f..f6ee52d 100644 --- a/src/app/(admin)/admin/marketers/page.tsx +++ b/src/app/(admin)/admin/marketers/page.tsx @@ -8,6 +8,8 @@ import { Input } from "@/components/ui/input"; import { requireAdminSession } from "@/lib/auth/guards"; import { formatCurrency, formatDate } from "@/lib/utils"; import { getAdminMarketerPerformanceDashboard } from "@/modules/team/performance"; +import { MarketerRankingTable } from "@/components/admin/marketer-ranking-table"; +import { Select } from "@/components/ui/select"; const SORT_OPTIONS = [ ["score", "Score"], @@ -93,28 +95,26 @@ export default async function AdminMarketersPage({
- - {SORT_OPTIONS.map(([value, label]) => ( ))} - +
@@ -215,69 +215,29 @@ export default async function AdminMarketersPage({ description={`${dashboard.rows.length} marketer${dashboard.rows.length === 1 ? "" : "s"} in this tenant workspace.`} className="px-0 py-0" > - {dashboard.rows.length > 0 ? ( -
- - - - {["Rank", "Marketer", "Score", "Stars", "Weekly revenue", "Monthly revenue", "Lifetime revenue", "Commission earned", "Deals", "Payments", "Inspections", "Reservations", "Trend"].map((column) => ( - - ))} - - - - {dashboard.rows.map((row) => ( - - - - - - - - - - - - - - - - ))} - -
{column}
#{row.rank} -
- -
-
{row.fullName}
-
{row.title}
-
- {!row.isActive ? Inactive : null} - {!row.isPublished ? Private : null} -
-
-
-
{row.score}{row.starRating.toFixed(1)}{formatCurrency(row.revenue.weekly)}{formatCurrency(row.revenue.monthly)}{formatCurrency(row.revenue.lifetime)} -
{formatCurrency(row.commissionTotal)}
- {row.commissionPending > 0 && ( -
- {formatCurrency(row.commissionPending)} pending -
- )} -
{row.metrics.completedDeals}{row.metrics.successfulPayments}{row.metrics.inspectionsHandled}{row.metrics.reservations}{trendLabel(row.trend)}
-
- ) : ( -
- -
- )} + ({ + id: row.id, + rank: row.rank, + fullName: row.fullName, + title: row.title, + avatarUrl: row.avatarUrl, + isActive: row.isActive, + isPublished: row.isPublished, + score: row.score, + starRating: row.starRating, + revenueWeekly: row.revenue.weekly, + revenueMonthly: row.revenue.monthly, + revenueLifetime: row.revenue.lifetime, + commissionTotal: row.commissionTotal, + commissionPending: row.commissionPending, + completedDeals: row.metrics.completedDeals, + successfulPayments: row.metrics.successfulPayments, + inspectionsHandled: row.metrics.inspectionsHandled, + reservations: row.metrics.reservations, + trendLabel: trendLabel(row.trend), + }))} + /> ); diff --git a/src/app/(admin)/admin/payments/page.tsx b/src/app/(admin)/admin/payments/page.tsx index f473c61..8408556 100644 --- a/src/app/(admin)/admin/payments/page.tsx +++ b/src/app/(admin)/admin/payments/page.tsx @@ -1,8 +1,7 @@ -import Link from "next/link"; - import { DashboardShell } from "@/components/portal/dashboard-shell"; import { StatCard } from "@/components/admin/admin-ui"; import { PaymentRequestManagement } from "@/components/admin/payment-request-management"; +import { PaymentsRegisterTable } from "@/components/admin/payments-register-table"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { requireAdminSession } from "@/lib/auth/guards"; @@ -35,39 +34,7 @@ export default async function AdminPaymentsPage() {

Deal payment register

-
- - - - {["Reference", "Buyer", "Marketer", "Payment state", "Stage", "Outstanding", "Next due", "Receipt"].map((column) => ( - - ))} - - - - {monitoring.rows.map((row) => ( - - - - - - - - - - - ))} - -
{column}
{row.reference}{row.buyer}{row.marketer}{row.paymentStatus}{row.stage}{row.outstandingBalance}{row.nextDueAt} - {row.receiptId ? ( - - Download - - ) : ( - "Pending" - )} -
-
+ Fee type - +
@@ -92,16 +92,15 @@ export default async function CommissionRulesPage() { - +

Leave as "All" for a company-wide default.

diff --git a/src/app/(admin)/admin/testimonials/[testimonialId]/page.tsx b/src/app/(admin)/admin/testimonials/[testimonialId]/page.tsx index bdcbf01..fb34ca2 100644 --- a/src/app/(admin)/admin/testimonials/[testimonialId]/page.tsx +++ b/src/app/(admin)/admin/testimonials/[testimonialId]/page.tsx @@ -6,6 +6,7 @@ import { DashboardShell } from "@/components/portal/dashboard-shell"; import { Card } from "@/components/ui/card"; import { requireAdminSession } from "@/lib/auth/guards"; import { getAdminTestimonialDetail } from "@/modules/testimonials/service"; +import { OptimizedImage } from "@/components/media/optimized-image"; export default async function AdminTestimonialDetailPage({ params, @@ -37,8 +38,7 @@ export default async function AdminTestimonialDetailPage({
{testimonial.avatarUrl ? ( - // eslint-disable-next-line @next/next/no-img-element - {`${testimonial.displayName} + ) : ( testimonial.displayName.slice(0, 2).toUpperCase() )} diff --git a/src/app/(admin)/admin/testimonials/page.tsx b/src/app/(admin)/admin/testimonials/page.tsx index 7ab6d90..f6510ad 100644 --- a/src/app/(admin)/admin/testimonials/page.tsx +++ b/src/app/(admin)/admin/testimonials/page.tsx @@ -4,6 +4,7 @@ import { DashboardShell } from "@/components/portal/dashboard-shell"; import { Card } from "@/components/ui/card"; import { requireAdminSession } from "@/lib/auth/guards"; import { getAdminTestimonials, testimonialStatusLabels } from "@/modules/testimonials/service"; +import { Select } from "@/components/ui/select"; export default async function AdminTestimonialsPage({ searchParams, @@ -34,22 +35,22 @@ export default async function AdminTestimonialsPage({ placeholder="Search testimonials" className="admin-focus rounded-[var(--radius-md)] border border-[var(--line)] px-3 py-2 text-sm" /> - {Object.entries(testimonialStatusLabels).map(([value, label]) => ( ))} - - + + diff --git a/src/app/(admin)/admin/transactions/page.tsx b/src/app/(admin)/admin/transactions/page.tsx index f7e56a7..ffc6b08 100644 --- a/src/app/(admin)/admin/transactions/page.tsx +++ b/src/app/(admin)/admin/transactions/page.tsx @@ -74,6 +74,7 @@ export default async function AdminTransactionsPage() { title="Transactions register" columns={["Reference", "Property", "Buyer", "Marketer", "Stage", "Balance"]} rows={rows} + searchPlaceholder="Search transactions…" />
diff --git a/src/app/(admin)/admin/users/page.tsx b/src/app/(admin)/admin/users/page.tsx new file mode 100644 index 0000000..44f0104 --- /dev/null +++ b/src/app/(admin)/admin/users/page.tsx @@ -0,0 +1,20 @@ +import { DashboardShell } from "@/components/portal/dashboard-shell"; +import { UsersManagement } from "@/components/admin/users-management"; +import { requireAdminSession } from "@/lib/auth/guards"; +import { rolesForAdminPath } from "@/lib/auth/admin-sections"; +import { getCompanyUsers } from "@/modules/admin/users"; + +export default async function AdminUsersPage() { + const tenant = await requireAdminSession(rolesForAdminPath("/admin/users")); + const users = await getCompanyUsers(tenant); + + return ( + + + + ); +} diff --git a/src/app/(marketing)/properties/[slug]/page.tsx b/src/app/(marketing)/properties/[slug]/page.tsx index ba997df..32fb39d 100644 --- a/src/app/(marketing)/properties/[slug]/page.tsx +++ b/src/app/(marketing)/properties/[slug]/page.tsx @@ -6,6 +6,7 @@ import { InspectionForm } from "@/components/marketing/inspection-form"; import { MapSection } from "@/components/marketing/map-section"; import { NearbyAmenitiesSection } from "@/components/marketing/nearby-amenities-section"; import { PropertyActions } from "@/components/marketing/property-actions"; +import { WhatsAppButton } from "@/components/shared/whatsapp-button"; import { PropertyCountdown } from "@/components/marketing/property-countdown"; import { Container } from "@/components/shared/container"; import { EmptyState } from "@/components/shared/empty-state"; @@ -18,6 +19,7 @@ import { getPublicPropertyDetailBySlug, } from "@/modules/properties/queries"; import { getVisibleTeamMembers } from "@/modules/team/queries"; +import { getTenantAdminSettings } from "@/modules/settings/service"; function formatLandOptionLabel(option: { label?: string; @@ -51,9 +53,10 @@ export default async function PropertyDetailPage({ }) { const { slug } = await params; const tenant = await getPublicPropertiesContext(); - const [property, marketers] = await Promise.all([ + const [property, marketers, settings] = await Promise.all([ getPublicPropertyDetailBySlug(slug, tenant), getVisibleTeamMembers(tenant), + getTenantAdminSettings(tenant), ]); const isLand = property.type.toUpperCase() === "LAND"; @@ -188,6 +191,14 @@ export default async function PropertyDetailPage({ kind: plan.kind, }))} /> + {settings.whatsappNumber ? ( + + ) : null} {property.brochureUrl ? (