From 4b6401be43196551e8db2ddeb974a29d83908212 Mon Sep 17 00:00:00 2001 From: Nguyen Ngo Date: Fri, 3 Jul 2026 15:38:31 -0400 Subject: [PATCH] July 3rd - Update landing page --- CLAUDE.md | 22 +-- MULTI_TENANT_PLAN.md | 20 ++- app/__init__.py | 2 + app/billing/emails.py | 2 +- app/routes/landing.py | 92 ++++++++++++ app/routes/signup.py | 23 ++- app/templates/billing/email/welcome.html | 4 +- app/templates/landing/index.html | 175 +++++++++++++++++++++++ app/tenancy/middleware.py | 18 ++- config.py | 5 + control/provision.py | 4 + tests/test_landing.py | 66 +++++++++ 12 files changed, 414 insertions(+), 19 deletions(-) create mode 100644 app/routes/landing.py create mode 100644 app/templates/landing/index.html create mode 100644 tests/test_landing.py diff --git a/CLAUDE.md b/CLAUDE.md index 10d5b30..da5cd02 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,7 +52,8 @@ - **Mobile API** — JWT-authenticated REST layer for the iPad native app - **iPad native app** — SwiftUI + SwiftData offline-first inspection tool (Phase A + B + C complete) - **Billing** — Stripe-backed subscription system with plan picker, Checkout, Customer Portal, dunning emails (HTML + plain text), invoice history, and trial-period enforcement -- **Self-service signup** — public `/signup` page provisions a new tenant immediately with a 14-day trial (no Stripe required at signup) +- **Public landing page** — `jqc.app` apex serves a marketing/landing page (`landing.index`, canonical path `/welcome`) introducing the product and plans, funneling to signup +- **Self-service signup** — public `/signup` page provisions a new tenant immediately: Free plan = free-forever (`active`), paid plans = 14-day trial (no Stripe required at signup) The application is actively deployed in production and maintained by a single developer/administrator. @@ -488,7 +489,8 @@ The `DeviceRegistration` model and the duplicate `api_devices` blueprint were ** | `api_discovery` | `/api/v1` | `GET /discover?subdomain=` or `?email=`, `GET /tenant` — MT-9 server side (public, no auth, exempt from tenant middleware) | | `tenant_settings` | `/settings` | MT-7: `GET/POST /branding`, `GET /plan`, `GET /domains`, `POST /domains/request`, `POST /domains//delete` | | `billing` | `/billing` | `GET /subscribe`, `POST /subscribe`, `GET /portal`, `POST /webhook`, `GET /suspended` | -| `signup` | `/signup` | `GET /`, `POST /` — public self-service signup (exempt from tenant middleware) | +| `signup` | `/signup` | `GET /`, `POST /` — public self-service signup (exempt from tenant middleware). Free plan → provisioned `active` (free forever); paid plans → 14-day trial | +| `landing` | `/` (apex only) | `GET /welcome` — public marketing/landing page (tenant-exempt). In MT mode the middleware serves this for the apex host's `/` and funnels to `/signup` | --- @@ -1235,6 +1237,8 @@ set -a; . /etc/jqc/control.env; set +a | 75 | **`delete_tenant()` + `_add_domains()` are retry-safe** | `_add_domains` uses `_upsert_domain()` (delete-then-insert) to handle orphan rows from failed partial runs. `delete_tenant()` also purges by derived domain string, not only by `tenant_id`, catching orphans whose parent tenant row was rolled back. | | 76 | **`register-tenant-zero` never bootstraps and never drops the DB** | `register_tenant_zero()` only reads the existing head, inserts control rows, and maps domains. `delete_tenant()` on tenant-zero must never use `--drop-db` — the guard checks `db_name == db_name_for(slug)` and refuses non-provisioner-named DBs (LT's DB name is `jqc_lt`, not `jqc_lts`). | | 84 | **Device registration is consolidated on `DeviceToken` / `api_device_tokens` — one handler only** | RESOLVED. There is exactly one `POST /api/v1/devices/register`, in `app/api/auth.py` (blueprint `api_auth`); it upserts `DeviceToken` (device_id, device_name, app_version, ios_version, apns_token, last_seen_at) which the admin Devices page reads. The former duplicate `api_devices` blueprint (`app/api/devices.py`) and the orphaned `DeviceRegistration` model / `device_registrations` table were **deleted** — that path wrote to a table phase31/32 drop. Do not reintroduce a second `/devices/register` route or a `device_registrations`-backed model. | +| 85 | **Apex host serves the public landing page; the landing route lives at `/welcome`, NOT `/`** | The dashboard owns `/` (login-gated) on tenant hosts, so the landing page cannot register a second `/` route (same collision class as rule 84). Instead the tenant middleware detects the apex host (`TENANT_BASE_DOMAIN` + `www.`) and calls `landing.index` directly for `/`, redirecting other non-exempt apex paths to `/`. `/welcome`, `/signup`, `/static/` are tenant-exempt. The apex handling is inert when `MULTI_TENANT_ENABLED=false`, so `/welcome` is the always-reachable preview URL. Requires the Nginx apex block to **proxy** (not 301-redirect) to port 8000 with `Host` passed through. | +| 86 | **Free plan is free-forever, not a trial** | `signup.index()` passes `trial_days=0` for `plan_code == 'free'`; `create_tenant()` then sets `subscription_status='active'` (no `trial_ends_at`) so `_billing_gate()` never blocks it. Paid plans keep the 14-day trial (`trial_days=14`). The welcome email adapts via `trial_note` and hides the trial row when `trial_ends_at` is blank. Do not reintroduce a hardcoded `trial_days=14` in the signup path. | --- @@ -1400,7 +1404,7 @@ curl -sI -H "Host: ztest.jqc.app" http://127.0.0.1:8000/ | head -2 | 76 | `register-tenant-zero` never bootstraps and never drops the DB | | 77 | `CONTROL_DATABASE_URL` not in interactive shell — always `set -a; . /etc/jqc/control.env; set +a` before CLI | | 78 | Nginx `admin.jqc.app` must be a separate `server {}` block before `*.jqc.app` — exact name wins only in its own block | -| 79 | `jqc.app` apex has no registered tenant — Nginx redirects to `lts.jqc.app` at the server level | +| 79 | `jqc.app` apex has no registered tenant — Nginx **proxies** it to the app (port 8000) and the tenant middleware serves the public landing page (`landing.index`) for `/`. (Was a server-level 301 to `lts.jqc.app`; changed when the marketing landing page was added — see rule 85 + §10 of MULTI_TENANT_PLAN.md.) | | 80 | `@feature_required` before `@quota_soft_check` in decorator stack — no point counting if feature is blocked | | 81 | `TenantSettings.get_or_default()` returns a transient (non-persisted) default instance — `db.session.add(row)` required before first save | | 82 | `inject_tenant_branding()` context processor wraps the DB call in try/except — branding failure must never break page rendering | @@ -1437,9 +1441,9 @@ Stripe-backed subscription billing. Controlled by `BILLING_ENABLED` env var (def ### Trial period -- New tenants provisioned via `create_tenant()` get `subscription_status='trial'`, `trial_ends_at = now + 14 days` by default. -- Self-service signup (`/signup`) provisions a tenant immediately with a 14-day trial and sends a `welcome` email. -- Trial enforcement is in `_billing_gate()` — no Stripe required until they subscribe. +- New tenants provisioned via `create_tenant()` with `trial_days > 0` get `subscription_status='trial'`, `trial_ends_at = now + N days`. With `trial_days=0` they are provisioned `subscription_status='active'` (no trial) — used by the Free plan (rule 86). +- Self-service signup (`/signup`) provisions paid plans with a 14-day trial and the **Free plan as active/free-forever**, then sends a `welcome` email (wording adapts per plan). +- Trial enforcement is in `_billing_gate()` — no Stripe required until they subscribe; `active` and `None` pass through indefinitely. ### Billing emails (`app/billing/emails.py`) @@ -1497,13 +1501,13 @@ All three actions write a `TenantAudit` row. ### Self-service signup (`/signup`) -Public route, exempt from tenant middleware. `SignupForm` validates: +Public route, exempt from tenant middleware. Reached from the public landing page (`landing.index`) CTA buttons. `SignupForm` validates: - Company name, full name, email - Subdomain: DNS label regex + uniqueness check against control DB -- Plan picker (Starter / Pro / Enterprise) +- Plan picker (Free / Starter / Pro / Enterprise — choices loaded from the control DB, cheapest-first so **Free is the default**) - Password + confirm -On submit calls `create_tenant(admin_password=pw, trial_days=14)`. Shows `signup/success.html` with workspace URL and trial end date. +On submit calls `create_tenant(admin_password=pw, trial_days=0 if free else 14)` (rule 86). Shows `signup/success.html` with workspace URL and (for paid plans) trial end date. --- diff --git a/MULTI_TENANT_PLAN.md b/MULTI_TENANT_PLAN.md index b9788d0..c6d83f2 100644 --- a/MULTI_TENANT_PLAN.md +++ b/MULTI_TENANT_PLAN.md @@ -260,11 +260,19 @@ server { location / { proxy_pass http://127.0.0.1:8001; ... } } -# 2. APEX redirect — jqc.app has no registered tenant +# 2. APEX — jqc.app serves the public marketing/landing page +# (was a 301 redirect to lts.jqc.app; now proxied to the app, which the +# tenant middleware serves the landing page for — see app/routes/landing.py) server { listen 80; - server_name jqc.app; - return 301 http://lts.jqc.app$request_uri; + server_name jqc.app www.jqc.app; + location / { + proxy_pass http://127.0.0.1:8000; + proxy_set_header Host $host; # resolver reads this + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } } # 3. WILDCARD — all tenant subdomains @@ -275,6 +283,12 @@ server { } ``` +The apex block must pass `Host` through unchanged: the app's tenant middleware +compares `request.host` to `TENANT_BASE_DOMAIN` (and its `www.` variant) and +serves the landing page (`landing.index`) for `/`, bouncing any other apex path +back to `/`. `/signup`, `/welcome`, and `/static/` are tenant-exempt and served +directly. Tenant subdomains and `admin.jqc.app` are unaffected. + If `admin.jqc.app` is in the same block as `*.jqc.app`, Nginx routes it to port 8000 (main app), which returns "Workspace not found" because `admin.jqc.app` is not a registered tenant domain. --- diff --git a/app/__init__.py b/app/__init__.py index 7874c03..eaa673b 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -206,6 +206,7 @@ def create_app(config_name='default'): from app.routes import devices # Admin device management from app.routes import tenant_settings # MT-7 — tenant self-service from app.routes import signup # MT-8+ — public self-service signup + from app.routes import landing # Public apex marketing/landing page from app.billing import bp as billing_bp # MT-8 — Stripe billing app.register_blueprint(auth.bp) @@ -225,6 +226,7 @@ def create_app(config_name='default'): app.register_blueprint(devices.bp) app.register_blueprint(tenant_settings.bp) app.register_blueprint(signup.bp) + app.register_blueprint(landing.bp) # Billing blueprint is CSRF-exempt: /billing/webhook receives raw POST from # Stripe and cannot carry a CSRF token. Subscribe/portal are GET redirects # which Flask-WTF does not protect anyway (CSRF only applies to unsafe methods). diff --git a/app/billing/emails.py b/app/billing/emails.py index 82a0b46..7476fc3 100644 --- a/app/billing/emails.py +++ b/app/billing/emails.py @@ -36,7 +36,7 @@ _PLAIN_BODIES = { 'welcome': ( "Welcome to Janitorial QC!\n\n" "Your workspace is ready at:\n {login_url}\n\n" - "Trial: {trial_days} days (expires {trial_ends_at})\n\n— The JQC Team" + "{trial_note}\n\nPlan: {plan_name}\n\n— The JQC Team" ), 'payment_failed': ( "We were unable to process your most recent payment for your JQC subscription.\n\n" diff --git a/app/routes/landing.py b/app/routes/landing.py new file mode 100644 index 0000000..39198b3 --- /dev/null +++ b/app/routes/landing.py @@ -0,0 +1,92 @@ +""" +app/routes/landing.py +---------------------- +Public marketing / landing page served at the apex domain (e.g. jqc.app). + +Canonical path is GET /welcome (always reachable, tenant-exempt) so the page +can be previewed directly in any environment. In multi-tenant production the +tenant middleware delegates the apex host's `/` to this view (see +app/tenancy/middleware.py) — the apex is NOT a tenant, so `/` cannot be owned +by the login-gated dashboard route. + +The page introduces the product, lists plans (read live from the control DB +when available, with a static fallback), and funnels visitors to the existing +public self-service signup at /signup (plan picker, Free selectable/default). +""" + +import logging + +from flask import Blueprint, render_template, current_app, url_for + +bp = Blueprint('landing', __name__) +logger = logging.getLogger(__name__) + + +# Static fallback mirrors control/seed.py PLAN_DEFS so the page still renders if +# the control DB is unreachable or multi-tenancy is disabled (dev preview). +_FALLBACK_PLANS = [ + dict(code='free', name='Free', price_label='$0', + max_users=3, max_facilities=2, max_inspections_month=50, max_issues_month=50, + allow_mobile_api=False, allow_scheduled_reports=False, + allow_branding=False, allow_custom_domain=False), + dict(code='starter', name='Starter', price_label='Contact us', + max_users=15, max_facilities=10, max_inspections_month=500, max_issues_month=500, + allow_mobile_api=True, allow_scheduled_reports=False, + allow_branding=False, allow_custom_domain=False), + dict(code='pro', name='Pro', price_label='Contact us', + max_users=50, max_facilities=50, max_inspections_month=5000, max_issues_month=5000, + allow_mobile_api=True, allow_scheduled_reports=True, + allow_branding=True, allow_custom_domain=True), + dict(code='enterprise', name='Enterprise', price_label='Contact us', + max_users=None, max_facilities=None, max_inspections_month=None, max_issues_month=None, + allow_mobile_api=True, allow_scheduled_reports=True, + allow_branding=True, allow_custom_domain=True), +] + + +def _plan_dict(p): + """Flatten a control Plan ORM row into a template-safe dict (read inside the + open session so no lazy attribute access happens after it closes).""" + if p.price_cents == 0: + price_label = '$0' + elif p.price_cents: + price_label = f'${p.price_cents // 100}/mo' + else: + price_label = 'Contact us' + return dict( + code=p.code, name=p.name, price_label=price_label, + max_users=p.max_users, max_facilities=p.max_facilities, + max_inspections_month=p.max_inspections_month, + max_issues_month=p.max_issues_month, + allow_mobile_api=p.allow_mobile_api, + allow_scheduled_reports=p.allow_scheduled_reports, + allow_branding=p.allow_branding, + allow_custom_domain=p.allow_custom_domain, + ) + + +def _load_plans(): + """Return plan dicts from the control DB, ordered cheapest-first, with a + static fallback so the landing page never fails to render.""" + try: + from control.base import control_session + from control.models import Plan + with control_session() as s: + plans = (s.query(Plan) + .order_by(Plan.price_cents.nullslast(), Plan.id) + .all()) + if plans: + return [_plan_dict(p) for p in plans] + except Exception as exc: + logger.debug('LANDING | plan_load_failed | err=%s', exc) + return _FALLBACK_PLANS + + +@bp.route('/welcome') +def index(): + return render_template( + 'landing/index.html', + plans=_load_plans(), + signup_url=url_for('signup.index'), + base_domain=current_app.config.get('TENANT_BASE_DOMAIN', 'jqc.app'), + ) diff --git a/app/routes/signup.py b/app/routes/signup.py index fd951b8..af8e6c5 100644 --- a/app/routes/signup.py +++ b/app/routes/signup.py @@ -27,16 +27,28 @@ logger = logging.getLogger(__name__) def _send_welcome_email(to_email, login_url, tenant_name, plan_code, trial_ends_at): - """Fire a welcome email in a background thread — never blocks the request.""" + """Fire a welcome email in a background thread — never blocks the request. + + The wording adapts to the plan: the Free plan has no trial/expiry, paid + plans start a 14-day trial. `trial_note` carries the plan-appropriate line + and `trial_ends_at` is blank for Free so the template hides the trial row. + """ try: from app.billing.emails import send_billing_email + is_free = (plan_code == 'free') ends_str = (trial_ends_at.strftime('%B %d, %Y') if hasattr(trial_ends_at, 'strftime') else str(trial_ends_at or '')) + if is_free: + trial_note = "You're on the Free plan — no credit card, no expiry." + ends_str = '' + else: + trial_note = f'Your 14-day free trial runs through {ends_str}.' send_billing_email(to_email, 'welcome', { 'tenant_name': tenant_name, 'login_url': login_url, - 'trial_days': 14, + 'trial_days': 0 if is_free else 14, 'trial_ends_at': ends_str, + 'trial_note': trial_note, 'plan_name': plan_code.title(), }) except Exception as exc: @@ -115,6 +127,11 @@ def index(): plan_code = form.plan.data password = form.password.data + # Free plan is free forever — no trial, no expiry. Paid plans start a + # 14-day trial. trial_days=0 makes create_tenant provision the tenant as + # 'active' (see control/provision.py) so the billing gate never blocks it. + trial_days = 0 if plan_code == 'free' else 14 + try: from control.provision import create_tenant info = create_tenant( @@ -124,7 +141,7 @@ def index(): admin_email=email, admin_full_name=full_name, admin_password=password, - trial_days=14, + trial_days=trial_days, ) except ValueError as exc: flash(str(exc), 'danger') diff --git a/app/templates/billing/email/welcome.html b/app/templates/billing/email/welcome.html index fbbdf99..bada8f6 100644 --- a/app/templates/billing/email/welcome.html +++ b/app/templates/billing/email/welcome.html @@ -2,14 +2,14 @@ {% set subject = "Welcome to JQC — your workspace is ready" %} {% block body %}

Welcome to Janitorial QC!

-

Your workspace {{ tenant_name }} is ready. You're on a free trial — no credit card needed until {{ trial_ends_at }}.

+

Your workspace {{ tenant_name }} is ready. {{ trial_note }}

diff --git a/app/templates/landing/index.html b/app/templates/landing/index.html new file mode 100644 index 0000000..086c5d6 --- /dev/null +++ b/app/templates/landing/index.html @@ -0,0 +1,175 @@ + + + + + + Janitorial QC — Quality control for janitorial contracts + + + + + + + + + + +
+
+
+
+ No credit card required +

Quality control for janitorial contracts.

+

+ Run structured inspections, track issues with automatic SLA enforcement, + and give your customers a live window into every facility — all in one place. +

+ +
+
+
+
+ Facility scorecard + 96% +
+
+ Restrooms +
+ Lobby & entrances +
+ Open issues +
+ 1 critical + 2 high + 3 open +
+
+
+
+
+
+ + +
+
+
+

Everything you need to prove clean.

+

From the inspector's tablet to the customer's inbox.

+
+
+
+
+
Custom inspections
+

Build templates with a drag-and-drop form editor. Score areas, attach photos, and capture GPS at submit time.

+
+
+
+
Issues with SLA
+

Flag problems by severity. The SLA engine escalates automatically and alerts the right people before deadlines slip.

+
+
+
📊
+
Live reports
+

Score trends, aging, SLA compliance, and per-facility PDF summaries — on demand or on a schedule.

+
+
+
📱
+
Offline iPad app
+

Inspectors work anywhere. Records sync automatically when a connection returns — no lost data in the field.

+
+
+
+
+ + +
+
+
+

Simple plans that grow with you.

+

Start on the Free plan — upgrade any time.

+
+
+ {% for p in plans %} +
+
+ {% if p.code == 'pro' %}Most popular{% endif %} +
{{ p.name }}
+
{{ p.price_label }}
+
    +
  • {{ p.max_users or 'Unlimited' }} users
  • +
  • {{ p.max_facilities or 'Unlimited' }} facilities
  • +
  • {{ p.max_inspections_month or 'Unlimited' }} inspections/mo
  • +
  • {% if p.allow_mobile_api %}{% else %}{% endif %} Mobile iPad app
  • +
  • {% if p.allow_scheduled_reports %}{% else %}{% endif %} Scheduled reports
  • +
  • {% if p.allow_branding %}{% else %}{% endif %} Custom branding
  • +
  • {% if p.allow_custom_domain %}{% else %}{% endif %} Custom domain
  • +
+ + {% if p.code == 'free' %}Get started{% else %}Choose {{ p.name }}{% endif %} + +
+
+ {% endfor %} +
+

+ Every workspace gets its own secure database and a your-name.{{ base_domain }} address. +

+
+
+ + +
+
+

Ready to see cleaner results?

+

Set up your workspace in under two minutes.

+ Register for free +
+
+ + + + + + diff --git a/app/tenancy/middleware.py b/app/tenancy/middleware.py index 2cd526e..054c75e 100644 --- a/app/tenancy/middleware.py +++ b/app/tenancy/middleware.py @@ -57,6 +57,8 @@ def _is_exempt(path): return True if path.startswith('/signup'): return True # public self-service signup has no tenant context + if path.startswith('/welcome'): + return True # public marketing/landing page has no tenant context if path.startswith('/notifications/trial-reminders'): return True # cross-tenant cron — iterates all tenants from control DB if path.startswith('/notifications/dunning-reminders'): @@ -74,6 +76,7 @@ def init_tenancy(app): g.tenant = None g.tenant_engine = None g.billing_warning = None # MT-8: set to 'past_due' by _billing_gate when needed + g.is_apex = False # True when serving the public apex/landing host if not current_app.config.get('MULTI_TENANT_ENABLED', False): return # inert: default database serves everything (single-tenant) @@ -124,8 +127,21 @@ def init_tenancy(app): session.pop('impersonating_tenant_id', None) session.pop('impersonating_superadmin_id', None) - # ── Normal Host → tenant resolution ────────────────────────────── + # ── Apex (marketing) host → public landing page ────────────────── + # The apex domain (jqc.app) and its www. variant are NOT tenants. Serve + # the public landing page for '/' and send any other non-exempt apex + # path back to it. /signup, /static, /welcome are already exempt above. host = (request.host or '').split(':')[0].strip().lower() + base = (current_app.config.get('TENANT_BASE_DOMAIN') or '').strip().lower() + if base and host in (base, f'www.{base}'): + g.is_apex = True + if request.path == '/': + # Serve the landing view without a redirect (dashboard owns '/' + # on tenant hosts, so we can't register a second '/' route). + return current_app.view_functions['landing.index']() + return redirect('/') + + # ── Normal Host → tenant resolution ────────────────────────────── tenant = resolve_tenant(host) if tenant is None: return Response(_UNKNOWN_TENANT_PAGE, status=404, mimetype='text/html') diff --git a/config.py b/config.py index a69ff9f..89c6988 100644 --- a/config.py +++ b/config.py @@ -47,6 +47,11 @@ class Config: p.strip() for p in os.environ.get('MULTI_TENANT_EXEMPT_PATHS', '').split(',') if p.strip() ] + # Apex domain for the public marketing/landing page. When MULTI_TENANT_ENABLED + # is True, requests to this host (and its www. variant) are served the landing + # page instead of being resolved to a tenant. Mirrors TENANT_BASE_DOMAIN used + # by the provisioner to build . subdomains. + TENANT_BASE_DOMAIN = os.environ.get('TENANT_BASE_DOMAIN', 'jqc.app') # Per-tenant SQLAlchemy engine pool tuning (see MULTI_TENANT_PLAN.md §4). TENANT_ENGINE_POOL_SIZE = int(os.environ.get('TENANT_ENGINE_POOL_SIZE', 5)) TENANT_ENGINE_MAX_OVERFLOW = int(os.environ.get('TENANT_ENGINE_MAX_OVERFLOW', 5)) diff --git a/control/provision.py b/control/provision.py index 7cd7fe7..27698bc 100644 --- a/control/provision.py +++ b/control/provision.py @@ -269,6 +269,10 @@ def create_tenant(slug, name, plan_code, admin_email, admin_username=None, if trial_days: t.subscription_status = 'trial' t.trial_ends_at = now_eastern() + timedelta(days=trial_days) + else: + # No trial (e.g. Free plan) — provision as active so the billing + # gate treats it as a live, non-expiring subscription. + t.subscription_status = 'active' t.set_db_password(password) s.add(t); s.flush() tenant_id = t.id diff --git a/tests/test_landing.py b/tests/test_landing.py new file mode 100644 index 0000000..5f6b2e1 --- /dev/null +++ b/tests/test_landing.py @@ -0,0 +1,66 @@ +""" +Tests for the public apex landing page (app/routes/landing.py) and the +apex-host routing in the tenant middleware (app/tenancy/middleware.py). + +Two invariants: + 1. GET /welcome always renders the landing page (tenant-exempt, any host). + 2. When multi-tenancy is on, the apex host (TENANT_BASE_DOMAIN + www.) serves + the landing page at '/' and bounces any other non-exempt path back to '/', + while /signup stays reachable. Tenant subdomains are unaffected (still 404 + for unknown hosts, dashboard still owns '/'). +""" + +import pytest + + +def test_welcome_renders_landing(app): + """/welcome is public and renders the marketing page (MT inert here).""" + client = app.test_client() + r = client.get('/welcome') + assert r.status_code == 200 + assert b'Quality control for janitorial contracts' in r.data + assert b'/signup' in r.data # funnels to existing signup + assert b'Simple plans that grow with you' in r.data # pricing section + + +@pytest.fixture +def mt_app(app): + """The shared app with multi-tenancy temporarily enabled (apex = jqc.app). + + Reuses the single app instance — create_app cannot be called twice in one + process because the /api/v1 parent blueprint is a module-level singleton. + The middleware reads current_app.config per request, so toggling config here + is sufficient. Config is restored afterwards so other tests are unaffected. + """ + prev_mt = app.config.get('MULTI_TENANT_ENABLED') + prev_base = app.config.get('TENANT_BASE_DOMAIN') + app.config.update(MULTI_TENANT_ENABLED=True, TENANT_BASE_DOMAIN='jqc.app') + yield app + app.config.update(MULTI_TENANT_ENABLED=prev_mt, TENANT_BASE_DOMAIN=prev_base) + + +def test_apex_root_serves_landing(mt_app): + client = mt_app.test_client() + r = client.get('/', headers={'Host': 'jqc.app'}) + assert r.status_code == 200 + assert b'Quality control for janitorial contracts' in r.data + + +def test_www_apex_serves_landing(mt_app): + client = mt_app.test_client() + r = client.get('/', headers={'Host': 'www.jqc.app'}) + assert r.status_code == 200 + assert b'Quality control for janitorial contracts' in r.data + + +def test_apex_other_path_redirects_to_root(mt_app): + client = mt_app.test_client() + r = client.get('/issues', headers={'Host': 'jqc.app'}) + assert r.status_code == 302 + assert r.headers['Location'] == '/' + + +def test_apex_signup_is_reachable(mt_app): + client = mt_app.test_client() + r = client.get('/signup', headers={'Host': 'jqc.app'}) + assert r.status_code == 200

✓ Workspace: {{ login_url }}
- ✓ Free trial: {{ trial_days }} days (expires {{ trial_ends_at }})
+ {% if trial_ends_at %}✓ Free trial: {{ trial_days }} days (expires {{ trial_ends_at }})
{% endif %} ✓ Plan: {{ plan_name }}