From 3670ec441f4ff8a341cfeffdb3aeaf8f4ab32a55 Mon Sep 17 00:00:00 2001 From: Dev Agarwalla Date: Fri, 12 Jun 2026 16:20:29 +0530 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20production=20hardening=20=E2=80=94?= =?UTF-8?q?=20CI=20gate,=20Docker=20limits,=20health/SEO/analytics,=20lint?= =?UTF-8?q?=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CI pipeline: lint + typecheck + build gate the SSH deploy; post-deploy Playwright smoke suite runs against the live droplet - Docker: node:24-alpine, npm ci, dev-deps pruned from runtime image; compose gets real cpu/mem limits (old swarm-only deploy block was ignored), log rotation, /api/health healthcheck - App: /api/health route, error & 404 pages, security headers, robots.txt, sitemap.xml, OpenGraph metadata - Analytics: first-party analytics_events pipeline (page views, signups, logins) + phase11 migration with insert-only RLS - Fixes: cron scheduler no longer dies when an Apify run fails (process.exit in library path); deterministic map marker offsets (Math.random in render); setState-in-effect lint errors restructured; ESLint pinned to v9 (plugin-react incompatible with v10); supervised cron restart in entrypoint - Tests: playwright.config.ts with PLAYWRIGHT_BASE_URL, expanded smoke suite (health, sitemap, detail page, role auth pages, 404) Co-Authored-By: Claude Fable 5 --- .github/workflows/deploy.yml | 107 +++++- CLAUDE.md | 35 ++ Dockerfile | 19 +- README.md | 25 +- .../supabase_migration_phase11_analytics.sql | 41 +++ docker-compose.yml | 22 +- eslint.config.mjs | 7 + next.config.ts | 17 + package-lock.json | 346 +++++++++++------- package.json | 6 +- playwright.config.ts | 25 ++ scripts/apify_sync.ts | 31 +- scripts/entrypoint.sh | 15 +- scripts/scraper.ts | 13 +- src/app/api/health/route.ts | 18 + src/app/dashboard/DashboardContent.tsx | 3 +- src/app/error.tsx | 49 +++ src/app/layout.tsx | 30 +- src/app/login/landlord/page.tsx | 4 +- src/app/login/tenant/page.tsx | 4 +- src/app/messages/page.tsx | 8 +- src/app/not-found.tsx | 23 ++ src/app/page.tsx | 2 +- src/app/register/landlord/page.tsx | 4 +- src/app/register/tenant/page.tsx | 4 +- src/app/robots.ts | 14 + src/app/sitemap.ts | 41 +++ src/components/FilterBar.tsx | 2 +- .../dashboard/LandlordApplications 2.tsx | 155 ++++++++ .../dashboard/LandlordApplications.tsx | 32 +- .../dashboard/TenantApplications.tsx | 28 +- src/components/property/MapboxCluster.tsx | 12 +- src/components/property/PropertyReviews.tsx | 2 +- src/components/property/TourScheduler.tsx | 2 +- src/components/providers/AnalyticsTracker.tsx | 19 + src/utils/analytics.ts | 50 +++ src/utils/supabase/middleware.ts | 2 +- test-results/.last-run.json | 4 - tests/e2e/userflow.spec.ts | 127 ++++--- 39 files changed, 1066 insertions(+), 282 deletions(-) create mode 100644 CLAUDE.md create mode 100644 db/migrations/supabase_migration_phase11_analytics.sql create mode 100644 playwright.config.ts create mode 100644 src/app/api/health/route.ts create mode 100644 src/app/error.tsx create mode 100644 src/app/not-found.tsx create mode 100644 src/app/robots.ts create mode 100644 src/app/sitemap.ts create mode 100644 src/components/dashboard/LandlordApplications 2.tsx create mode 100644 src/components/providers/AnalyticsTracker.tsx create mode 100644 src/utils/analytics.ts delete mode 100644 test-results/.last-run.json diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 986e89e..806f859 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,35 +1,114 @@ -name: Deploy to DigitalOcean Droplet +name: CI & Deploy on: push: - branches: - - main + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: deploy-${{ github.ref }} + cancel-in-progress: false jobs: - deploy: + ci: + name: Lint, Typecheck & Build runs-on: ubuntu-latest env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - + # Build-time placeholders — real values are injected on the droplet + # from its .env via docker-compose build args. NEXT_PUBLIC_* values + # are public by design; nothing secret is needed to compile. + NEXT_PUBLIC_SUPABASE_URL: https://placeholder.supabase.co + NEXT_PUBLIC_SUPABASE_ANON_KEY: placeholder-anon-key + NEXT_PUBLIC_MAPBOX_TOKEN: placeholder-mapbox-token steps: - name: Checkout Code uses: actions/checkout@v4 + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + - name: Typecheck + run: npm run typecheck + + - name: Build + run: npm run build + + deploy: + name: Deploy to DigitalOcean Droplet + needs: ci + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + steps: - name: Deploy to Droplet via SSH - uses: appleboy/ssh-action@v1.0.0 + uses: appleboy/ssh-action@v1.2.4 with: host: ${{ secrets.HOST }} username: root password: ${{ secrets.PASSWORD }} - # Alternatively, use SSH key if password auth is disabled: - # key: ${{ secrets.SSH_PRIVATE_KEY }} + # Recommended hardening: switch to key auth (secrets.SSH_PRIVATE_KEY) + # and a non-root deploy user once available. script: | + set -e cd /var/www/rentwise - git pull origin main - - # Rebuild and restart the unified Next.js docker container in the background - docker-compose down + git fetch origin main + git reset --hard origin/main + + # Rebuild and restart the unified Next.js docker container docker-compose up -d --build - + # Clean up old unused images to save space docker image prune -f + + smoke: + name: Post-deploy smoke tests (live site) + needs: deploy + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + + - name: Wait for app to become healthy + run: | + for i in $(seq 1 30); do + if curl -fsS --max-time 5 "http://${{ secrets.HOST }}:3009/api/health" > /dev/null; then + echo "App healthy"; exit 0 + fi + echo "Waiting for app... ($i/30)"; sleep 10 + done + echo "App did not become healthy in time"; exit 1 + + - name: Run e2e smoke suite against production + env: + PLAYWRIGHT_BASE_URL: http://${{ secrets.HOST }}:3009 + run: npm run test:e2e + + - name: Upload Playwright report + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..fd96b7f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,35 @@ +# RentWise + +Rental listings platform for Bangalore. Next.js 16 App Router + Supabase (auth, Postgres, RLS, realtime) + Mapbox. Data pipeline scrapes MagicBricks (cheerio/axios) and NoBroker (Apify actor) into the `properties` table. + +## Commands + +```bash +npm run dev # local dev server +npm run build # production build (standalone output) +npm run lint # eslint (flat config) +npm run typecheck # tsc --noEmit +npm run test:e2e # playwright smoke suite (defaults to live droplet URL) +npm run scrape # one-off Apify NoBroker sync +``` + +E2E against local build: `PLAYWRIGHT_BASE_URL=http://localhost:3000 npm run test:e2e`. + +## Architecture + +- `src/app/` — App Router pages. No API routes except `api/health`; pages talk to Supabase directly via `@supabase/ssr` clients in `src/utils/supabase/` (client/server/middleware variants). +- `src/middleware.ts` — refreshes Supabase session cookies on every request. +- Roles: `tenant` and `landlord`, stored in `user_metadata.role`; separate login/register pages per role. Access control is enforced by Supabase RLS policies (see `db/migrations/`), not app code. +- `scripts/` — scraper pipeline. `cron.ts` runs inside the same Docker container as the web server (started by `scripts/entrypoint.sh`), scheduled 06:00 & 18:00 IST. Zero-fake-data policy: scrapers never invent field values. +- `src/utils/analytics.ts` — first-party event tracking into `analytics_events` table (RLS: insert-only for clients). Fire-and-forget; must never throw. + +## Deploy + +Push to `main` → GitHub Actions (`.github/workflows/deploy.yml`): CI (lint/typecheck/build) → SSH to DigitalOcean droplet (`/var/www/rentwise`) → `docker-compose up -d --build` → Playwright smoke tests against http://157.245.110.163:3009. The droplet's `.env` supplies all secrets/build args; CI builds with placeholders. + +## Constraints + +- ESLint is pinned to v9: `eslint-plugin-react` (via eslint-config-next) breaks on ESLint 10. +- React Compiler lint (`react-hooks/set-state-in-effect`) is enforced — don't call setState-bearing callbacks synchronously in effects; use `.then()` chains or subscription callbacks. +- Listing images come from arbitrary scraped hosts — use ``, not `next/image` (rule disabled in eslint config). +- DB schema changes go in `db/migrations/supabase_migration_phaseN_*.sql` and must be applied manually in the Supabase SQL editor. diff --git a/Dockerfile b/Dockerfile index 95571bc..81a1bf1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,11 @@ # --- Stage 1: Build Image --- -FROM node:20-alpine AS builder +FROM node:24-alpine AS builder WORKDIR /app -# Copy package files and install dependencies +# Copy package files and install dependencies (reproducible via lockfile) COPY package.json package-lock.json ./ -# Install dependencies using npm install to bypass lockfile strictness on version bumps -RUN npm install --legacy-peer-deps +RUN npm ci # Copy full source and build application COPY . . @@ -21,20 +20,24 @@ ENV NEXT_PUBLIC_MAPBOX_TOKEN=$NEXT_PUBLIC_MAPBOX_TOKEN RUN npm run build +# Drop dev-only packages so the runner stage copies a lean node_modules +# (tsx + scraper deps live in "dependencies" and survive the prune) +RUN npm prune --omit=dev + # --- Stage 2: Production Image --- -FROM node:20-alpine AS runner +FROM node:24-alpine AS runner WORKDIR /app -ENV NODE_ENV production -ENV NEXT_TELEMETRY_DISABLED 1 +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 # Copy Next.js standalone build COPY --from=builder /app/public ./public COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/static ./.next/static -# Copy scraper scripts + node_modules for cron +# Copy scraper scripts + pruned node_modules for the cron process COPY --from=builder /app/scripts ./scripts COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/package.json ./package.json diff --git a/README.md b/README.md index 12ba61e..973490c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # RentWise 2.0 - Premium Real Estate Engine -RentWise is a Next.js 15 powered real-estate platform designed with robust Server Components, fluid Framer Motion micro-animations, and a highly secure Supabase backend. It features an automated data ingestion pipeline powered by Apify to scrape properties in real-time. +RentWise is a Next.js 16 powered real-estate platform designed with robust Server Components, fluid Framer Motion micro-animations, and a highly secure Supabase backend. It features an automated data ingestion pipeline powered by Apify to scrape properties in real-time. ## 🚀 Key Features - **Server-Side Rendered:** `app/properties/page.tsx` directly queries the database server-side for blisteringly fast SEO and load times. @@ -58,3 +58,26 @@ If you ever want to force a scrape immediately without waiting for the schedule, ```bash docker exec -it rentwise_next_app npm run scrape ``` + +--- + +## ✅ CI/CD Pipeline + +Every push to `main` runs through `.github/workflows/deploy.yml`: + +1. **CI** — `npm run lint`, `npm run typecheck`, `npm run build` (placeholder env; real values live only on the droplet). +2. **Deploy** — SSH into the droplet, `git reset --hard origin/main`, `docker-compose up -d --build` (no downtime `down` step). +3. **Smoke** — waits for `/api/health`, then runs the Playwright e2e suite against the live site. A failed smoke run flags the deploy red in GitHub. + +Pull requests run the CI job only. + +## 🧪 Testing + +```bash +npm run test:e2e # against live droplet +PLAYWRIGHT_BASE_URL=http://localhost:3000 npm run test:e2e # against local build +``` + +## 📊 Analytics + +First-party analytics events (page views, signups, logins) are written to the `analytics_events` Supabase table (`db/migrations/supabase_migration_phase11_analytics.sql` — run it once in the Supabase SQL editor). Query `analytics_daily_summary` for daily uniques per event. diff --git a/db/migrations/supabase_migration_phase11_analytics.sql b/db/migrations/supabase_migration_phase11_analytics.sql new file mode 100644 index 0000000..1bde10d --- /dev/null +++ b/db/migrations/supabase_migration_phase11_analytics.sql @@ -0,0 +1,41 @@ +-- Phase 11: Product analytics events +-- Lightweight first-party event tracking for user-growth metrics +-- (page views, signups, logins, applications) — pre-seed traction data. + +create table if not exists public.analytics_events ( + id bigint generated always as identity primary key, + event_name text not null, + user_id uuid references auth.users (id) on delete set null, + session_id text, + path text, + properties jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now() +); + +create index if not exists analytics_events_name_time_idx + on public.analytics_events (event_name, created_at desc); + +create index if not exists analytics_events_time_idx + on public.analytics_events (created_at desc); + +alter table public.analytics_events enable row level security; + +-- Anyone (anon or authenticated) may write events; nobody but the +-- service role may read them back. +drop policy if exists "analytics insert for all" on public.analytics_events; +create policy "analytics insert for all" + on public.analytics_events + for insert + to anon, authenticated + with check (true); + +-- Convenience view for quick dashboards (service-role only via RLS). +create or replace view public.analytics_daily_summary as +select + date_trunc('day', created_at) as day, + event_name, + count(*) as events, + count(distinct coalesce(user_id::text, session_id)) as uniques +from public.analytics_events +group by 1, 2 +order by 1 desc, 3 desc; diff --git a/docker-compose.yml b/docker-compose.yml index 6df6b5a..8851a57 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,17 +18,19 @@ services: - NEXT_PUBLIC_MAPBOX_TOKEN=${NEXT_PUBLIC_MAPBOX_TOKEN} - SUPABASE_SERVICE_ROLE_KEY=${SUPABASE_SERVICE_ROLE_KEY} - APIFY_API_TOKEN=${APIFY_API_TOKEN} + - NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL:-http://157.245.110.163:3009} healthcheck: - test: ["CMD-SHELL", "wget -qO- http://localhost:3000/ > /dev/null || exit 1"] - interval: 10s + test: ["CMD-SHELL", "wget -qO- http://localhost:3000/api/health > /dev/null || exit 1"] + interval: 15s timeout: 5s retries: 3 start_period: 90s - deploy: - resources: - limits: - cpus: '0.8' - memory: 800M - update_config: - order: start-first - failure_action: rollback + # Real limits for plain docker-compose (the old `deploy:` block is + # swarm-only and was silently ignored) + cpus: 0.8 + mem_limit: 800m + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" diff --git a/eslint.config.mjs b/eslint.config.mjs index 05e726d..ce451f8 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -5,6 +5,13 @@ import nextTs from "eslint-config-next/typescript"; const eslintConfig = defineConfig([ ...nextVitals, ...nextTs, + { + rules: { + // Scraped listings serve images from arbitrary external hosts; + // next/image throws at runtime for domains not in remotePatterns. + "@next/next/no-img-element": "off", + }, + }, // Override default ignores of eslint-config-next. globalIgnores([ // Default ignores of eslint-config-next: diff --git a/next.config.ts b/next.config.ts index e86a578..e51c916 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,16 @@ import type { NextConfig } from "next"; +const securityHeaders = [ + { key: 'X-Frame-Options', value: 'SAMEORIGIN' }, + { key: 'X-Content-Type-Options', value: 'nosniff' }, + { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, + { key: 'X-DNS-Prefetch-Control', value: 'on' }, + { key: 'Permissions-Policy', value: 'camera=(), microphone=(), payment=()' }, +]; + const nextConfig: NextConfig = { output: 'standalone', + poweredByHeader: false, images: { remotePatterns: [ { protocol: 'https', hostname: 'img.staticmb.com' }, @@ -10,6 +19,14 @@ const nextConfig: NextConfig = { { protocol: 'https', hostname: 'assets.nobroker.in' }, ], }, + async headers() { + return [ + { + source: '/:path*', + headers: securityHeaders, + }, + ]; + }, }; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index 22e62c4..444dc59 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,7 +37,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "babel-plugin-react-compiler": "1.0.0", - "eslint": "^10", + "eslint": "^9.39.4", "eslint-config-next": "16.2.6", "playwright": "^1.60.0", "tailwindcss": "^4", @@ -274,7 +274,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -284,7 +284,7 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -377,7 +377,7 @@ "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -1811,107 +1811,105 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.23.5", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", - "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^3.0.5", + "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", - "minimatch": "^10.2.4" + "minimatch": "^3.1.5" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@eslint/config-array/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, "engines": { - "node": "18 || 20 || >=22" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "balanced-match": "^4.0.2" + "@types/json-schema": "^7.0.15" }, "engines": { - "node": "18 || 20 || >=22" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", "dev": true, - "license": "BlueOak-1.0.0", + "license": "MIT", "dependencies": { - "brace-expansion": "^5.0.5" + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" }, "engines": { - "node": "18 || 20 || >=22" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://opencollective.com/eslint" } }, - "node_modules/@eslint/config-helpers": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", - "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^1.2.1" - }, + "license": "MIT", "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/core": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", - "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "funding": { + "url": "https://eslint.org/donate" } }, "node_modules/@eslint/object-schema": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", - "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/plugin-kit": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", - "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^1.2.1", + "@eslint/core": "^0.17.0", "levn": "^0.4.1" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@humanfs/core": { @@ -2805,7 +2803,7 @@ "version": "1.60.0", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "playwright": "1.60.0" @@ -3352,17 +3350,10 @@ "integrity": "sha512-Hq9IMnfekuOCsEmYl4QX2HBrT+XsfXiupfrLLY8Dcf3Puf4BkBOxSbWYTITSOQAhJoYPBez+b4MJRpIYL65z8A==", "license": "MIT" }, - "node_modules/@types/esrecurse": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", - "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true, "license": "MIT" }, @@ -4099,9 +4090,9 @@ } }, "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", "bin": { @@ -4239,6 +4230,13 @@ "type-fest": "^4.0.0" } }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, "node_modules/aria-query": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", @@ -4541,7 +4539,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz", "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/types": "^7.26.0" @@ -5984,30 +5982,33 @@ } }, "node_modules/eslint": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.0.tgz", - "integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==", + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.2", - "@eslint/config-array": "^0.23.5", - "@eslint/config-helpers": "^0.6.0", - "@eslint/core": "^1.2.1", - "@eslint/plugin-kit": "^0.7.1", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", + "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^9.1.2", - "eslint-visitor-keys": "^5.0.1", - "espree": "^11.2.0", - "esquery": "^1.7.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", @@ -6017,7 +6018,8 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "minimatch": "^10.2.4", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -6025,7 +6027,7 @@ "eslint": "bin/eslint.js" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://eslint.org/donate" @@ -6316,19 +6318,17 @@ } }, "node_modules/eslint-scope": { - "version": "9.1.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", - "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@types/esrecurse": "^4.3.1", - "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" @@ -6347,58 +6347,45 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/eslint/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, - "engines": { - "node": "18 || 20 || >=22" + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, - "license": "BlueOak-1.0.0", + "license": "BSD-2-Clause", "dependencies": { - "brace-expansion": "^5.0.5" + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" }, "engines": { - "node": "18 || 20 || >=22" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://opencollective.com/eslint" } }, - "node_modules/espree": { - "version": "11.2.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", - "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.16.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^5.0.1" - }, + "license": "Apache-2.0", "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" @@ -7070,6 +7057,19 @@ "node": ">=10.13.0" } }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/globalthis": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", @@ -7581,6 +7581,23 @@ "node": ">= 4" } }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/import-local": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", @@ -8234,6 +8251,29 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/jsdom": { "version": "26.1.0", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", @@ -9614,6 +9654,19 @@ "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", "license": "(MIT AND Zlib)" }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/parent-require": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/parent-require/-/parent-require-1.0.0.tgz", @@ -9830,7 +9883,7 @@ "version": "1.60.0", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "playwright-core": "1.60.0" @@ -9849,7 +9902,7 @@ "version": "1.60.0", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" @@ -10316,6 +10369,16 @@ "node": ">=8" } }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -11191,6 +11254,19 @@ "node": ">=4" } }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/strtok3": { "version": "10.3.5", "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", diff --git a/package.json b/package.json index 5034ec2..c6db24f 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,9 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint" + "lint": "eslint", + "typecheck": "tsc --noEmit", + "test:e2e": "playwright test" }, "dependencies": { "@supabase/ssr": "^0.10.3", @@ -39,7 +41,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "babel-plugin-react-compiler": "1.0.0", - "eslint": "^10", + "eslint": "^9.39.4", "eslint-config-next": "16.2.6", "playwright": "^1.60.0", "tailwindcss": "^4", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..49ee720 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,25 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * E2E smoke suite. Targets the deployed droplet by default; + * override with PLAYWRIGHT_BASE_URL (e.g. http://localhost:3000 in CI + * against a local prod build). + */ +export default defineConfig({ + testDir: './tests/e2e', + timeout: 30_000, + retries: process.env.CI ? 2 : 0, + forbidOnly: !!process.env.CI, + reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list', + use: { + baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://157.245.110.163:3009', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}); diff --git a/scripts/apify_sync.ts b/scripts/apify_sync.ts index b5c276d..e939246 100644 --- a/scripts/apify_sync.ts +++ b/scripts/apify_sync.ts @@ -31,6 +31,25 @@ const BANGALORE_RENT_URLS = [ "https://www.nobroker.in/property/rent/bangalore/Koramangala?searchParam=W3sibGF0IjoxMi45MzUyLCJsb24iOjc3LjYyNDUsInBsYWNlSWQiOiJDaElKN1YtcUpKVVdyanNSeDBmU3d5Y2FfVzQiLCJwbGFjZU5hbWUiOiJLb3JhbWFuZ2FsYSIsInNob3dNYXAiOmZhbHNlfV0=&radius=2.0" ]; +interface ApifyItem { + id?: string | number; + propertyTitle?: string; + rent?: string | number; + url?: string; + locality?: string; + builtUpArea?: string | number; + propertyType?: string; + bathrooms?: string; + parking?: string | number; + furnishing?: string; + description?: string; + ownerName?: string; + imageUrls?: string[]; + imageUrl?: string; + latitude?: number; + longitude?: number; +} + async function getAreaId(name: string): Promise { const { data } = await supabase.from('areas').select('area_id').eq('name', name).single(); if (data?.area_id) return data.area_id; @@ -63,7 +82,7 @@ export async function runApifySync() { let inserted = 0; let updated = 0; - for (const item of items as any[]) { + for (const item of items as ApifyItem[]) { // Safety checks if (!item.propertyTitle || !item.rent || !item.url) continue; @@ -79,7 +98,7 @@ export async function runApifySync() { const sizeRaw = String(item.builtUpArea || '0').replace(/[^0-9]/g, ''); const sizeNum = parseInt(sizeRaw) || 500; - const externalId = `nobroker-${item.id || item.propertyTitle.replace(/\s+/g, '').slice(0, 15)}`; + const externalId = `nobroker-${item.id || item.propertyTitle!.replace(/\s+/g, '').slice(0, 15)}`; const row = { area_id: areaId, @@ -87,7 +106,7 @@ export async function runApifySync() { property_type: item.propertyType || '2BHK', rent: rentNum, size: sizeNum, - bathrooms: parseInt(item.bathrooms) || null, + bathrooms: parseInt(item.bathrooms || '') || null, parking: item.parking ? (String(item.parking).toLowerCase().includes('yes') || String(item.parking) !== '0') : null, furnishing_status: item.furnishing || null, description: item.description || `Property in ${areaName}`, @@ -120,11 +139,13 @@ export async function runApifySync() { console.log(`[APIFY SYNC] Upsert complete. Inserted: ${inserted}, Updated: ${updated}`); } catch (error) { console.error('[APIFY SYNC] Error running sync:', error); - process.exit(1); + // Re-throw so callers (cron.ts) can handle it without the whole + // process dying — process.exit here would kill the cron scheduler. + throw error; } } // Exported for cron.ts to use. If run directly: if (import.meta.url === `file://${process.argv[1]}`) { - runApifySync(); + runApifySync().catch(() => process.exit(1)); } diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh index 02cf18e..e8512c8 100644 --- a/scripts/entrypoint.sh +++ b/scripts/entrypoint.sh @@ -2,10 +2,17 @@ echo "[ENTRYPOINT] Starting RentWise services..." -# Start the cron scraper in background -echo "[ENTRYPOINT] Starting cron scraper..." -npx tsx scripts/cron.ts & +# Start the cron scraper in background, restarting it if it ever crashes +# so a scraper failure can't permanently disable scheduled syncs. +echo "[ENTRYPOINT] Starting cron scraper (supervised)..." +( + while true; do + npx tsx scripts/cron.ts + echo "[ENTRYPOINT] Cron scraper exited (code $?). Restarting in 60s..." + sleep 60 + done +) & # Start Next.js server (foreground - keeps container alive) echo "[ENTRYPOINT] Starting Next.js server on port 3000..." -node server.js +exec node server.js diff --git a/scripts/scraper.ts b/scripts/scraper.ts index f7a7c75..068a59e 100644 --- a/scripts/scraper.ts +++ b/scripts/scraper.ts @@ -130,7 +130,7 @@ function extractPhone(sipro: string): string | null { * Tries ownerName, nameLabel, agentName fields in order. * Returns null if nothing real found — NEVER constructs a name. */ -function extractOwnerName(scriptText: string, i: number): string | null { +export function extractOwnerName(scriptText: string, i: number): string | null { for (const field of ['ownerName', 'nameLabel', 'agentName']) { const matches = [...scriptText.matchAll(new RegExp(`"${field}"\\s*:\\s*"([^"]{2,80})"`, 'g'))].map(m => m[1].trim()); const val = matches[i]; @@ -282,11 +282,12 @@ async function scrapeMagicBricks(area: AreaConfig): Promise { return listings; - } catch (err: any) { - if ([403, 503, 429].includes(err.response?.status)) { - console.warn(`[MB] ${area.name}: blocked (${err.response.status})`); + } catch (err) { + const e = err as { response?: { status?: number }; message?: string }; + if (e.response?.status && [403, 503, 429].includes(e.response.status)) { + console.warn(`[MB] ${area.name}: blocked (${e.response.status})`); } else { - console.error(`\n[MB] ${area.name} error:`, err.message); + console.error(`\n[MB] ${area.name} error:`, e.message); } return []; } @@ -357,7 +358,7 @@ export async function runScraper(): Promise { for (const area of BANGALORE_AREAS) { console.log(`\n[SCRAPER] ${area.name}...`); let listings: PropertyListing[] = []; - try { listings = await scrapeMagicBricks(area); } catch (e: any) { console.error(e.message); } + try { listings = await scrapeMagicBricks(area); } catch (e) { console.error((e as Error).message); } totalFound += listings.length; if (!listings.length) { diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts new file mode 100644 index 0000000..b81373f --- /dev/null +++ b/src/app/api/health/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from 'next/server'; + +export const dynamic = 'force-dynamic'; + +/** + * Lightweight liveness probe for Docker healthchecks and uptime monitors. + * Returns 200 as long as the Next.js server is able to handle requests. + */ +export async function GET() { + return NextResponse.json( + { + status: 'ok', + uptime: process.uptime(), + timestamp: new Date().toISOString(), + }, + { headers: { 'Cache-Control': 'no-store' } } + ); +} diff --git a/src/app/dashboard/DashboardContent.tsx b/src/app/dashboard/DashboardContent.tsx index 596ea15..90926ca 100644 --- a/src/app/dashboard/DashboardContent.tsx +++ b/src/app/dashboard/DashboardContent.tsx @@ -88,7 +88,8 @@ export default function DashboardContent() { .order('created_at', { ascending: false }); if (!error && data) { - const formattedData: Property[] = data.map((item: any) => ({ + type FavoriteRow = { properties: Property & { areas: { name: string } | null } }; + const formattedData: Property[] = (data as unknown as FavoriteRow[]).map((item) => ({ ...item.properties, area_name: item.properties.areas?.name || 'Unknown Area' })); diff --git a/src/app/error.tsx b/src/app/error.tsx new file mode 100644 index 0000000..da70f3b --- /dev/null +++ b/src/app/error.tsx @@ -0,0 +1,49 @@ +'use client' + +import { useEffect } from 'react'; +import Link from 'next/link'; + +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + console.error('[APP ERROR]', error); + }, [error]); + + return ( +
+

+ System Fault +

+

+ Something broke. +

+

+ An unexpected error occurred. You can retry, or head back to the listings. +

+
+ + + Browse Properties + +
+ {error.digest && ( +

+ Ref: {error.digest} +

+ )} +
+ ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index c426149..22df5ba 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,12 +2,37 @@ import type { Metadata } from 'next'; import './globals.css'; import Navbar from '@/components/Navbar'; import { Toaster } from 'react-hot-toast'; +const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'http://157.245.110.163:3009'; + export const metadata: Metadata = { - title: 'RentWise - Premium Rentals in Bangalore', - description: 'Find your perfect rental with AI-powered insights.', + metadataBase: new URL(BASE_URL), + title: { + default: 'RentWise - Premium Rentals in Bangalore', + template: '%s | RentWise', + }, + description: + 'Find your perfect rental in Bangalore with verified listings, rent intelligence, commute analysis and zero brokerage noise.', + keywords: ['rentals', 'Bangalore', 'flats for rent', 'no broker', 'rent prices'], + openGraph: { + title: 'RentWise - Premium Rentals in Bangalore', + description: + 'Verified Bangalore rental listings with rent intelligence and commute analysis.', + url: BASE_URL, + siteName: 'RentWise', + locale: 'en_IN', + type: 'website', + }, + twitter: { + card: 'summary_large_image', + title: 'RentWise - Premium Rentals in Bangalore', + description: + 'Verified Bangalore rental listings with rent intelligence and commute analysis.', + }, + robots: { index: true, follow: true }, }; import { AppProvider } from '@/components/providers/AppContext'; +import AnalyticsTracker from '@/components/providers/AnalyticsTracker'; export default function RootLayout({ children, @@ -18,6 +43,7 @@ export default function RootLayout({ + {children} diff --git a/src/app/login/landlord/page.tsx b/src/app/login/landlord/page.tsx index 0e9e626..325a0d1 100644 --- a/src/app/login/landlord/page.tsx +++ b/src/app/login/landlord/page.tsx @@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'; import Link from 'next/link'; import { createClient } from '@/utils/supabase/client'; import toast from 'react-hot-toast'; +import { track } from '@/utils/analytics'; export default function LoginLandlord() { const [email, setEmail] = useState(''); @@ -36,7 +37,7 @@ export default function LoginLandlord() { const loadingToast = toast.loading('Authenticating...'); try { - const { data, error } = await supabase.auth.signInWithPassword({ + const { error } = await supabase.auth.signInWithPassword({ email, password, }); @@ -53,6 +54,7 @@ export default function LoginLandlord() { router.refresh(); + track('login', { role: 'landlord' }); toast.success('Welcome back.', { id: loadingToast }); router.push('/dashboard'); diff --git a/src/app/login/tenant/page.tsx b/src/app/login/tenant/page.tsx index 629a23f..bc884a5 100644 --- a/src/app/login/tenant/page.tsx +++ b/src/app/login/tenant/page.tsx @@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'; import Link from 'next/link'; import { createClient } from '@/utils/supabase/client'; import toast from 'react-hot-toast'; +import { track } from '@/utils/analytics'; export default function LoginTenant() { const [email, setEmail] = useState(''); @@ -36,7 +37,7 @@ export default function LoginTenant() { const loadingToast = toast.loading('Authenticating...'); try { - const { data, error } = await supabase.auth.signInWithPassword({ + const { error } = await supabase.auth.signInWithPassword({ email, password, }); @@ -53,6 +54,7 @@ export default function LoginTenant() { router.refresh(); + track('login', { role: 'tenant' }); toast.success('Welcome back.', { id: loadingToast }); router.push('/properties'); diff --git a/src/app/messages/page.tsx b/src/app/messages/page.tsx index 571dcc8..bcf7991 100644 --- a/src/app/messages/page.tsx +++ b/src/app/messages/page.tsx @@ -93,11 +93,15 @@ function MessagesContent() { }, [supabase, userId, activeContact]); useEffect(() => { - if (userId) fetchContacts(); + if (!userId) return; + // Defer to a microtask so state updates happen asynchronously + // (avoids cascading renders from sync setState in effects) + void Promise.resolve().then(fetchContacts); }, [userId, fetchContacts]); useEffect(() => { - if (userId && activeContact) fetchMessages(); + if (!userId || !activeContact) return; + void Promise.resolve().then(fetchMessages); }, [userId, activeContact, fetchMessages]); // Real-time subscription diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx new file mode 100644 index 0000000..80ac96d --- /dev/null +++ b/src/app/not-found.tsx @@ -0,0 +1,23 @@ +import Link from 'next/link'; + +export default function NotFound() { + return ( +
+

+ Error 404 +

+

+ This page does not exist. +

+

+ The listing may have been removed, or the address was mistyped. +

+ + Browse Properties + +
+ ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index e802565..fe48a2c 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -22,7 +22,7 @@ export default function Home() {

Stop guessing.
- KNOW what it's worth. + KNOW what it's worth.

RentWise uses billions of data points to evaluate properties in Bangalore. Find underpriced luxury and verified inventory instantly. diff --git a/src/app/register/landlord/page.tsx b/src/app/register/landlord/page.tsx index 85a2892..1fddb9f 100644 --- a/src/app/register/landlord/page.tsx +++ b/src/app/register/landlord/page.tsx @@ -1,10 +1,11 @@ 'use client' -import React, { useState, useEffect } from 'react'; +import React, { useState } from 'react'; import { useRouter } from 'next/navigation'; import Link from 'next/link'; import { createClient } from '@/utils/supabase/client'; import toast from 'react-hot-toast'; +import { track } from '@/utils/analytics'; export default function RegisterLandlord() { const [email, setEmail] = useState(''); @@ -43,6 +44,7 @@ export default function RegisterLandlord() { if (data?.user?.identities?.length === 0) { toast.error('An account with this email already exists.', { id: loadingToast }); } else { + track('signup', { role: 'landlord' }); toast.success('Registration successful! Check your email to verify.', { id: loadingToast }); router.push('/login/landlord'); } diff --git a/src/app/register/tenant/page.tsx b/src/app/register/tenant/page.tsx index 0d48b47..005585b 100644 --- a/src/app/register/tenant/page.tsx +++ b/src/app/register/tenant/page.tsx @@ -1,10 +1,11 @@ 'use client' -import React, { useState, useEffect } from 'react'; +import React, { useState } from 'react'; import { useRouter } from 'next/navigation'; import Link from 'next/link'; import { createClient } from '@/utils/supabase/client'; import toast from 'react-hot-toast'; +import { track } from '@/utils/analytics'; export default function RegisterTenant() { const [email, setEmail] = useState(''); @@ -44,6 +45,7 @@ export default function RegisterTenant() { if (data?.user?.identities?.length === 0) { toast.error('An account with this email already exists.', { id: loadingToast }); } else { + track('signup', { role: 'tenant' }); toast.success('Registration successful! Check your email to verify.', { id: loadingToast }); router.push('/login/tenant'); } diff --git a/src/app/robots.ts b/src/app/robots.ts new file mode 100644 index 0000000..59bc756 --- /dev/null +++ b/src/app/robots.ts @@ -0,0 +1,14 @@ +import type { MetadataRoute } from 'next'; + +const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'http://157.245.110.163:3009'; + +export default function robots(): MetadataRoute.Robots { + return { + rules: { + userAgent: '*', + allow: '/', + disallow: ['/dashboard', '/messages', '/api/'], + }, + sitemap: `${BASE_URL}/sitemap.xml`, + }; +} diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts new file mode 100644 index 0000000..188f7db --- /dev/null +++ b/src/app/sitemap.ts @@ -0,0 +1,41 @@ +import type { MetadataRoute } from 'next'; +import { createClient } from '@supabase/supabase-js'; + +const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'http://157.245.110.163:3009'; + +export const revalidate = 3600; + +export default async function sitemap(): Promise { + const staticRoutes: MetadataRoute.Sitemap = [ + { url: BASE_URL, changeFrequency: 'daily', priority: 1 }, + { url: `${BASE_URL}/properties`, changeFrequency: 'hourly', priority: 0.9 }, + { url: `${BASE_URL}/login`, changeFrequency: 'monthly', priority: 0.3 }, + { url: `${BASE_URL}/register`, changeFrequency: 'monthly', priority: 0.3 }, + ]; + + const url = process.env.NEXT_PUBLIC_SUPABASE_URL; + const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; + if (!url || !key) return staticRoutes; + + try { + // Cookie-less client: sitemap only needs public listing data and may + // run outside a request scope (e.g. at build time). + const supabase = createClient(url, key); + const { data } = await supabase + .from('properties') + .select('property_id, scraped_at') + .order('property_id', { ascending: false }) + .limit(1000); + + const propertyRoutes: MetadataRoute.Sitemap = (data || []).map((p) => ({ + url: `${BASE_URL}/properties/${p.property_id}`, + lastModified: p.scraped_at ? new Date(p.scraped_at) : undefined, + changeFrequency: 'daily', + priority: 0.7, + })); + + return [...staticRoutes, ...propertyRoutes]; + } catch { + return staticRoutes; + } +} diff --git a/src/components/FilterBar.tsx b/src/components/FilterBar.tsx index 76535cf..a8d6d34 100644 --- a/src/components/FilterBar.tsx +++ b/src/components/FilterBar.tsx @@ -52,7 +52,7 @@ export default function FilterBar() { [name]: newValue }; - setFilters(newFilters as any); + setFilters(newFilters as typeof filters); // Update URL const current = new URLSearchParams(Array.from(searchParams.entries())); diff --git a/src/components/dashboard/LandlordApplications 2.tsx b/src/components/dashboard/LandlordApplications 2.tsx new file mode 100644 index 0000000..c141c57 --- /dev/null +++ b/src/components/dashboard/LandlordApplications 2.tsx @@ -0,0 +1,155 @@ +'use client' + +import { useState, useEffect, useCallback } from 'react'; +import { createClient } from '@/utils/supabase/client'; +import toast from 'react-hot-toast'; +import { generateLeaseDocument } from '@/utils/generateLease'; + +interface Application { + application_id: number; + property_id: number; + tenant_id: string; + status: string; + intent_description: string; + move_in_date: string; + kyc_document_url: string | null; + created_at: string; + properties: { + address: string; + rent: number; + } | null; +} + +export default function LandlordApplications({ landlord_id }: { landlord_id: string }) { + const supabase = createClient(); + const [applications, setApplications] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + const fetchApplications = useCallback(async () => { + setIsLoading(true); + // Supabase RLS policies already restrict Landlords to viewing only applications for their properties + const { data, error } = await supabase + .from('applications') + .select(` + *, + properties ( address, rent ) + `) + .order('created_at', { ascending: false }); + + if (!error && data) { + setApplications(data as Application[]); + } + setIsLoading(false); + }, [supabase]); + + useEffect(() => { + if (landlord_id) fetchApplications(); + }, [landlord_id, fetchApplications]); + + const handleUpdateStatus = async (appId: number, status: 'approved' | 'rejected') => { + const loadingToast = toast.loading('Executing status update...'); + const { error } = await supabase + .from('applications') + .update({ status }) + .eq('application_id', appId); + + if (error) { + toast.error(error.message, { id: loadingToast }); + } else { + toast.success(`Application ${status.toUpperCase()} successfully`, { id: loadingToast }); + setApplications(apps => apps.map(a => a.application_id === appId ? { ...a, status } : a)); + } + }; + + if (isLoading) { + return

; + } + + if (applications.length === 0) { + return null; + } + + return ( +
+

+ + Application Review Vault +

+ +
+ {applications.map((app) => ( +
+ + {/* Context Info */} +
+
+ + {app.status} + + + {new Date(app.created_at).toLocaleDateString()} + +
+ +

+ {app.properties?.address} +

+ +

"{app.intent_description}"

+ +
+ Move In: {new Date(app.move_in_date).toLocaleDateString()} + {app.kyc_document_url && ( + + View Encrypted KYC + + + + + )} +
+
+ + {/* Actions */} + {app.status === 'pending' && ( +
+ + +
+ )} + + {app.status === 'approved' && app.properties && ( +
+ +
+ )} +
+ ))} +
+
+ ); +} diff --git a/src/components/dashboard/LandlordApplications.tsx b/src/components/dashboard/LandlordApplications.tsx index c141c57..5cd9372 100644 --- a/src/components/dashboard/LandlordApplications.tsx +++ b/src/components/dashboard/LandlordApplications.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect } from 'react'; import { createClient } from '@/utils/supabase/client'; import toast from 'react-hot-toast'; import { generateLeaseDocument } from '@/utils/generateLease'; @@ -25,26 +25,26 @@ export default function LandlordApplications({ landlord_id }: { landlord_id: str const [applications, setApplications] = useState([]); const [isLoading, setIsLoading] = useState(true); - const fetchApplications = useCallback(async () => { - setIsLoading(true); + useEffect(() => { + if (!landlord_id) return; + let cancelled = false; // Supabase RLS policies already restrict Landlords to viewing only applications for their properties - const { data, error } = await supabase + supabase .from('applications') .select(` *, properties ( address, rent ) `) - .order('created_at', { ascending: false }); - - if (!error && data) { - setApplications(data as Application[]); - } - setIsLoading(false); - }, [supabase]); - - useEffect(() => { - if (landlord_id) fetchApplications(); - }, [landlord_id, fetchApplications]); + .order('created_at', { ascending: false }) + .then(({ data, error }) => { + if (cancelled) return; + if (!error && data) { + setApplications(data as unknown as Application[]); + } + setIsLoading(false); + }); + return () => { cancelled = true; }; + }, [landlord_id, supabase]); const handleUpdateStatus = async (appId: number, status: 'approved' | 'rejected') => { const loadingToast = toast.loading('Executing status update...'); @@ -97,7 +97,7 @@ export default function LandlordApplications({ landlord_id }: { landlord_id: str {app.properties?.address} -

"{app.intent_description}"

+

“{app.intent_description}”

Move In: {new Date(app.move_in_date).toLocaleDateString()} diff --git a/src/components/dashboard/TenantApplications.tsx b/src/components/dashboard/TenantApplications.tsx index 17153c6..bcf3913 100644 --- a/src/components/dashboard/TenantApplications.tsx +++ b/src/components/dashboard/TenantApplications.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect } from 'react'; import { createClient } from '@/utils/supabase/client'; import { generateLeaseDocument } from '@/utils/generateLease'; import PaymentModal from '@/components/property/PaymentModal'; @@ -25,27 +25,27 @@ export default function TenantApplications({ tenant_id }: { tenant_id: string }) const [isLoading, setIsLoading] = useState(true); const [paymentModalApp, setPaymentModalApp] = useState(null); - const fetchApplications = useCallback(async () => { - setIsLoading(true); - const { data, error } = await supabase + useEffect(() => { + if (!tenant_id) return; + let cancelled = false; + supabase .from('applications') .select(` *, properties ( address, rent, landlord_id ) `) .eq('tenant_id', tenant_id) - .order('created_at', { ascending: false }); - - if (!error && data) { - setApplications(data as Application[]); - } - setIsLoading(false); + .order('created_at', { ascending: false }) + .then(({ data, error }) => { + if (cancelled) return; + if (!error && data) { + setApplications(data as unknown as Application[]); + } + setIsLoading(false); + }); + return () => { cancelled = true; }; }, [supabase, tenant_id]); - useEffect(() => { - if (tenant_id) fetchApplications(); - }, [tenant_id, fetchApplications]); - if (isLoading) { return
; } diff --git a/src/components/property/MapboxCluster.tsx b/src/components/property/MapboxCluster.tsx index 1536534..ce28bff 100644 --- a/src/components/property/MapboxCluster.tsx +++ b/src/components/property/MapboxCluster.tsx @@ -29,11 +29,13 @@ export default function MapboxCluster({ properties }: Props) { // Map properties to scatter them slightly if they are in the same area const mapData = useMemo(() => { - return properties.map(p => { + return properties.map((p, idx) => { const baseCoords = areaCoordinates[p.area_name] || [77.5946, 12.9716]; // Default to Bangalore center - // Add a tiny random offset to prevent perfect stacking - const offsetLon = (Math.random() - 0.5) * 0.01; - const offsetLat = (Math.random() - 0.5) * 0.01; + // Deterministic per-property offset to prevent perfect stacking + // (stable across renders, unlike Math.random) + const seed = (p.property_id ?? idx) * 2654435761 % 1000; + const offsetLon = ((seed % 100) / 100 - 0.5) * 0.01; + const offsetLat = ((Math.floor(seed / 100) % 100) / 100 - 0.5) * 0.01; return { ...p, @@ -79,7 +81,7 @@ export default function MapboxCluster({ properties }: Props) { longitude={p.longitude} latitude={p.latitude} anchor="bottom" - onClick={(e: any) => { + onClick={(e: { originalEvent: MouseEvent }) => { e.originalEvent.stopPropagation(); setSelectedProperty(p); }} diff --git a/src/components/property/PropertyReviews.tsx b/src/components/property/PropertyReviews.tsx index 5f196d3..ed62369 100644 --- a/src/components/property/PropertyReviews.tsx +++ b/src/components/property/PropertyReviews.tsx @@ -76,7 +76,7 @@ export default function PropertyReviews({ propertyId }: Props) { {review.date}
-

"{review.text}"

+

“{review.text}”

{[...Array(5)].map((_, i) => ( diff --git a/src/components/property/TourScheduler.tsx b/src/components/property/TourScheduler.tsx index 60a83a7..9e6f49c 100644 --- a/src/components/property/TourScheduler.tsx +++ b/src/components/property/TourScheduler.tsx @@ -42,7 +42,7 @@ export default function TourScheduler({ propertyId }: Props) {

Tour Confirmed

- {new Date(selectedDate).toLocaleDateString()} at {selectedTime} + REF RW-{propertyId} · {new Date(selectedDate).toLocaleDateString()} at {selectedTime}

- - - )} - - {app.status === 'approved' && app.properties && ( -
- -
- )} - - ))} - - - ); -}