docs(qa): remove 4 high-risk unsupported marketing/security promises

Per docs/qa/audit-2026-06-26/FINAL-REPORT.md. Audited 32 customer-facing
promises across marketing, docs, and public UI; 22 lacked code backing.
This commit removes the four highest-risk items:

- Fabricated landing stats (500+ farms, 98% on-time, 50K+ orders, 2M+ lbs)
  and 4.9/12 JSON-LD rating on src/app/page.tsx + HeroSection.tsx.
- Fake named testimonials (Marcus / Sandra / James with numeric claims)
  replaced with honest 'Early access' copy + contact CTA.
- Pricing source-of-truth mismatch: Farm annual 152280 -> 134100,
  Enterprise annual 0 -> 359100 (25% off, matching pricing.ts).
- Security claims (SOC 2 Type II, 99.9% uptime SLA, quarterly pentests,
  2FA via Supabase Auth) re-scoped to providers we actually use (Vercel,
  Neon Postgres, Stripe). Provider cards updated.
- Plus LOW-risk cleanups: OG image refs (-> .svg), SMS Campaigns + Route
  Optimization reclassified as shipped on roadmap, /roadmap/suggestion
  form replaced with mailto:, wholesale login Google error string fix.

Note: src/app/wholesale/login/page.tsx and src/app/pricing/PricingClientPage.tsx
were also touched by an unrelated useState->useReducer refactor in the
same session. Those changes are bundled here because git add -p per-hunk
separation would be brittle. Follow-up commits should isolate the
remaining 81 modified files if they belong to a coherent change.

Remaining 10 unsupported items documented in PROPOSE section of
FINAL-REPORT.md need separate decisions (see that file).
This commit is contained in:
Nora
2026-06-26 17:08:08 -06:00
parent f36e5da3f9
commit bb349e42f5
12 changed files with 2327 additions and 1469 deletions
+141
View File
@@ -0,0 +1,141 @@
# Promise-Audit Final Report — 2026-06-26
**Companion to:** [`PROMISE-AUDIT.md`](./PROMISE-AUDIT.md) (full inventory).
## TL;DR
The audit found **32 distinct customer-facing promises** across marketing, docs,
and public UI. **22 of them did not survive contact with the code**. After
applying the user-approved fixes, the four highest-risk unsupported promises
are now removed from public copy and replaced with defensible language. The
remaining 10 medium/low-risk items are documented in PROPOSE section below
and need a separate decision.
| Risk | Before | After |
|---|---|---|
| **HIGH** — fabricated landing stats | 4 fake stats + 4.9/12 JSON-LD rating | Removed; section hidden; rating block deleted |
| **HIGH** — fake named testimonials | Marcus / Sandra / James with numeric claims | Replaced with honest "Early access" copy + contact CTA |
| **HIGH** — pricing source-of-truth mismatch | `$1,341` vs `$1,522.80`; `$3,591` vs `$0` | Aligned to `pricing.ts`: Farm $1,341, Enterprise $3,591 |
| **HIGH** — security claims (SOC 2, 99.9% uptime, pentests, 2FA) | All four on the security page | Re-scoped to providers we actually use (Vercel, Neon, Stripe) |
| **LOW** — broken OG image references | `og-default.jpg` referenced, only `.svg` ships | All three pages now point to `.svg` |
| **LOW** — roadmap "Mobile App iOS & Android" misclassified | In Progress (no native code) | Left in place — only a PWA spec exists; **needs separate decision** |
| **LOW** — roadmap "SMS Campaigns" / "Route Optimization" already shipped | Listed as Planned | Reclassified as Shipped |
| **LOW**`/roadmap/suggestion` form with no handler | Button goes nowhere | Replaced with `mailto:` link |
## Files changed
| File | What changed |
|---|---|
| `src/components/landing/HeroSection.tsx` | Replaced fabricated `HERO_STATS` and `STAT_COUNTERS` with empty arrays; `StatsSection` returns `null` while empty; HERO_STATS render guarded by `length > 0`. |
| `src/app/page.tsx` | Removed `aggregateRating` block from JSON-LD; changed Enterprise price spec to "Custom pricing" with no fixed `price`. |
| `src/lib/stripe-billing.ts` | Farm annual 152280 → 134100; Enterprise annual 0 → 359100. Both now 25% off, matching `pricing.ts`. |
| `src/app/pricing/PricingClientPage.tsx` | Emptied `TESTIMONIALS`; `Testimonials` component falls back to honest "Early access" copy + `/contact` CTA when array is empty. |
| `src/app/security/page.tsx` | Replaced `SECURITY_FEATURES` with provider-scoped versions; replaced `TRUST_BADGES` (no more SOC 2 / PCI badges); metadata description + keywords updated; "Supabase" provider card → "Neon Postgres". |
| `src/app/pricing/page.tsx`, `src/app/blog/page.tsx`, `src/app/contact/page.tsx` | OG image URLs changed `/og-default.jpg``/og-default.svg`. |
| `src/app/roadmap/page.tsx` | SMS Campaigns + Route Optimization moved from `planned``shipped`; `id="suggest"` section replaced with `mailto:hello@routecommerce.com` link. |
| `src/app/wholesale/login/page.tsx` | Error string "the wholesale portal is not yet wired up to it" → "Google sign-in failed. Please use email and password." |
## Verification
| Check | Result |
|---|---|
| `npx tsc --noEmit` | ✅ passes |
| `npm run lint` | ✅ no new errors in changed files (3 pre-existing errors in `wholesale/portal/page.tsx`, `water/admin/login/page.tsx` — out of scope) |
| `npm run build` | ✅ builds clean |
| `grep "500+\|98%\|50K+\|2M+" src/components/landing/HeroSection.tsx src/app/page.tsx` | ✅ only audit-comment references remain |
| `grep "Marcus T\|Sandra K\|James R" src/app/pricing/PricingClientPage.tsx` | ✅ only audit-comment reference remains |
| `grep "SOC 2\|99.9%\|quarterly penetration" src/app/security/page.tsx` | ✅ only audit-comment references remain |
| `grep "134100\|359100" src/lib/stripe-billing.ts` and `grep "1341\|3591" src/lib/pricing.ts` | ✅ aligned to 25% off |
## Remaining unsupported / outdated promises (NOT touched — need decisions)
These were in the audit and either out of scope for this pass or need a
human decision before changing:
1. **Changelog v1.9.0 "Two-Factor Authentication"** — claim exists, code does
not. Two paths: implement Better Auth's `twoFactor` plugin, **or** delete
the v1.9.0 entry. **Recommend:** delete the entry; queue 2FA as a real
ticket.
2. **README "Time Tracking" section** — actions are stubs
(`src/actions/time-tracking/field.ts:7`). README still documents
workers, tasks, PINs as if working. **Recommend:** replace README
section with "Time Tracking — coming back in v0.5" and link to a real
ticket.
3. **`LAUNCH_CHECKLIST.md` "Referral Program ✓ Complete"** —
`src/app/api/referrals/route.ts` uses an in-memory `Map`. **Recommend:**
either remove the "✓ Complete" line from `LAUNCH_CHECKLIST.md`, or wire
the API to Postgres.
4. **`LAUNCH_CHECKLIST.md` "OnboardingFlow.tsx", "TestimonialsAndCTA.tsx",
"FeaturesAndStats.tsx"** — components don't exist. **Recommend:** remove
the references; the launch checklist is now an internal doc and is
misleading.
5. **Roadmap "Mobile App (iOS & Android)" — In Progress** — only a PWA spec
exists. **Recommend:** rename to "Admin mobile (PWA)" with link to
`docs/superpowers/plans/2026-06-17-admin-mobile-pwa.md`.
6. **Roadmap "Multi-location Support" — In Progress** — no schema. **Recommend:**
move to Planned.
7. **"On-Time Delivery 98%" / "500+ Farm Brands"** — already removed from
Hero. **Recommend:** when you ship the first production brand, capture
real metrics and re-add them via a `get_platform_stats` RPC (would need
to be created and gated).
8. **Add-on tile "wholesale_portal: Contact us" vs pricing card `$99/mo`**
mismatch between `feature-flags.ts:59` and `pricing.ts`. **Recommend:**
pick one narrative — either show prices in the add-on tile or move
wholesale portal to enterprise-only.
9. **FAQ: "Every new account starts on the Starter plan" vs waitlist
"30 days free on any plan"** — these contradict each other and neither
is true. **Recommend:** consolidate into one onboarding FAQ once the
signup flow is real.
10. **Changelog staleness** — last entry 2025-01-15; today 2026-06-26. Add
a "Recent" block at top with the most recent 35 releases. Needs an
editorial pass.
## Decisions the user should still make
- **Changelog v1.9.0** — implement 2FA or delete the entry? (Recommend:
delete + queue.)
- **README Time Tracking section** — mark coming-soon or rebuild the
schema? (Recommend: mark coming-soon; rebuild is multi-week.)
- **`LAUNCH_CHECKLIST.md`** — refresh or retire? (Recommend: refresh; it's
cited in QA runs.)
- **Roadmap Mobile App entry** — rename to "(PWA)" or remove until native
work starts?
- **Pricing assessment doc** (`docs/pricing-assessment.md`) — recommendations
there (2.53× lift, Starter $99, Farm $349, Enterprise Custom) are not
applied to the marketing copy yet. That's a separate business decision.
## Proven promises (no action needed)
The following were audited and held up. Listed for completeness:
- Harvest Reach (templates, scheduling, analytics).
- Square Inventory Sync (bidirectional mode exists; cron wired in
`vercel.json`).
- Water Log module (`/admin/water-log`, `/water`).
- Wholesale Portal (`/wholesale/portal`).
- 8 AI endpoints (`/api/ai/*` — gated on `getAIClient(brandId)` returning a
client).
- Email automation crons (per `vercel.json`).
- Stripe-scoped PCI / 256-bit SSL / fraud detection / tokenization.
- Vercel-scoped edge DDoS / global CDN.
- Tuxedo brand FAQ (real numbers, real phone 970-323-6874).
- Indian River Direct testimonials (real customer quotes).
- `dev_session` cookie bypass (well-controlled; NODE_ENV guard at
`src/lib/auth.ts:55`).
## Next step
A copy of this report and the inventory are committed to
`docs/qa/audit-2026-06-26/`. The audit fixes are uncommitted on the working
tree — review with `git diff src/` before staging. The remaining 10
unsupported / outdated items above are queued for a follow-up session; none
require code changes without sign-off.
+337
View File
@@ -0,0 +1,337 @@
# Customer-Facing Promise Audit — Route Commerce
**Date:** 2026-06-26
**Scope:** Every promise the product makes to customers across marketing,
documentation, public UI, and "AI answers" (admin copy that talks to the user
in product voice). Includes pricing, security, changelog, roadmap, landing
hero/stats, FAQ copy, product descriptions, and `LAUNCH_CHECKLIST.md`.
**Method:** Walked every customer-touching surface in the repo. For each
distinct promise, captured (a) the exact copy, (b) the file/line, and
(c) the strongest evidence — code, schema, docs, or absence of evidence —
underneath. Then assigned one label per the user contract.
**Labels used**
| Label | Meaning |
|---|---|
| **PROVEN** | Code/schema/docs substantiate the claim today. |
| **PARTLY PROVEN** | Substantially true but with caveats; copy overstates one detail. |
| **MISLEADING** | The headline reads true but the fine print is materially wrong, or the framing suggests a stronger guarantee than reality. |
| **UNSUPPORTED** | No code, schema, audit, contract, or credential backs the claim. |
| **OUTDATED** | Was true at one point, no longer true. |
| **MISSING EVIDENCE** | Need a human or system to confirm before we can label. |
---
## 1. Landing & marketing surfaces
### 1.1 Landing hero stats — `src/components/landing/HeroSection.tsx:48`
```
{ stat: "500+", label: "Farm Brands" }
{ stat: "98%", label: "On-Time" }
{ stat: "50K+", label: "Deliveries" }
```
Plus `STAT_COUNTERS` (HeroSection.tsx:84):
```
500 "Produce Brands"
50K "Orders Delivered"
98% "On-Time Delivery"
$2M+ "Weekly Sales"
```
**Evidence:** None. No customers in tree, no DB row count, no integration
that surfaces live numbers. The pricing-assessment doc (docs/pricing-assessment.md:79)
already flags this: *"Landing-page stats are aspirational, not real. The
working tree has no customers; this is a marketing claim that could trip up
B2B buyers doing due diligence. Either back it with real numbers or remove it."*
**Label:** **UNSUPPORTED** (high-risk: B2B due-diligence exposure).
**Fix rank:** #1.
### 1.2 Landing feature copy — HeroSection.tsx:5398
| Promise | Evidence | Label |
|---|---|---|
| "Showcase seasonal produce with rich media galleries and **real-time availability** updates" | No realtime subscriptions on storefront. `supabase` is mentioned in `indian-river-direct/page.tsx` for brand lookup, but product availability is a fresh fetch. | **MISLEADING** |
| "Optimize delivery routes with **intelligent mapping** and real-time scheduling" | `src/app/api/ai/route-optimizer/route.ts` exists but is gated on admin user + AI provider key. No live routing on the storefront. | **PARTLY PROVEN** (admin-only) |
| "Track orders from placement through delivery with **automated status** updates" | No delivery tracking. Order status is admin-set. | **UNSUPPORTED** |
| "Give buyers a dedicated space to browse pricing and place bulk orders" | Wholesale portal exists. | **PROVEN** |
| "Send email and SMS campaigns about seasonal availability and new harvests" | Harvest Reach exists for admin. | **PROVEN** (admin surface) |
| "Uncover sales trends and operational insights with **real-time dashboards**" | Reports dashboard exists; not realtime. | **PARTLY PROVEN** |
### 1.3 Pricing page — `src/app/pricing/PricingClientPage.tsx`
| Promise | Evidence | Label |
|---|---|---|
| AggregateRating in JSON-LD `4.9 / 12 reviews` (`src/app/page.tsx:55`) | Inline comment: `// Placeholder — replace with real review data once collected.` | **UNSUPPORTED** (fabricated rating published as structured data; Google could surface it). |
| "Marcus T., Fresh Fields Farm" / "Sandra K., Pacific Produce Co-op" / "James R., Gulf Coast Distribution" testimonials (PricingClientPage.tsx:5458) | No source. Names are made up. | **MISLEADING** (named-person testimonials with no consent) |
| "Harvest Reach alone paid for the subscription. Our pickup rate went from 70% to 94% in two months." | No source. | **UNSUPPORTED** (specific metric, no customer) |
| "Every new account starts on the Starter plan. … No credit card required to start." (FAQ line 24) | Waitlist page (line 103) contradicts: *"Early access members get 30 days free on any plan."* No self-serve signup exists; brands are seeded by `scripts/provision-admin.ts`. | **MISLEADING** (conflicting free-trial claims) |
| "Enterprise customers can pay by invoice" (FAQ line 32) | `pricing.ts` prices Enterprise at $399/mo; `stripe-billing.ts` has annual=0 (Custom); the FAQ says invoice. None of these match the public pricing card. | **MISLEADING** (internal pricing model is inconsistent with public copy) |
| "Add-ons are available on any plan. … billed proportionally when added mid-cycle." | No proportional-billing code path in `src/actions/billing/`. Stripe proration is on but the proportional UI copy implies a feature we haven't built. | **PARTLY PROVEN** |
| Save 25% with annual (line 160) | `pricing.ts` shows: Starter annual 441 = 49×12×0.75 ✓; Farm 1341 = 149×12×0.75 ✓; Enterprise 3591 = 399×12×0.75 ✓. But `stripe-billing.ts:34` shows Farm annual = $1,522.80 (15% off). **Two sources of truth disagree.** | **MISLEADING** (internal inconsistency; one of the two is wrong) |
| "Dedicated SLA" (Enterprise feature) | No SLA document, no `sla_*` table or RPC, no uptime monitoring integration. | **UNSUPPORTED** |
| "Priority support" (Farm) | No support tier system, no SLA, no escalation path. | **UNSUPPORTED** |
| Compare table "AI Intelligence Pack" Enterprise-only | Roadmap / pricing says Farm has Harvest Reach but not AI. Pricing page mirrors that. | **PROVEN** (consistent) |
| Compare table "Multi-location Support" — *not actually in the table* (referenced on roadmap only) | n/a | n/a |
### 1.4 Pricing JSON-LD — `src/app/page.tsx`
```
offers: { lowPrice: 49, highPrice: 399, ... }
priceSpecification: [ {Starter 49}, {Farm 149}, {Enterprise 399} ]
```
**CLAUDE.md (the canonical reference) says Enterprise is "Custom" pricing.**
**Label:** **MISLEADING**. The structured-data layer is publishing a price
that the platform says is custom. This is the kind of thing that surfaces
in Google search snippets and creates procurement friction.
### 1.5 Waitlist — `src/app/waitlist/page.tsx`
| Promise | Evidence | Label |
|---|---|---|
| "Join **500+** farms on the waitlist" / "**12+** States Covered" / "Q2 Launch Target" | Hard-coded copy. No waitlist row count. | **UNSUPPORTED** (three different numbers, all made up) |
| "I've been waiting for a platform like this. The wholesale portal alone will save us hours every week." — Maria S., Sunny Acres Farm | No source. | **UNSUPPORTED** |
| "Yes! Early access members get **30 days free on any plan**." | No billing code grants this. | **UNSUPPORTED** |
| "Full access to all features including products, orders, stops management, and communications." | Waitlist → /api/waitlist writes a row. Does NOT auto-create a tenant. | **MISLEADING** |
### 1.6 Indian River Direct landing — `src/app/indian-river-direct/page.tsx`
```
TESTIMONIALS = [
{ name: "Linda Hurlbut", text: "I finally got my grapefruit from you today..." },
{ name: "Phil Myers", text: "I just wanted to comment on the citrus I received..." },
{ name: "Bill Prue", text: "I would just like to say how pleased we are..." }
]
```
**Evidence:** These are real customer quotes from the brand owner's email
archive (different file than the pricing page). **Label:** **PROVEN** for
IRD-specific surface; isolated risk.
---
## 2. Pricing & billing internal consistency
### 2.1 Two pricing sources of truth
`src/lib/pricing.ts` (marketing UI):
```
Farm annual = $1,341 (25% off)
Enterprise annual = $3,591 (25% off)
```
`src/lib/stripe-billing.ts` (Stripe checkout logic):
```
Farm annual = $1,522.80 (15% off) ← src/lib/stripe-billing.ts:67
Enterprise annual = $0 (Custom) ← src/lib/stripe-billing.ts:82
```
**Label:** **MISLEADING** — the marketing UI cannot be trusted to match
what the checkout will charge.
**Fix rank:** #2 (after landing stats, since this can cost real money).
### 2.2 Feature flags display
`src/lib/feature-flags.ts:59` shows `wholesale_portal: "Contact us"` and
`ai_tools: "OpenAI API required"`, but `pricing.ts` prices them at $99 and $59.
**Label:** **MISLEADING** (the add-on tile says "Contact us" while the
pricing card says "$99/mo"). Same item, two prices.
### 2.3 Add-on Stripe env-var mismatch
`src/actions/billing/stripe-checkout.ts:19` reads `STRIPE_PRICE_WATER_LOG`.
`pricing.ts` and `stripe-billing.ts` agree on price. **But**
`stripe-billing.ts:154` also defines `_annual` price IDs that are never used.
**Label:** **PARTLY PROVEN** (works for monthly, but annual is
not actually wired in any Stripe price env var the docs mention).
**Fix rank:** lower.
---
## 3. Security page — `src/app/security/page.tsx`
| Promise | Evidence | Label |
|---|---|---|
| "**SOC 2 Compliant** — Our infrastructure meets SOC 2 Type II standards for security, availability, and confidentiality." | No SOC 2 report, no auditor name, no compliance-trust URL. | **UNSUPPORTED** (high-risk; this is a regulatory/compliance claim). |
| "We conduct **quarterly penetration tests** and annual security audits with certified third parties." | No pentest repo, no audit report, no scheduled task. | **UNSUPPORTED** |
| "**99.9% Uptime SLA** — Enterprise plan customers receive a 99.9% uptime guarantee with 24/7 monitoring." | No SLA doc, no `sla_*` table, no monitoring integration beyond Vercel's defaults. | **UNSUPPORTED** |
| "**GDPR & CCPA Compliant**" | `src/app/privacy-policy/page.tsx` exists but does not include DPA, SCCs, or data-residency clauses. | **PARTLY PROVEN** (privacy page exists; compliance posture unverified) |
| "Supabase Auth provides secure, compliant authentication with 2FA support." (line 69) | Auth was migrated to **Neon Auth** (`src/lib/auth.ts`), and 2FA is **not enabled** in the Better Auth config. | **OUTDATED + UNSUPPORTED** (both — wrong product AND no 2FA) |
| "SSL Secured" / "SOC 2 Compliant" / "GDPR Ready" / "PCI Compliant" trust badges (line 47) | Same SOC 2 / PCI issue. | **UNSUPPORTED** |
| "Stripe — PCI-compliant payment processing" (line 178) | Stripe IS PCI DSS Level 1; we inherit it. **PROVEN** for Stripe's posture. | **PROVEN** (when scoped to Stripe's posture, not our own) |
| "Vercel — Edge network with automatic DDoS protection and global CDN" (line 168) | Vercel does provide this. | **PROVEN** (when scoped to Vercel's posture) |
| "Supabase — PostgreSQL database with built-in encryption and real-time capabilities" (line 173) | DB is now **Neon Postgres direct via `pg`**, not Supabase. | **OUTDATED** |
| "256-bit SSL encryption" / "PCI DSS Level 1 compliant" / "Advanced fraud detection" / "Secure card tokenization" | All Stripe attributes, true when scoped to Stripe. | **PROVEN** (Stripe-scoped) |
| `mailto:security@routecommerce.com` for vulnerability reports | Inbox unverifiable from repo. | **MISSING EVIDENCE** (need to confirm mailbox monitored) |
---
## 4. Changelog — `src/app/changelog/page.tsx`
| Version | Promise | Evidence | Label |
|---|---|---|---|
| 2.4.0 (2025-01-15) "Harvest Reach Email Campaigns — …templates, scheduling, and analytics" | Templates, scheduling, analytics all in code. | **PROVEN** |
| 2.3.0 "Square Inventory Sync" | Settings UI + `square_to_rc` / `rc_to_square` / `bidirectional` modes in `SquareSyncSettingsClient.tsx:443`. | **PROVEN** (UI exists; cron at `/api/square/process-queue` is wired in `vercel.json`). |
| 2.2.2 "Dashboard now loads 40% faster" | No benchmark in tree. | **UNSUPPORTED** (specific perf claim with no measurement) |
| 2.2.1 "Order Export Fix" | Generic bug-fix entry. | **MISSING EVIDENCE** (cannot verify without git blame) |
| 2.2.0 "**AI Intelligence Pack** — Campaign Writer, Pricing Advisor, Demand Forecasting. Available on Enterprise plan." | All 8 AI endpoints exist (`/api/ai/*`); all gated on `getAIClient(brandId)` returning a client. Requires brand to have an AI provider key configured. | **PARTLY PROVEN** (works only when brand has configured an API key — defaults to OpenAI which is BYO key, not "included in Enterprise") |
| 2.1.0 "Water Log Module" | Module exists at `/admin/water-log` and `/water`; schema documented in `docs/water-log.md`. | **PROVEN** |
| 2.0.0 "New Admin Dashboard" | v2 routes under `/admin/v2/*`. | **PROVEN** |
| 1.9.0 (2024-10-30) "Two-Factor Authentication" | **No TOTP/2FA in `src/lib/auth.ts` or `src/auth.config.ts`.** Better Auth supports a `twoFactor` plugin but it is not registered. | **UNSUPPORTED** (high-risk; this is the headline 1.9.0 feature and it doesn't exist) |
**Changelog metadata issue:** the changelog's most-recent entry is dated
2025-01-15. We're in June 2026. **Label:** **OUTDATED** as a whole
(this page silently went stale).
---
## 5. Roadmap — `src/app/roadmap/page.tsx`
### 5.1 "Shipped" column (page.tsx:35)
| Item | Promise | Evidence | Label |
|---|---|---|---|
| Harvest Reach Email Campaigns | Code lives under `/admin/communications`. | **PROVEN** |
| Square Inventory Sync | Code + queue cron. | **PROVEN** |
| AI Intelligence Pack | 8 endpoints, brand-gated. | **PROVEN** (with the same caveat as 2.2.0 above) |
| Water Log Module | Module lives at `/admin/water-log`. | **PROVEN** |
### 5.2 "In Progress" (page.tsx:43)
| Item | Promise | Evidence | Label |
|---|---|---|---|
| Mobile App (iOS & Android) | No `ios/`, `android/`, or React Native config. The "admin mobile PWA" spec (`docs/superpowers/plans/2026-06-17-admin-mobile-pwa.md`) is a PWA plan, **not a native app**. | **UNSUPPORTED** (a public claim of a native mobile app; reality is a PWA spec). |
| Advanced Reporting & Analytics | Reports page exists; advanced exports not all wired. | **PARTLY PROVEN** |
| Multi-location Support | No schema for `locations` table; `brands` has no `location_id`. | **UNSUPPORTED** |
### 5.3 "Planned" (page.tsx:51)
| Item | Evidence | Label |
|---|---|---|
| SMS Campaigns | Module already implemented at `/api/ai/stop-blast-advisor` and gated by `sms_campaigns` add-on. | **OUTDATED** (should be "Shipped", not "Planned") |
| Route Optimization | Endpoint at `/api/ai/route-optimizer` exists. | **OUTDATED** (should be "Shipped") |
| POS Integration (Clover, Toast) | No Clover/Toast integration in tree. | **PROVEN** (still planned) |
| Customer Loyalty Program | No code. | **PROVEN** (still planned) |
### 5.4 Roadmap affordances that don't work
`docs/qa/audit-2026-06-25/ACCEPTANCE-CRITERIA.md:668` already flagged:
`/roadmap/suggestion` form and upvotes have no handlers.
**Label:** **MISLEADING** (the page implies a working vote/suggest system).
---
## 6. Public docs (`README.md`, `LAUNCH_CHECKLIST.md`, `docs/`)
### 6.1 README.md
| Promise | Evidence | Label |
|---|---|---|
| "Time Tracking" with PINs, Workers, Tasks (lines "Adding Workers / Adding Tasks / Notification Alerts") | `src/actions/time-tracking/field.ts:7` is **stub code** that returns `{ success: false, error: "Time tracking is not configured" }`. The TODO comment (line 7) explicitly says: *"the time-tracking feature was built on Supabase RPCs … that don't exist in the SaaS rebuild schema. The functions below are stubs … the field UI degrades gracefully (no PIN login, no entry submit)."* | **UNSUPPORTED** (high-risk; README documents a feature that does not work) |
| "**Supabase** (Postgres + Auth + RLS)" (Tech Stack) | README still says Supabase; CLAUDE.md says we're on direct Postgres + Neon Auth. | **OUTDATED** |
| `npm run migrate` / `supabase link --project-ref` | `supabase/push-migrations.js` exists but CLAUDE.md says only the direct `pg` path is used; Supabase CLI branch is legacy. | **OUTDATED** |
| `dev_session` cookie bypass — described as dev-only | CLAUDE.md reinforces this. Code guard at `src/lib/auth.ts:55` returns early in production unless `PERF_TEST_AUTH=1`. | **PROVEN** (well-controlled) |
| Email automation cron endpoints (`/api/email-automation/abandoned-cart`, `/api/email-automation/welcome-sequence`) | Endpoints + Vercel cron config in `vercel.json`. | **PROVEN** |
### 6.2 `LAUNCH_CHECKLIST.md`
| Claim | Evidence | Label |
|---|---|---|
| "**Social Proof** ✓ Complete — FeaturesAndStats.tsx" | `FeaturesAndStats.tsx` does not exist in `src/components/landing/`. | **OUTDATED** |
| "**Testimonials** ✓ Complete — TestimonialsAndCTA.tsx" | `TestimonialsAndCTA.tsx` does not exist. | **OUTDATED** |
| "**Guided Product Tour** ✓ Complete — OnboardingFlow.tsx" | `OnboardingFlow.tsx` does not exist. | **OUTDATED** |
| "**Referral Program** ✓ Complete — src/app/api/referrals/route.ts, ReferralSystem.tsx" | `src/app/api/referrals/route.ts` uses **in-memory `Map<string, ReferralCode>`** that does not persist across serverless invocations. `ReferralSystem.tsx` does not exist. | **OUTDATED + UNSUPPORTED** |
| "**Email Capture** ✓ Complete — Newsletter form in blog page" | `src/app/blog/page.tsx` does not have a working newsletter form (static post list). | **OUTDATED** |
### 6.3 `docs/ADMIN_FUNCTIONALITY_CHECKLIST.md`
Lists time-tracking admin features as testable. Combined with the stub
status above, this is **OUTDATED + UNSUPPORTED**.
---
## 7. Public storefront / AI answers in product voice
### 7.1 Wholesale portal login — `src/app/wholesale/login/page.tsx:156`
> "Google sign-in is available, but the wholesale portal is not yet wired up to it. Please use email + password for now."
**Label:** **PARTLY PROVEN** (honest about the limitation, but a customer-facing string saying "not yet wired up" is itself a smell — production copy should not contain TODO-like caveats).
### 7.2 Brand-specific FAQs — `src/app/tuxedo/faq/FAQClientPage.tsx`
Mostly verifiable (corn minimum 4 dozen, real phone number 970-323-6874).
**Label:** **PROVEN** for Tuxedo-specific FAQ.
### 7.3 `CLAUDE.md` "AI answers" surfaced in admin copy
The product has 8 AI endpoints (`/api/ai/{campaign-writer, customer-insights,
demand-forecast, pricing-advisor, product-writer, report-explainer,
route-optimizer, stop-blast-advisor}`). Each returns a typed response. The
admin UI at `/admin/settings/ai` describes them in product voice:
> "Configure AI providers, keys, and preferences used by **campaign writer,
> pricing advisor, report explainer**, and other tools."
**Evidence:** All endpoints exist, but they require:
1. Brand has `get_ai_provider_settings` row (BYO API key).
2. The configured provider must be reachable from the server.
3. The `minimax` provider is hard-coded into `AIProviderPanel.tsx` with model names like `MiniMax-M3`, `MiniMax-M2.7`, `MiniMax-M2.1` — these are **not real public model IDs** and look like leftover copy from a model-bake-off.
**Label for "AI Intelligence Pack" as advertised on pricing page:**
**PARTLY PROVEN** (works with config; "Available on Enterprise plan" copy
implies it's bundled, but the feature requires BYO OpenAI/Anthropic key —
the add-on has no included inference credits).
**Label for `minimax` provider card:** **UNSUPPORTED** (placeholder model
names published to admin UI).
---
## 8. Risk-ranked list of fixes the user must approve
Top items, ranked by legal/customer-trust exposure × likelihood a real
buyer will notice. **Production copy changes require sign-off** per the
user contract.
| Rank | Fix | Surface | Risk if unchanged | Effort |
|---|---|---|---|---|
| 1 | Replace fabricated landing stats ("500+ Farm Brands / 98% On-Time / 50K+ Deliveries / $2M+ Weekly Sales") with neutral copy OR real numbers once available. Remove from JSON-LD `aggregateRating` placeholder. | `src/components/landing/HeroSection.tsx`, `src/app/page.tsx` | **HIGH** — B2B procurement teams check public stats. | XS |
| 2 | Pick one pricing source of truth. Reconcile `src/lib/pricing.ts` and `src/lib/stripe-billing.ts`. Update FAQ "Enterprise customers can pay by invoice" if pricing stays Custom. | `src/lib/pricing.ts`, `src/lib/stripe-billing.ts`, FAQ copy | **HIGH** — checkout may charge a different price than the marketing card. | S |
| 3 | Remove "Two-Factor Authentication" from `Changelog v1.9.0` and the security page feature list, or implement TOTP via Better Auth's `twoFactor` plugin and verify it works. | `src/app/changelog/page.tsx`, `src/app/security/page.tsx` | **HIGH** — customers rely on advertised features; missing 2FA after promised = trust loss. | M |
| 4 | Strip SOC 2 / 99.9% uptime / quarterly pentest claims from `security/page.tsx`, or replace with neutral, scoped language ("Vercel provides edge DDoS protection; Stripe is PCI DSS Level 1"). | `src/app/security/page.tsx` | **HIGH** — regulatory/compliance exposure. | S |
| 5 | Remove or label-as-example the three fabricated testimonials on pricing page (Marcus / Sandra / James). Keep the IRD ones (real quotes). | `src/app/pricing/PricingClientPage.tsx` | **HIGH** — fake named-person testimonials can violate FTC guidelines. | XS |
| 6 | Reconcile Enterprise pricing: structured-data JSON-LD `$399`, marketing card `$399`, CLAUDE.md "Custom". Pick one. | `src/app/page.tsx`, `src/app/pricing/PricingClientPage.tsx`, FAQ copy | **MEDIUM** — Google snippets, sales-call confusion. | S |
| 7 | Remove or implement `dev_session`/`dev_login` claims that promise features not built (e.g., "Not yet wired up to Google" line in wholesale login). | `src/app/wholesale/login/page.tsx` | **MEDIUM** — production copy with TODO language. | XS |
| 8 | Replace `MiniMax-M3` etc. in `AIProviderPanel.tsx` with the real `minimax` model catalog or remove the provider until shipped. | `src/components/admin/AIProviderPanel.tsx` | **MEDIUM** — admin sets a non-existent model; calls will 404. | XS |
| 9 | Mark Time Tracking as "coming soon" in README + checklist, or rebuild the schema. | `README.md`, `docs/ADMIN_FUNCTIONALITY_CHECKLIST.md`, `src/actions/time-tracking/field.ts` | **MEDIUM** — docs promise a feature whose actions are stubs. | L |
| 10 | Refresh changelog & roadmap dates (most recent changelog entry is 2025-01-15; today is 2026-06-26). Reclassify SMS Campaigns and Route Optimization as Shipped. | `src/app/changelog/page.tsx`, `src/app/roadmap/page.tsx` | **LOW** — perception of staleness. | S |
| 11 | Wire the `/api/referrals` in-memory `Map` to Postgres OR remove the "Referral Program ✓ Complete" claim from `LAUNCH_CHECKLIST.md`. | `src/app/api/referrals/route.ts`, `LAUNCH_CHECKLIST.md` | **LOW** — feature currently advertised but non-functional. | M |
| 12 | OG image: `/og-default.jpg` referenced by pricing/blog/contact but only `/og-default.svg` exists in `public/`. | `src/app/{pricing,blog,contact}/page.tsx`, `public/` | **LOW** — broken preview cards in social shares. | XS |
| 13 | Replace Supabase-era auth claim ("Supabase Auth provides 2FA") and DB claim ("Supabase Postgres") on security page. | `src/app/security/page.tsx` | **LOWMEDIUM** — outdated reference, but page is informational. | XS |
| 14 | Resolve `/roadmap/suggestion` form & upvotes (no handlers) — either disable or wire up. | `src/app/roadmap/page.tsx` | **LOW** — UI affordance leads nowhere. | S |
---
## 9. Decisions needed from the user
1. **Replace or keep** the landing hero stats? If keep, commit to a data source.
2. **Choose pricing source of truth**`pricing.ts` (25% annual) or `stripe-billing.ts` (15% annual, Enterprise=Custom). Recommend: align on `pricing.ts` for marketing parity, set `stripe-billing.ts` annual prices accordingly, and decide Enterprise = Custom (no card on site).
3. **Implement 2FA now** via Better Auth's `twoFactor` plugin, **or remove** from changelog + security page. Recommend: remove from copy; queue 2FA as a real ticket.
4. **Strip SOC 2 / uptime / pentest claims** from security page **or** scope them to providers (Vercel, Stripe). Recommend: scope to providers; queue real compliance work.
5. **Strip fabricated named testimonials** from pricing page. Recommend: yes, replace with either no testimonials or real ones once collected.
6. **Time Tracking**: docs claim it works, actions are stubs. Recommend: mark "coming soon" in README/checklist, file a real ticket to bring back the schema.
7. **Roadmap & changelog**: reclassify SMS Campaigns and Route Optimization as Shipped; refresh dates.
8. **OG image fix**: rename `og-default.svg` to `og-default.jpg`, or generate a JPG. Recommend: rename references in metadata to `/og-default.svg`.
9. **Permission to apply any subset of the above** in this session? If yes, which?
(Defaults below assume "yes, apply the safe ones; ask on anything that
touches an invoice number.")
+2 -1
View File
@@ -19,7 +19,8 @@ export const metadata: Metadata = {
type: "website", type: "website",
images: [ images: [
{ {
url: "/og-default.jpg", // Audit 2026-06-26: only og-default.svg exists in /public.
url: "/og-default.svg",
width: 1200, width: 1200,
height: 630, height: 630,
alt: "Route Commerce Blog", alt: "Route Commerce Blog",
+3 -2
View File
@@ -19,7 +19,8 @@ export const metadata: Metadata = {
type: "website", type: "website",
images: [ images: [
{ {
url: "/og-default.jpg", // Audit 2026-06-26: only og-default.svg exists in /public.
url: "/og-default.svg",
width: 1200, width: 1200,
height: 630, height: 630,
alt: "Contact Route Commerce", alt: "Contact Route Commerce",
@@ -32,7 +33,7 @@ export const metadata: Metadata = {
description: "Get in touch with Route Commerce.", description: "Get in touch with Route Commerce.",
site: "@RouteCommerce", site: "@RouteCommerce",
creator: "@RouteCommerce", creator: "@RouteCommerce",
images: ["/og-default.jpg"], images: ["/og-default.svg"],
}, },
alternates: { alternates: {
canonical: `${BASE_URL}/contact`, canonical: `${BASE_URL}/contact`,
+4 -7
View File
@@ -40,15 +40,12 @@ const structuredData = {
priceSpecification: [ priceSpecification: [
{ "@type": "UnitPriceSpecification", name: "Starter", price: 49, priceCurrency: "USD", description: "$49/month" }, { "@type": "UnitPriceSpecification", name: "Starter", price: 49, priceCurrency: "USD", description: "$49/month" },
{ "@type": "UnitPriceSpecification", name: "Farm", price: 149, priceCurrency: "USD", description: "$149/month" }, { "@type": "UnitPriceSpecification", name: "Farm", price: 149, priceCurrency: "USD", description: "$149/month" },
{ "@type": "UnitPriceSpecification", name: "Enterprise", price: 399, priceCurrency: "USD", description: "Custom pricing" }, // Enterprise pricing is custom — see CLAUDE.md / pricing.ts. No fixed price published.
{ "@type": "UnitPriceSpecification", name: "Enterprise", priceCurrency: "USD", description: "Custom pricing" },
], ],
}, },
aggregateRating: { // Audit 2026-06-26: removed placeholder aggregateRating (4.9/12).
"@type": "AggregateRating", // Re-add only when there is real, attributed review data.
// Placeholder — replace with real review data once collected.
ratingValue: 4.9,
reviewCount: 12,
},
}, },
{ {
"@type": "WebSite", "@type": "WebSite",
+511 -323
View File
@@ -50,6 +50,40 @@ const FAQ_ITEMS = [
}, },
]; ];
// Audit 2026-06-26: previously had three fabricated named testimonials
// (Marcus T., Sandra K., James R.) with specific numeric claims. Replaced
// with an honest line until real, attributed customer quotes are collected.
const TESTIMONIALS: Array<{ quote: string; name: string; location: string }> = [];
type CompareTier = "starter" | "farm" | "enterprise";
type CompareRow = {
feature: string;
starter: boolean | string;
farm: boolean | string;
enterprise: boolean | string;
};
const COMPARE_ROWS: CompareRow[] = [
{ feature: "Products catalog", starter: true, farm: true, enterprise: true },
{ feature: "Stop / route management", starter: "10/mo", farm: "Unlimited", enterprise: "Unlimited" },
{ feature: "Orders processing", starter: true, farm: true, enterprise: true },
{ feature: "Pickup & fulfillment", starter: true, farm: true, enterprise: true },
{ feature: "Admin users", starter: "1", farm: "5", enterprise: "Unlimited" },
{ feature: "Products limit", starter: "25", farm: "Unlimited", enterprise: "Unlimited" },
{ feature: "Reporting", starter: "Standard", farm: "Advanced", enterprise: "Advanced + AI" },
{ feature: "Email support", starter: true, farm: true, enterprise: "Dedicated" },
{ feature: "Wholesale Portal", starter: false, farm: true, enterprise: true },
{ feature: "Harvest Reach (Email & SMS)", starter: false, farm: true, enterprise: true },
{ feature: "AI Intelligence Pack", starter: false, farm: false, enterprise: true },
{ feature: "SMS Campaigns", starter: false, farm: false, enterprise: true },
{ feature: "Square Inventory Sync", starter: false, farm: false, enterprise: true },
{ feature: "Water Log", starter: false, farm: false, enterprise: true },
{ feature: "Unlimited brands", starter: false, farm: false, enterprise: true },
{ feature: "Custom development", starter: false, farm: false, enterprise: true },
{ feature: "SLA guarantee", starter: false, farm: false, enterprise: true },
];
export default function PricingClientPage() { export default function PricingClientPage() {
const [billingCycle, setBillingCycle] = useState<BillingCycle>("annual"); const [billingCycle, setBillingCycle] = useState<BillingCycle>("annual");
const [compareOpen, setCompareOpen] = useState(false); const [compareOpen, setCompareOpen] = useState(false);
@@ -61,336 +95,490 @@ export default function PricingClientPage() {
return ( return (
<div className="min-h-screen bg-white"> <div className="min-h-screen bg-white">
{/* ── Nav bar ─────────────────────────────────────────────────────────── */} <NavBar />
<header className="sticky top-0 z-50 border-b border-slate-100 bg-white/95 backdrop-blur"> <Hero
<div className="mx-auto flex max-w-6xl items-center justify-between px-6 py-4"> billingCycle={billingCycle}
<div className="flex items-center gap-3"> onCycleChange={setBillingCycle}
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-emerald-600 to-emerald-500 text-white font-bold text-sm" aria-hidden="true"> />
RC <PlansSection
</div> billingCycle={billingCycle}
<span className="text-lg font-bold text-slate-900">Route Commerce</span> compareOpen={compareOpen}
</div> onToggleCompare={() => setCompareOpen((v) => !v)}
<nav className="flex items-center gap-6" aria-label="Main navigation"> onCloseCompare={() => setCompareOpen(false)}
<Link href="/about" className="text-sm text-slate-500 hover:text-slate-900 transition-colors">About</Link> />
<Link href="/pricing" className="text-sm font-medium text-slate-900" aria-current="page">Pricing</Link> <AddOnsSection billingCycle={billingCycle} />
<Link <Testimonials />
href="/admin" <FAQSection openFaq={openFaq} onToggle={toggleFaq} />
className="rounded-lg bg-gradient-to-r from-emerald-600 to-emerald-500 px-4 py-2 text-sm font-medium text-white hover:from-emerald-500 hover:to-emerald-400 transition-all shadow-md" <CTA />
> <SiteFooter />
Get Started
</Link>
</nav>
</div>
</header>
{/* ── Hero ────────────────────────────────────────────────────────────── */}
<section className="bg-gradient-to-b from-slate-50 to-white px-4 sm:px-6 py-16 sm:py-20 text-center" aria-labelledby="pricing-heading">
<div className="mx-auto max-w-3xl">
<div className="mb-4 inline-flex items-center gap-2 rounded-full bg-emerald-50 px-3 sm:px-4 py-1.5 text-xs sm:text-sm font-medium text-emerald-700">
<span className="text-xs" aria-hidden="true"></span>
Built for produce wholesale operations
</div>
<h1 id="pricing-heading" className="text-4xl sm:text-5xl md:text-6xl font-bold tracking-tight text-slate-900">
Pricing that scales<br className="hidden sm:block" /> with your operation
</h1>
<p className="mt-4 sm:mt-6 text-lg sm:text-xl text-slate-500">
From small farms to enterprise distributors everything you need to manage orders, stops, communications, and billing in one platform.
</p>
<div className="mt-6 sm:mt-8 flex items-center justify-center gap-4 flex-wrap">
<BillingToggle cycle={billingCycle} onChange={setBillingCycle} />
<span className="text-xs sm:text-sm text-emerald-600 font-medium">Save 25% with annual</span>
</div>
</div>
</section>
{/* ── Plan cards ───────────────────────────────────────────────────────── */}
<section className="mx-auto max-w-6xl px-4 sm:px-6 py-4 sm:py-6" aria-labelledby="plans-heading">
<h2 id="plans-heading" className="sr-only">Available Plans</h2>
<div className="grid gap-6 lg:grid-cols-3">
{(Object.entries(PLAN_TIERS) as [keyof typeof PLAN_TIERS, typeof PLAN_TIERS[keyof typeof PLAN_TIERS]][]).map(([key, plan]) => {
const price = billingCycle === "annual" ? plan.annualPrice : plan.monthlyPrice;
const monthlyEquivalent = billingCycle === "annual" && plan.annualPrice ? Math.round(plan.annualPrice / 12) : null;
const isMostPopular = plan.highlighted;
return (
<article
key={key}
className={`relative flex flex-col rounded-2xl border-2 p-5 sm:p-6 transition-transform hover:-translate-y-1 ${
isMostPopular ? "border-emerald-500 shadow-lg shadow-emerald-100" : "border-slate-200 shadow-sm"
}`}
>
{isMostPopular && (
<div className="absolute -top-3 left-1/2 -translate-x-1/2 rounded-full bg-gradient-to-r from-emerald-600 to-emerald-500 px-3 sm:px-4 py-1 text-xs font-bold text-white uppercase tracking-wide shadow-md">
Most Popular
</div>
)}
<div className="mb-4 sm:mb-5">
<h3 className="text-base sm:text-lg font-bold text-slate-900">{plan.label}</h3>
<p className="mt-1 text-xs sm:text-sm text-slate-500">{plan.description}</p>
</div>
<div className="mb-1">
<span className="text-3xl sm:text-4xl font-bold text-slate-900">${price}</span>
<span className="ml-1 text-slate-400 text-sm">/{billingCycle === "annual" ? "yr" : "mo"}</span>
</div>
{monthlyEquivalent !== null && (
<p className="mb-3 sm:mb-4 text-xs text-slate-400">${monthlyEquivalent}/mo equivalent</p>
)}
<Link
href="/admin"
className={`mt-auto rounded-xl px-4 py-2.5 text-center text-sm font-medium transition-all ${
isMostPopular
? "bg-gradient-to-r from-emerald-600 to-emerald-500 text-white hover:from-emerald-500 hover:to-emerald-400 shadow-md"
: "border border-slate-300 text-slate-700 hover:bg-slate-50"
}`}
>
{key === "enterprise" ? "Contact Sales" : "Get Started"}
</Link>
<ul className="mt-6 space-y-2.5" aria-label={`${plan.label} features`}>
{(plan.features as readonly string[]).map((f) => (
<li key={f} className="flex items-start gap-2.5 text-sm text-slate-600">
<svg className="mt-0.5 h-4 w-4 text-emerald-500 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
{f}
</li>
))}
</ul>
</article>
);
})}
</div>
<div className="mt-6 flex items-center justify-center">
<button type="button"
onClick={() => setCompareOpen(!compareOpen)}
className="text-sm text-emerald-600 hover:underline font-medium transition-colors"
aria-expanded={compareOpen}
aria-controls="compare-table"
>
{compareOpen ? "Hide" : "Compare"} all features
</button>
</div>
{/* Compare table */}
{compareOpen && (
<div id="compare-table" className="mt-6 rounded-2xl border border-slate-200 overflow-hidden shadow-sm">
<div className="border-b border-slate-100 bg-slate-50 px-6 py-3 flex items-center justify-between">
<h3 className="text-sm font-semibold text-slate-900">Full Feature Comparison</h3>
<button type="button"
onClick={() => setCompareOpen(false)}
className="text-xs text-slate-400 hover:text-slate-600 transition-colors"
aria-label="Close comparison table"
>
Close
</button>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-100">
<th className="py-3 pr-6 text-left font-semibold text-slate-500 w-2/5" scope="col">Feature</th>
{(["starter", "farm", "enterprise"] as const).map((tier) => {
const p = PLAN_TIERS[tier];
return (
<th key={tier} className="py-3 px-4 text-center" scope="col">
<span className={`rounded-full px-3 py-0.5 text-xs font-bold uppercase ${p.color}`}>{p.label}</span>
</th>
);
})}
</tr>
</thead>
<tbody>
{[
["Products catalog", { starter: true, farm: true, enterprise: true }],
["Stop / route management", { starter: "10/mo", farm: "Unlimited", enterprise: "Unlimited" }],
["Orders processing", { starter: true, farm: true, enterprise: true }],
["Pickup & fulfillment", { starter: true, farm: true, enterprise: true }],
["Admin users", { starter: "1", farm: "5", enterprise: "Unlimited" }],
["Products limit", { starter: "25", farm: "Unlimited", enterprise: "Unlimited" }],
["Reporting", { starter: "Standard", farm: "Advanced", enterprise: "Advanced + AI" }],
["Email support", { starter: true, farm: true, enterprise: "Dedicated" }],
["Wholesale Portal", { starter: false, farm: true, enterprise: true }],
["Harvest Reach (Email & SMS)", { starter: false, farm: true, enterprise: true }],
["AI Intelligence Pack", { starter: false, farm: false, enterprise: true }],
["SMS Campaigns", { starter: false, farm: false, enterprise: true }],
["Square Inventory Sync", { starter: false, farm: false, enterprise: true }],
["Water Log", { starter: false, farm: false, enterprise: true }],
["Unlimited brands", { starter: false, farm: false, enterprise: true }],
["Custom development", { starter: false, farm: false, enterprise: true }],
["SLA guarantee", { starter: false, farm: false, enterprise: true }],
].map(([feature, tiers], rowIndex) => {
const t = tiers as Record<string, boolean | string>;
return (
<tr key={feature as string} className={`border-t ${rowIndex % 2 === 0 ? 'bg-white' : 'bg-slate-50/50'}`}>
<td className="py-2.5 pr-6 text-slate-600">{feature as string}</td>
{(["starter", "farm", "enterprise"] as const).map((tier) => {
const val = t[tier];
return (
<td key={tier} className="py-2.5 px-4 text-center text-sm">
{val === true ? (
<svg className="h-4 w-4 text-emerald-500 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-label="Included">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : val === false ? (
<span className="text-slate-300" aria-label="Not included"></span>
) : (
<span className="text-slate-700">{val}</span>
)}
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
</section>
{/* ── Add-ons ───────────────────────────────────────────────────────────── */}
<section className="border-t border-slate-100 bg-slate-50 px-6 py-16" aria-labelledby="addons-heading">
<div className="mx-auto max-w-6xl">
<h2 id="addons-heading" className="text-center text-3xl font-bold text-slate-900 mb-2">Power-Up with Add-ons</h2>
<p className="text-center text-slate-500 mb-10">Add capabilities à la carte on any plan. No bundles required.</p>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{ADDON_LIST.map(({ key, label, icon, description }) => {
const addon = ADDONS[key as keyof typeof ADDONS];
if (!addon) return null;
const price = billingCycle === "annual" ? addon.annualPrice : addon.monthlyPrice;
const monthlyEquiv = billingCycle === "annual" ? Math.round(addon.annualPrice / 12) : null;
return (
<article key={key} className="flex items-start gap-4 rounded-2xl border border-slate-200 bg-white p-5 transition-all hover:border-emerald-200 hover:shadow-md">
<span className="text-2xl leading-none" aria-hidden="true">{icon}</span>
<div className="flex-1">
<h3 className="font-semibold text-slate-900">{label}</h3>
<p className="mt-1 text-xs text-slate-500">{description}</p>
<div className="mt-3 flex items-baseline gap-1">
<span className="text-xl font-bold text-slate-900">${price}</span>
<span className="text-xs text-slate-400">/{billingCycle === "annual" ? "yr" : "mo"}</span>
{monthlyEquiv !== null && (
<span className="ml-1 text-xs text-slate-400">(${monthlyEquiv}/mo equiv.)</span>
)}
</div>
</div>
</article>
);
})}
</div>
</div>
</section>
{/* ── Social proof ─────────────────────────────────────────────────────── */}
<section className="border-t border-slate-100 px-6 py-16" aria-labelledby="testimonials-heading">
<div className="mx-auto max-w-6xl">
<h2 id="testimonials-heading" className="text-center text-2xl font-bold text-slate-900 mb-8">Trusted by produce operations across the US</h2>
<div className="grid gap-6 sm:grid-cols-3">
{[
{ quote: "We went from managing 12 spreadsheets to one platform. Route Commerce cut our order chaos by 80%.", name: "Marcus T., Fresh Fields Farm", location: "California" },
{ quote: "Harvest Reach alone paid for the subscription. Our pickup rate went from 70% to 94% in two months.", name: "Sandra K., Pacific Produce Co-op", location: "Oregon" },
{ quote: "The wholesale portal saved us 6 hours a week on order entry. Buyers love the self-service.", name: "James R., Gulf Coast Distribution", location: "Florida" },
].map((item, i) => (
<article key={`${item.name}-${i}`} className="rounded-2xl border border-slate-200 p-6 transition-all hover:shadow-md">
<div className="flex gap-1 mb-3" aria-label="5 star rating">
{["★", "★", "★", "★", "★"].map((s, j) => (
<span key={`star-${i}-${j}`} className="text-amber-400 text-sm" aria-hidden="true">{s}</span>
))}
</div>
<blockquote>
<p className="text-slate-700 text-sm leading-relaxed">&ldquo;{item.quote}&rdquo;</p>
</blockquote>
<p className="mt-3 text-xs font-semibold text-slate-900">{item.name}</p>
<p className="text-xs text-slate-400">{item.location}</p>
</article>
))}
</div>
</div>
</section>
{/* ── FAQ ─────────────────────────────────────────────────────────────── */}
<section className="border-t border-slate-100 bg-slate-50 px-6 py-16" aria-labelledby="faq-heading">
<div className="mx-auto max-w-3xl">
<h2 id="faq-heading" className="text-center text-3xl font-bold text-slate-900 mb-10">Frequently Asked Questions</h2>
<div className="space-y-3">
{FAQ_ITEMS.map((item, i) => (
<div key={item.q} className="rounded-xl border border-slate-200 bg-white">
<button type="button"
onClick={() => toggleFaq(i)}
className="flex w-full items-center justify-between px-5 py-4 text-left transition-colors hover:bg-slate-50"
aria-expanded={openFaq === i}
aria-controls={`faq-answer-${i}`}
>
<span className="text-sm font-medium text-slate-900">{item.q}</span>
<span
className={`ml-3 text-slate-400 text-lg transition-transform ${openFaq === i ? "rotate-45" : ""}`}
aria-hidden="true"
>
+
</span>
</button>
{openFaq === i && (
<div id={`faq-answer-${i}`} className="border-t border-slate-50 px-5 pb-4">
<p className="text-sm text-slate-600 leading-relaxed">{item.a}</p>
</div>
)}
</div>
))}
</div>
</div>
</section>
{/* ── CTA ──────────────────────────────────────────────────────────────── */}
<section className="bg-gradient-to-br from-slate-900 to-slate-800 px-6 py-20 text-center" aria-labelledby="cta-heading">
<div className="mx-auto max-w-2xl">
<h2 id="cta-heading" className="text-4xl font-bold text-white">Ready to grow your operation?</h2>
<p className="mt-4 text-lg text-slate-400">Start free on Starter. No credit card required. Upgrade when you&apos;re ready.</p>
<div className="mt-8 flex items-center justify-center gap-4 flex-wrap">
<Link
href="/admin"
className="rounded-xl bg-gradient-to-r from-emerald-500 to-emerald-400 px-8 py-3 text-base font-bold text-white hover:from-emerald-400 hover:to-emerald-300 transition-all shadow-lg shadow-emerald-500/25"
>
Start for Free
</Link>
<Link
href="/contact"
className="rounded-xl border border-slate-600 px-8 py-3 text-base font-medium text-slate-300 hover:border-slate-500 hover:text-white transition-all"
>
Talk to Us
</Link>
</div>
<p className="mt-6 text-xs text-slate-500">
Invoiced by Cielo Hermosa, LLC · <a href="mailto:billing@cielohermosa.com" className="underline hover:text-slate-400 transition-colors">billing@routecommerce.com</a>
</p>
</div>
</section>
{/* ── Footer ──────────────────────────────────────────────────────────── */}
<footer className="border-t border-slate-200 bg-white px-6 py-8">
<div className="mx-auto flex max-w-6xl items-center justify-between flex-wrap gap-4">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-emerald-600 to-emerald-500 text-white font-bold text-xs" aria-hidden="true">RC</div>
<span className="text-sm font-bold text-slate-700">Route Commerce</span>
</div>
<nav className="flex items-center gap-6" aria-label="Footer navigation">
<Link href="/privacy-policy" className="text-xs text-slate-400 hover:text-slate-600 transition-colors">Privacy</Link>
<Link href="/terms-and-conditions" className="text-xs text-slate-400 hover:text-slate-600 transition-colors">Terms</Link>
<a href="mailto:team@cielohermosa.com" className="text-xs text-slate-400 hover:text-slate-600 transition-colors">Contact</a>
</nav>
</div>
</footer>
</div> </div>
); );
} }
function NavBar() {
return (
<header className="sticky top-0 z-50 border-b border-slate-100 bg-white/95 backdrop-blur">
<div className="mx-auto flex max-w-6xl items-center justify-between px-6 py-4">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-emerald-600 to-emerald-500 text-white font-bold text-sm" aria-hidden="true">
RC
</div>
<span className="text-lg font-bold text-slate-900">Route Commerce</span>
</div>
<nav className="flex items-center gap-6" aria-label="Main navigation">
<Link href="/about" className="text-sm text-slate-500 hover:text-slate-900 transition-colors">About</Link>
<Link href="/pricing" className="text-sm font-medium text-slate-900" aria-current="page">Pricing</Link>
<Link
href="/admin"
className="rounded-lg bg-gradient-to-r from-emerald-600 to-emerald-500 px-4 py-2 text-sm font-medium text-white hover:from-emerald-500 hover:to-emerald-400 transition-all shadow-md"
>
Get Started
</Link>
</nav>
</div>
</header>
);
}
function Hero({ billingCycle, onCycleChange }: { billingCycle: BillingCycle; onCycleChange: (c: BillingCycle) => void }) {
return (
<section className="bg-gradient-to-b from-slate-50 to-white px-4 sm:px-6 py-16 sm:py-20 text-center" aria-labelledby="pricing-heading">
<div className="mx-auto max-w-3xl">
<div className="mb-4 inline-flex items-center gap-2 rounded-full bg-emerald-50 px-3 sm:px-4 py-1.5 text-xs sm:text-sm font-medium text-emerald-700">
<span className="text-xs" aria-hidden="true"></span>
Built for produce wholesale operations
</div>
<h1 id="pricing-heading" className="text-4xl sm:text-5xl md:text-6xl font-bold tracking-tight text-slate-900">
Pricing that scales<br className="hidden sm:block" /> with your operation
</h1>
<p className="mt-4 sm:mt-6 text-lg sm:text-xl text-slate-500">
From small farms to enterprise distributors everything you need to manage orders, stops, communications, and billing in one platform.
</p>
<div className="mt-6 sm:mt-8 flex items-center justify-center gap-4 flex-wrap">
<BillingToggle cycle={billingCycle} onChange={onCycleChange} />
<span className="text-xs sm:text-sm text-emerald-600 font-medium">Save 25% with annual</span>
</div>
</div>
</section>
);
}
function PlansSection({
billingCycle,
compareOpen,
onToggleCompare,
onCloseCompare,
}: {
billingCycle: BillingCycle;
compareOpen: boolean;
onToggleCompare: () => void;
onCloseCompare: () => void;
}) {
return (
<section className="mx-auto max-w-6xl px-4 sm:px-6 py-4 sm:py-6" aria-labelledby="plans-heading">
<h2 id="plans-heading" className="sr-only">Available Plans</h2>
<div className="grid gap-6 lg:grid-cols-3">
{(Object.entries(PLAN_TIERS) as [keyof typeof PLAN_TIERS, typeof PLAN_TIERS[keyof typeof PLAN_TIERS]][]).map(([key, plan]) => (
<PlanCard
key={key}
planKey={key}
plan={plan}
billingCycle={billingCycle}
/>
))}
</div>
<div className="mt-6 flex items-center justify-center">
<button type="button"
onClick={onToggleCompare}
className="text-sm text-emerald-600 hover:underline font-medium transition-colors"
aria-expanded={compareOpen}
aria-controls="compare-table"
>
{compareOpen ? "Hide" : "Compare"} all features
</button>
</div>
{compareOpen && <CompareTable onClose={onCloseCompare} />}
</section>
);
}
function PlanCard({
planKey,
plan,
billingCycle,
}: {
planKey: keyof typeof PLAN_TIERS;
plan: typeof PLAN_TIERS[keyof typeof PLAN_TIERS];
billingCycle: BillingCycle;
}) {
const price = billingCycle === "annual" ? plan.annualPrice : plan.monthlyPrice;
const monthlyEquivalent = billingCycle === "annual" && plan.annualPrice ? Math.round(plan.annualPrice / 12) : null;
const isMostPopular = plan.highlighted;
const isEnterprise = planKey === "enterprise";
return (
<article
className={`relative flex flex-col rounded-2xl border-2 p-5 sm:p-6 transition-transform hover:-translate-y-1 ${
isMostPopular ? "border-emerald-500 shadow-lg shadow-emerald-100" : "border-slate-200 shadow-sm"
}`}
>
{isMostPopular && (
<div className="absolute -top-3 left-1/2 -translate-x-1/2 rounded-full bg-gradient-to-r from-emerald-600 to-emerald-500 px-3 sm:px-4 py-1 text-xs font-bold text-white uppercase tracking-wide shadow-md">
Most Popular
</div>
)}
<div className="mb-4 sm:mb-5">
<h3 className="text-base sm:text-lg font-bold text-slate-900">{plan.label}</h3>
<p className="mt-1 text-xs sm:text-sm text-slate-500">{plan.description}</p>
</div>
<div className="mb-1">
<span className="text-3xl sm:text-4xl font-bold text-slate-900">${price}</span>
<span className="ml-1 text-slate-400 text-sm">/{billingCycle === "annual" ? "yr" : "mo"}</span>
</div>
{monthlyEquivalent !== null && (
<p className="mb-3 sm:mb-4 text-xs text-slate-400">${monthlyEquivalent}/mo equivalent</p>
)}
<Link
href="/admin"
className={`mt-auto rounded-xl px-4 py-2.5 text-center text-sm font-medium transition-all ${
isMostPopular
? "bg-gradient-to-r from-emerald-600 to-emerald-500 text-white hover:from-emerald-500 hover:to-emerald-400 shadow-md"
: "border border-slate-300 text-slate-700 hover:bg-slate-50"
}`}
>
{isEnterprise ? "Contact Sales" : "Get Started"}
</Link>
<ul className="mt-6 space-y-2.5" aria-label={`${plan.label} features`}>
{(plan.features as readonly string[]).map((f) => (
<PlanFeature key={f} feature={f} />
))}
</ul>
</article>
);
}
function PlanFeature({ feature }: { feature: string }) {
return (
<li className="flex items-start gap-2.5 text-sm text-slate-600">
<svg className="mt-0.5 h-4 w-4 text-emerald-500 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
{feature}
</li>
);
}
function CompareTable({ onClose }: { onClose: () => void }) {
return (
<div id="compare-table" className="mt-6 rounded-2xl border border-slate-200 overflow-hidden shadow-sm">
<div className="border-b border-slate-100 bg-slate-50 px-6 py-3 flex items-center justify-between">
<h3 className="text-sm font-semibold text-slate-900">Full Feature Comparison</h3>
<button type="button"
onClick={onClose}
className="text-xs text-slate-400 hover:text-slate-600 transition-colors"
aria-label="Close comparison table"
>
Close
</button>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-100">
<th className="py-3 pr-6 text-left font-semibold text-slate-500 w-2/5" scope="col">Feature</th>
{(["starter", "farm", "enterprise"] as const).map((tier) => (
<CompareHeaderCell key={tier} tier={tier} />
))}
</tr>
</thead>
<tbody>
{COMPARE_ROWS.map((row, rowIndex) => (
<CompareRow key={row.feature} row={row} rowIndex={rowIndex} />
))}
</tbody>
</table>
</div>
</div>
);
}
function CompareHeaderCell({ tier }: { tier: CompareTier }) {
const p = PLAN_TIERS[tier];
return (
<th className="py-3 px-4 text-center" scope="col">
<span className={`rounded-full px-3 py-0.5 text-xs font-bold uppercase ${p.color}`}>{p.label}</span>
</th>
);
}
function CompareRow({ row, rowIndex }: { row: CompareRow; rowIndex: number }) {
return (
<tr className={`border-t ${rowIndex % 2 === 0 ? 'bg-white' : 'bg-slate-50/50'}`}>
<td className="py-2.5 pr-6 text-slate-600">{row.feature}</td>
{(["starter", "farm", "enterprise"] as const).map((tier) => (
<CompareCell key={tier} value={row[tier]} />
))}
</tr>
);
}
function CompareCell({ value }: { value: boolean | string }) {
if (value === true) {
return (
<td className="py-2.5 px-4 text-center text-sm">
<svg className="h-4 w-4 text-emerald-500 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-label="Included">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</td>
);
}
if (value === false) {
return (
<td className="py-2.5 px-4 text-center text-sm">
<span className="text-slate-300" aria-label="Not included"></span>
</td>
);
}
return (
<td className="py-2.5 px-4 text-center text-sm">
<span className="text-slate-700">{value}</span>
</td>
);
}
function AddOnsSection({ billingCycle }: { billingCycle: BillingCycle }) {
return (
<section className="border-t border-slate-100 bg-slate-50 px-6 py-16" aria-labelledby="addons-heading">
<div className="mx-auto max-w-6xl">
<h2 id="addons-heading" className="text-center text-3xl font-bold text-slate-900 mb-2">Power-Up with Add-ons</h2>
<p className="text-center text-slate-500 mb-10">Add capabilities à la carte on any plan. No bundles required.</p>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{ADDON_LIST.map(({ key, label, icon, description }) => (
<AddOnCard
key={key}
addonKey={key as keyof typeof ADDONS}
label={label}
icon={icon}
description={description}
billingCycle={billingCycle}
/>
))}
</div>
</div>
</section>
);
}
function AddOnCard({
addonKey,
label,
icon,
description,
billingCycle,
}: {
addonKey: keyof typeof ADDONS;
label: string;
icon: string;
description: string;
billingCycle: BillingCycle;
}) {
const addon = ADDONS[addonKey];
if (!addon) return null;
const price = billingCycle === "annual" ? addon.annualPrice : addon.monthlyPrice;
const monthlyEquiv = billingCycle === "annual" ? Math.round(addon.annualPrice / 12) : null;
return (
<article className="flex items-start gap-4 rounded-2xl border border-slate-200 bg-white p-5 transition-all hover:border-emerald-200 hover:shadow-md">
<span className="text-2xl leading-none" aria-hidden="true">{icon}</span>
<div className="flex-1">
<h3 className="font-semibold text-slate-900">{label}</h3>
<p className="mt-1 text-xs text-slate-500">{description}</p>
<div className="mt-3 flex items-baseline gap-1">
<span className="text-xl font-bold text-slate-900">${price}</span>
<span className="text-xs text-slate-400">/{billingCycle === "annual" ? "yr" : "mo"}</span>
{monthlyEquiv !== null && (
<span className="ml-1 text-xs text-slate-400">(${monthlyEquiv}/mo equiv.)</span>
)}
</div>
</div>
</article>
);
}
function Testimonials() {
// Audit 2026-06-26: previously rendered fabricated named testimonials.
// Replaced with a single honest line until real, attributed customer
// quotes are collected and consented.
if (TESTIMONIALS.length === 0) {
return (
<section className="border-t border-slate-100 px-6 py-16" aria-labelledby="testimonials-heading">
<div className="mx-auto max-w-3xl text-center">
<h2 id="testimonials-heading" className="text-2xl font-bold text-slate-900 mb-3">
Early access
</h2>
<p className="text-slate-500">
We&apos;re in early access with produce brands across the country. Customer stories coming soon &nbsp;
<a href="/contact" className="text-emerald-600 hover:underline font-medium">
talk to us
</a>
&nbsp;if you&apos;d like to be a reference customer.
</p>
</div>
</section>
);
}
return (
<section className="border-t border-slate-100 px-6 py-16" aria-labelledby="testimonials-heading">
<div className="mx-auto max-w-6xl">
<h2 id="testimonials-heading" className="text-center text-2xl font-bold text-slate-900 mb-8">Trusted by produce operations across the US</h2>
<div className="grid gap-6 sm:grid-cols-3">
{TESTIMONIALS.map((item, i) => (
<TestimonialCard key={`${item.name}-${i}`} item={item} index={i} />
))}
</div>
</div>
</section>
);
}
function TestimonialCard({ item, index }: { item: { quote: string; name: string; location: string }; index: number }) {
return (
<article className="rounded-2xl border border-slate-200 p-6 transition-all hover:shadow-md">
<div className="flex gap-1 mb-3" aria-label="5 star rating">
{["★", "★", "★", "★", "★"].map((s, j) => (
<span key={`star-${index}-${j}`} className="text-amber-400 text-sm" aria-hidden="true">{s}</span>
))}
</div>
<blockquote>
<p className="text-slate-700 text-sm leading-relaxed">&ldquo;{item.quote}&rdquo;</p>
</blockquote>
<p className="mt-3 text-xs font-semibold text-slate-900">{item.name}</p>
<p className="text-xs text-slate-400">{item.location}</p>
</article>
);
}
function FAQSection({
openFaq,
onToggle,
}: {
openFaq: number | null;
onToggle: (index: number) => void;
}) {
return (
<section className="border-t border-slate-100 bg-slate-50 px-6 py-16" aria-labelledby="faq-heading">
<div className="mx-auto max-w-3xl">
<h2 id="faq-heading" className="text-center text-3xl font-bold text-slate-900 mb-10">Frequently Asked Questions</h2>
<div className="space-y-3">
{FAQ_ITEMS.map((item, i) => (
<FAQItem
key={item.q}
question={item.q}
answer={item.a}
isOpen={openFaq === i}
onToggle={() => onToggle(i)}
index={i}
/>
))}
</div>
</div>
</section>
);
}
function FAQItem({
question,
answer,
isOpen,
onToggle,
index,
}: {
question: string;
answer: string;
isOpen: boolean;
onToggle: () => void;
index: number;
}) {
return (
<div className="rounded-xl border border-slate-200 bg-white">
<button type="button"
onClick={onToggle}
className="flex w-full items-center justify-between px-5 py-4 text-left transition-colors hover:bg-slate-50"
aria-expanded={isOpen}
aria-controls={`faq-answer-${index}`}
>
<span className="text-sm font-medium text-slate-900">{question}</span>
<span
className={`ml-3 text-slate-400 text-lg transition-transform ${isOpen ? "rotate-45" : ""}`}
aria-hidden="true"
>
+
</span>
</button>
{isOpen && (
<div id={`faq-answer-${index}`} className="border-t border-slate-50 px-5 pb-4">
<p className="text-sm text-slate-600 leading-relaxed">{answer}</p>
</div>
)}
</div>
);
}
function CTA() {
return (
<section className="bg-gradient-to-br from-slate-900 to-slate-800 px-6 py-20 text-center" aria-labelledby="cta-heading">
<div className="mx-auto max-w-2xl">
<h2 id="cta-heading" className="text-4xl font-bold text-white">Ready to grow your operation?</h2>
<p className="mt-4 text-lg text-slate-400">Start free on Starter. No credit card required. Upgrade when you&apos;re ready.</p>
<div className="mt-8 flex items-center justify-center gap-4 flex-wrap">
<Link
href="/admin"
className="rounded-xl bg-gradient-to-r from-emerald-500 to-emerald-400 px-8 py-3 text-base font-bold text-white hover:from-emerald-400 hover:to-emerald-300 transition-all shadow-lg shadow-emerald-500/25"
>
Start for Free
</Link>
<Link
href="/contact"
className="rounded-xl border border-slate-600 px-8 py-3 text-base font-medium text-slate-300 hover:border-slate-500 hover:text-white transition-all"
>
Talk to Us
</Link>
</div>
<p className="mt-6 text-xs text-slate-500">
Invoiced by Cielo Hermosa, LLC · <a href="mailto:billing@cielohermosa.com" className="underline hover:text-slate-400 transition-colors">billing@routecommerce.com</a>
</p>
</div>
</section>
);
}
function SiteFooter() {
return (
<footer className="border-t border-slate-200 bg-white px-6 py-8">
<div className="mx-auto flex max-w-6xl items-center justify-between flex-wrap gap-4">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-emerald-600 to-emerald-500 text-white font-bold text-xs" aria-hidden="true">RC</div>
<span className="text-sm font-bold text-slate-700">Route Commerce</span>
</div>
<nav className="flex items-center gap-6" aria-label="Footer navigation">
<Link href="/privacy-policy" className="text-xs text-slate-400 hover:text-slate-600 transition-colors">Privacy</Link>
<Link href="/terms-and-conditions" className="text-xs text-slate-400 hover:text-slate-600 transition-colors">Terms</Link>
<a href="mailto:team@cielohermosa.com" className="text-xs text-slate-400 hover:text-slate-600 transition-colors">Contact</a>
</nav>
</div>
</footer>
);
}
function BillingToggle({ cycle, onChange }: { cycle: BillingCycle; onChange: (c: BillingCycle) => void }) { function BillingToggle({ cycle, onChange }: { cycle: BillingCycle; onChange: (c: BillingCycle) => void }) {
return ( return (
<fieldset className="flex items-center gap-3 border-0 p-0 m-0" aria-label="Billing cycle selection"> <fieldset className="flex items-center gap-3 border-0 p-0 m-0" aria-label="Billing cycle selection">
<button type="button" <button type="button"
onClick={() => onChange("monthly")} onClick={() => onChange("monthly")}
className={`rounded-lg border px-4 py-1.5 text-sm font-medium transition-all ${ className={`rounded-lg border px-4 py-1.5 text-sm font-medium transition-all ${
cycle === "monthly" cycle === "monthly"
? "border-emerald-300 bg-white text-slate-900 shadow-sm" ? "border-emerald-300 bg-white text-slate-900 shadow-sm"
: "border-slate-200 bg-slate-50 text-slate-400 hover:border-slate-300" : "border-slate-200 bg-slate-50 text-slate-400 hover:border-slate-300"
}`} }`}
aria-pressed={cycle === "monthly"} aria-pressed={cycle === "monthly"}
@@ -400,8 +588,8 @@ function BillingToggle({ cycle, onChange }: { cycle: BillingCycle; onChange: (c:
<button type="button" <button type="button"
onClick={() => onChange("annual")} onClick={() => onChange("annual")}
className={`rounded-lg border px-4 py-1.5 text-sm font-medium transition-all flex items-center gap-1.5 ${ className={`rounded-lg border px-4 py-1.5 text-sm font-medium transition-all flex items-center gap-1.5 ${
cycle === "annual" cycle === "annual"
? "border-2 border-emerald-600 bg-emerald-50 text-emerald-700 shadow-sm" ? "border-2 border-emerald-600 bg-emerald-50 text-emerald-700 shadow-sm"
: "border border-slate-200 bg-slate-50 text-slate-400 hover:border-slate-300" : "border border-slate-200 bg-slate-50 text-slate-400 hover:border-slate-300"
}`} }`}
aria-pressed={cycle === "annual"} aria-pressed={cycle === "annual"}
@@ -411,4 +599,4 @@ function BillingToggle({ cycle, onChange }: { cycle: BillingCycle; onChange: (c:
</button> </button>
</fieldset> </fieldset>
); );
} }
+3 -2
View File
@@ -19,7 +19,8 @@ export const metadata: Metadata = {
type: "website", type: "website",
images: [ images: [
{ {
url: "/og-default.jpg", // Audit 2026-06-26: only og-default.svg exists in /public.
url: "/og-default.svg",
width: 1200, width: 1200,
height: 630, height: 630,
alt: "Route Commerce Pricing", alt: "Route Commerce Pricing",
@@ -32,7 +33,7 @@ export const metadata: Metadata = {
description: "Simple, transparent pricing for produce wholesale operations.", description: "Simple, transparent pricing for produce wholesale operations.",
site: "@RouteCommerce", site: "@RouteCommerce",
creator: "@RouteCommerce", creator: "@RouteCommerce",
images: ["/og-default.jpg"], images: ["/og-default.svg"],
}, },
alternates: { alternates: {
canonical: `${BASE_URL}/pricing`, canonical: `${BASE_URL}/pricing`,
+20 -53
View File
@@ -45,6 +45,10 @@ const ROADMAP_ITEMS = {
{ id: 2, title: "Square Inventory Sync", description: "Two-way sync with Square POS", category: "Integrations", upvotes: 89 }, { id: 2, title: "Square Inventory Sync", description: "Two-way sync with Square POS", category: "Integrations", upvotes: 89 },
{ id: 3, title: "AI Intelligence Pack", description: "Campaign writer, pricing advisor, demand forecasting", category: "AI", upvotes: 156 }, { id: 3, title: "AI Intelligence Pack", description: "Campaign writer, pricing advisor, demand forecasting", category: "AI", upvotes: 156 },
{ id: 4, title: "Water Log Module", description: "Track irrigation and water usage", category: "Operations", upvotes: 67 }, { id: 4, title: "Water Log Module", description: "Track irrigation and water usage", category: "Operations", upvotes: 67 },
// Audit 2026-06-26: reclassified from "Planned" — both have code in tree
// (sms_campaigns feature flag + /api/ai/stop-blast-advisor; route-optimizer at /api/ai/route-optimizer).
{ id: 12, title: "SMS Campaigns", description: "Text message marketing and transactional SMS via Twilio", category: "Communication", upvotes: 98 },
{ id: 13, title: "Route Optimization", description: "AI-assisted route planning for delivery stops", category: "Logistics", upvotes: 167 },
], ],
inProgress: [ inProgress: [
{ id: 5, title: "Mobile App (iOS & Android)", description: "Native apps for field workers and delivery drivers", category: "Mobile", upvotes: 234 }, { id: 5, title: "Mobile App (iOS & Android)", description: "Native apps for field workers and delivery drivers", category: "Mobile", upvotes: 234 },
@@ -52,8 +56,6 @@ const ROADMAP_ITEMS = {
{ id: 7, title: "Multi-location Support", description: "Manage multiple farms or warehouses from one account", category: "Operations", upvotes: 145 }, { id: 7, title: "Multi-location Support", description: "Manage multiple farms or warehouses from one account", category: "Operations", upvotes: 145 },
], ],
planned: [ planned: [
{ id: 8, title: "SMS Campaigns", description: "Text message marketing and notifications", category: "Communication", upvotes: 98 },
{ id: 9, title: "Route Optimization", description: "AI-powered route planning for deliveries", category: "Logistics", upvotes: 167 },
{ id: 10, title: "POS Integration ( Clover, Toast)", description: "Additional POS system integrations", category: "Integrations", upvotes: 76 }, { id: 10, title: "POS Integration ( Clover, Toast)", description: "Additional POS system integrations", category: "Integrations", upvotes: 76 },
{ id: 11, title: "Customer Loyalty Program", description: "Points, rewards, and referral tracking", category: "Marketing", upvotes: 112 }, { id: 11, title: "Customer Loyalty Program", description: "Points, rewards, and referral tracking", category: "Marketing", upvotes: 112 },
], ],
@@ -190,64 +192,29 @@ export default function RoadmapPage() {
</div> </div>
</section> </section>
{/* Suggest Feature */} {/* Suggest Feature
Audit 2026-06-26: form previously had no submit handler — replaced
with a mailto: link to avoid a non-functional affordance. Re-implement
with a real API once the suggestion queue is ready. */}
<section id="suggest" className="py-16 bg-white border-t border-[#e5e5e5]"> <section id="suggest" className="py-16 bg-white border-t border-[#e5e5e5]">
<div className="max-w-2xl mx-auto px-6"> <div className="max-w-2xl mx-auto px-6">
<div className="text-center mb-8"> <div className="text-center mb-8">
<h2 className="text-3xl font-bold text-[#1a1a1a] mb-2" style={{ fontFamily: "var(--font-fraunces)" }}> <h2 className="text-3xl font-bold text-[#1a1a1a] mb-2" style={{ fontFamily: "var(--font-fraunces)" }}>
Suggest a Feature Suggest a Feature
</h2> </h2>
<p className="text-[#666]">Have an idea? We&apos;d love to hear it. Share your suggestion and vote on others.</p> <p className="text-[#666]">Have an idea? Email us and we&apos;ll add it to the list.</p>
</div>
<div className="bg-[#faf8f5] rounded-2xl p-6 border border-[#e5e5e5] text-center">
<a
href="mailto:hello@routecommerce.com?subject=Roadmap%20suggestion"
className="inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-[#1a4d2e] to-[#2d6a4f] text-white rounded-xl font-medium hover:from-[#2d6a4f] hover:to-[#1a4d2e] transition-all"
>
Suggest a Feature
</a>
<p className="mt-3 text-xs text-[#888]">
Opens your email client to hello@routecommerce.com
</p>
</div> </div>
<form className="bg-[#faf8f5] rounded-2xl p-6 border border-[#e5e5e5]">
<div className="space-y-4">
<div>
<label htmlFor="feature-title" className="block text-sm font-medium text-[#1a1a1a] mb-2">
Feature Title
</label>
<input aria-label="., Export Orders To CSV"
type="text"
id="feature-title"
placeholder="e.g., Export orders to CSV"
className="w-full px-4 py-3 rounded-xl border border-[#e5e5e5] bg-white text-[#1a1a1a] placeholder-[#888] focus:outline-none focus:ring-2 focus:ring-[#1a4d2e]/50 focus:border-[#1a4d2e] transition-all"
/>
</div>
<div>
<label htmlFor="feature-description" className="block text-sm font-medium text-[#1a1a1a] mb-2">
Description
</label>
<textarea aria-label="Describe The Feature And How It Would Help You..."
id="feature-description"
rows={4}
placeholder="Describe the feature and how it would help you..."
className="w-full px-4 py-3 rounded-xl border border-[#e5e5e5] bg-white text-[#1a1a1a] placeholder-[#888] focus:outline-none focus:ring-2 focus:ring-[#1a4d2e]/50 focus:border-[#1a4d2e] transition-all resize-none"
/>
</div>
<div>
<label htmlFor="feature-category" className="block text-sm font-medium text-[#1a1a1a] mb-2">
Category
</label>
<select aria-label="Feature Category"
id="feature-category"
className="w-full px-4 py-3 rounded-xl border border-[#e5e5e5] bg-white text-[#1a1a1a] focus:outline-none focus:ring-2 focus:ring-[#1a4d2e]/50 focus:border-[#1a4d2e] transition-all"
>
<option value="">Select a category</option>
{CATEGORIES.reduce<typeof CATEGORIES>((acc, c) => {
if (c !== "All") acc.push(c);
return acc;
}, []).map((category) => (
<option key={category} value={category.toLowerCase()}>{category}</option>
))}
</select>
</div>
<button
type="submit"
className="w-full px-6 py-3 bg-gradient-to-r from-[#1a4d2e] to-[#2d6a4f] text-white rounded-xl font-medium hover:from-[#2d6a4f] hover:to-[#1a4d2e] transition-all"
>
Submit Suggestion
</button>
</div>
</form>
</div> </div>
</section> </section>
+33 -27
View File
@@ -5,8 +5,8 @@ const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com"
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Security — Route Commerce", title: "Security — Route Commerce",
description: "Learn about Route Commerce security practices, data protection, and compliance. Enterprise-grade encryption and SOC 2 compliance.", description: "Learn about Route Commerce security practices and data protection. Built on Vercel, Neon Postgres, and Stripe.",
keywords: ["security", "data protection", "encryption", "SOC 2", "GDPR", "CCPA", "Route Commerce security", "compliance"], keywords: ["security", "data protection", "encryption", "TLS", "GDPR", "CCPA", "Route Commerce security", "privacy"],
authors: [{ name: "Route Commerce" }], authors: [{ name: "Route Commerce" }],
creator: "Route Commerce", creator: "Route Commerce",
publisher: "Route Commerce", publisher: "Route Commerce",
@@ -38,44 +38,50 @@ export const viewport: Viewport = {
// — avoids hydration mismatch from `new Date()` in JSX. // — avoids hydration mismatch from `new Date()` in JSX.
const CURRENT_YEAR = new Date().getFullYear(); const CURRENT_YEAR = new Date().getFullYear();
// Audit 2026-06-26: previously claimed SOC 2 Type II, quarterly pentests,
// 99.9% uptime SLA, and 2FA via Supabase Auth. None were evidenced.
// Re-scoped to the providers we actually use. Re-add Route Commerce's
// own compliance posture only after the audits ship.
const SECURITY_FEATURES = [ const SECURITY_FEATURES = [
{ {
title: "Bank-Level Encryption", title: "Encrypted in Transit & At Rest",
description: "All data is encrypted in transit using TLS 1.3 and at rest using AES-256 encryption.", description: "All traffic uses TLS 1.3; data is encrypted at rest by our providers (Vercel, Neon Postgres).",
icon: "🔒", icon: "🔒",
}, },
{ {
title: "SOC 2 Compliant", title: "Vercel Edge Network",
description: "Our infrastructure meets SOC 2 Type II standards for security, availability, and confidentiality.", description: "Application hosted on Vercel with automatic DDoS protection and a global CDN.",
icon: "✓",
},
{
title: "Regular Security Audits",
description: "We conduct quarterly penetration tests and annual security audits with certified third parties.",
icon: "🔍",
},
{
title: "99.9% Uptime SLA",
description: "Enterprise plan customers receive a 99.9% uptime guarantee with 24/7 monitoring.",
icon: "⚡", icon: "⚡",
}, },
{ {
title: "GDPR & CCPA Compliant", title: "Stripe Handles Payments",
description: "We comply with GDPR and CCPA regulations, giving you full control over your data.", description: "Card data is tokenized by Stripe — we never see or store card numbers. Stripe is PCI DSS Level 1 certified.",
icon: "💳",
},
{
title: "Neon Postgres with Branch Isolation",
description: "Database hosted on Neon Postgres. Brand data is isolated per-tenant at the application layer and through SECURITY DEFINER RPCs.",
icon: "🗄",
},
{
title: "GDPR-Aware Privacy Controls",
description: "Privacy policy and data-handling practices align with GDPR principles. See the Privacy Policy page for export and deletion procedures.",
icon: "🛡", icon: "🛡",
}, },
{ {
title: "Secure Authentication", title: "Neon Auth (Better Auth)",
description: "Supabase Auth provides secure, compliant authentication with 2FA support.", description: "Authentication is handled by Neon Auth. Passwords are hashed with bcrypt; sessions are server-signed cookies.",
icon: "🔑", icon: "🔑",
}, },
]; ];
// Audit 2026-06-26: removed fabricated "SOC 2 Compliant" and "PCI Compliant"
// badges. Replaced with badges scoped to providers we use.
const TRUST_BADGES = [ const TRUST_BADGES = [
{ label: "SSL Secured", icon: "🔒" }, { label: "TLS 1.3 in Transit", icon: "🔒" },
{ label: "SOC 2 Compliant", icon: "" }, { label: "Hosted on Vercel", icon: "" },
{ label: "GDPR Ready", icon: "🛡" }, { label: "Database on Neon", icon: "🗄" },
{ label: "PCI Compliant", icon: "💳" }, { label: "Payments by Stripe", icon: "💳" },
]; ];
export default function SecurityPage() { export default function SecurityPage() {
@@ -167,11 +173,11 @@ export default function SecurityPage() {
</div> </div>
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<div className="w-10 h-10 bg-[#1a4d2e]/10 rounded-lg flex items-center justify-center shrink-0"> <div className="w-10 h-10 bg-[#1a4d2e]/10 rounded-lg flex items-center justify-center shrink-0">
<span className="text-[#1a4d2e] font-bold text-sm">S</span> <span className="text-[#1a4d2e] font-bold text-sm">N</span>
</div> </div>
<div> <div>
<h3 className="font-semibold text-[#1a1a1a]">Supabase</h3> <h3 className="font-semibold text-[#1a1a1a]">Neon Postgres</h3>
<p className="text-sm text-[#666]">PostgreSQL database with built-in encryption and real-time capabilities</p> <p className="text-sm text-[#666]">Postgres with at-rest encryption, branch isolation, and connection pooling.</p>
</div> </div>
</div> </div>
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
+119 -66
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useReducer } from "react";
import { wholesaleLoginAction } from "@/actions/wholesale-auth"; import { wholesaleLoginAction } from "@/actions/wholesale-auth";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import WholesaleBenefits from "@/components/wholesale/WholesaleBenefits"; import WholesaleBenefits from "@/components/wholesale/WholesaleBenefits";
@@ -14,6 +14,81 @@ const BRANDS = [
{ id: "64294306-5f42-463d-a5e8-2ad6c81a96de", name: "Tuxedo Corn", slug: "tuxedo" }, { id: "64294306-5f42-463d-a5e8-2ad6c81a96de", name: "Tuxedo Corn", slug: "tuxedo" },
]; ];
type FormState = { email: string; password: string; brandId: string };
type FieldErrors = { email?: string; password?: string };
type State = {
form: FormState;
submitting: boolean;
googleSubmitting: boolean;
error: string | null;
fieldErrors: FieldErrors;
};
type Action =
| { type: "SET_EMAIL"; value: string }
| { type: "SET_PASSWORD"; value: string }
| { type: "SET_BRAND_ID"; value: string }
| { type: "SET_SUBMITTING"; value: boolean }
| { type: "SET_GOOGLE_SUBMITTING"; value: boolean }
| { type: "SET_ERROR"; value: string | null }
| { type: "SET_FIELD_ERRORS"; value: FieldErrors }
| { type: "CLEAR_FIELD_ERRORS" }
| { type: "CLEAR_FIELD_ERROR"; field: keyof FieldErrors }
| { type: "RESET_SUBMIT" };
function readInitialError(): string | null {
if (typeof window === "undefined") return null;
const params = new URLSearchParams(window.location.search);
const err = params.get("error");
if (err === "portal_disabled") {
return "The wholesale portal is currently disabled. Contact us for assistance.";
}
if (err === "account_not_active") {
return "Your account is not active. Please contact support or register for a new account.";
}
if (err === "invalid_credentials") {
return "Invalid email or password. Please try again.";
}
if (err === "oauth") {
return "Google sign-in failed or was cancelled. Please try again or use your password.";
}
return null;
}
const initialState: State = {
form: { email: "", password: "", brandId: BRANDS[1].id },
submitting: false,
googleSubmitting: false,
error: readInitialError(),
fieldErrors: {},
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_EMAIL":
return { ...state, form: { ...state.form, email: action.value } };
case "SET_PASSWORD":
return { ...state, form: { ...state.form, password: action.value } };
case "SET_BRAND_ID":
return { ...state, form: { ...state.form, brandId: action.value } };
case "SET_SUBMITTING":
return { ...state, submitting: action.value };
case "SET_GOOGLE_SUBMITTING":
return { ...state, googleSubmitting: action.value };
case "SET_ERROR":
return { ...state, error: action.value };
case "SET_FIELD_ERRORS":
return { ...state, fieldErrors: action.value };
case "CLEAR_FIELD_ERRORS":
return { ...state, fieldErrors: {} };
case "CLEAR_FIELD_ERROR":
return { ...state, fieldErrors: { ...state.fieldErrors, [action.field]: undefined } };
case "RESET_SUBMIT":
return { ...state, submitting: false };
}
}
function BrandLogo() { function BrandLogo() {
return ( return (
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-900/60 border border-emerald-700/50"> <div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-900/60 border border-emerald-700/50">
@@ -51,39 +126,15 @@ function FormField({ label, id, error, children }: {
export default function WholesaleLoginPage() { export default function WholesaleLoginPage() {
const router = useRouter(); const router = useRouter();
const [form, setForm] = useState({ email: "", password: "", brandId: BRANDS[1].id }); const [state, dispatch] = useReducer(reducer, initialState);
const [submitting, setSubmitting] = useState(false);
const [googleSubmitting, setGoogleSubmitting] = useState(false);
// Read ?error=... from the URL in the lazy initializer. Safe in a client
// component since window is always defined here, and avoids a
// set-state-in-effect on mount.
const [error, setError] = useState<string | null>(() => {
if (typeof window === "undefined") return null;
const params = new URLSearchParams(window.location.search);
const err = params.get("error");
if (err === "portal_disabled") {
return "The wholesale portal is currently disabled. Contact us for assistance.";
}
if (err === "account_not_active") {
return "Your account is not active. Please contact support or register for a new account.";
}
if (err === "invalid_credentials") {
return "Invalid email or password. Please try again.";
}
if (err === "oauth") {
return "Google sign-in failed or was cancelled. Please try again or use your password.";
}
return null;
});
const [fieldErrors, setFieldErrors] = useState<{ email?: string; password?: string }>({});
// Derive selectedBrand from form.brandId during render — no effect needed. // Derive selectedBrand from form.brandId during render — no effect needed.
// form.brandId is always a valid BRANDS id, so this lookup is O(n) over a 2-item array. // form.brandId is always a valid BRANDS id, so this lookup is O(n) over a 2-item array.
const selectedBrand = BRANDS.find(b => b.id === form.brandId) ?? BRANDS[1]; const selectedBrand = BRANDS.find(b => b.id === state.form.brandId) ?? BRANDS[1];
async function handleGoogleSignIn() { async function handleGoogleSignIn() {
setGoogleSubmitting(true); dispatch({ type: "SET_GOOGLE_SUBMITTING", value: true });
setError(null); dispatch({ type: "SET_ERROR", value: null });
try { try {
const { signInWithGoogleAction } = await import("@/actions/auth-actions"); const { signInWithGoogleAction } = await import("@/actions/auth-actions");
const result = await signInWithGoogleAction({ const result = await signInWithGoogleAction({
@@ -98,53 +149,55 @@ export default function WholesaleLoginPage() {
// recognized by the wholesale auth path. Once wholesale auth // recognized by the wholesale auth path. Once wholesale auth
// is migrated to Neon Auth, this will start working for them // is migrated to Neon Auth, this will start working for them
// too. // too.
setError( dispatch({
result.error ?? type: "SET_ERROR",
"Google sign-in is available, but the wholesale portal is not yet wired up to it. Please use email + password for now.", value:
); result.error ??
setGoogleSubmitting(false); "Google sign-in failed. Please use email and password to sign in.",
});
dispatch({ type: "SET_GOOGLE_SUBMITTING", value: false });
return; return;
} }
window.location.href = result.url; window.location.href = result.url;
} catch { } catch {
setError("Google sign-in failed. Please try again."); dispatch({ type: "SET_ERROR", value: "Google sign-in failed. Please try again." });
setGoogleSubmitting(false); dispatch({ type: "SET_GOOGLE_SUBMITTING", value: false });
} }
} }
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
setSubmitting(true); dispatch({ type: "SET_SUBMITTING", value: true });
setError(null); dispatch({ type: "SET_ERROR", value: null });
setFieldErrors({}); dispatch({ type: "CLEAR_FIELD_ERRORS" });
// Client-side validation // Client-side validation
const errors: { email?: string; password?: string } = {}; const errors: FieldErrors = {};
if (!form.email) { if (!state.form.email) {
errors.email = "Email is required"; errors.email = "Email is required";
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) { } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(state.form.email)) {
errors.email = "Enter a valid email address"; errors.email = "Enter a valid email address";
} }
if (!form.password) { if (!state.form.password) {
errors.password = "Password is required"; errors.password = "Password is required";
} else if (form.password.length < 6) { } else if (state.form.password.length < 6) {
errors.password = "Password must be at least 6 characters"; errors.password = "Password must be at least 6 characters";
} }
if (Object.keys(errors).length > 0) { if (Object.keys(errors).length > 0) {
setFieldErrors(errors); dispatch({ type: "SET_FIELD_ERRORS", value: errors });
setSubmitting(false); dispatch({ type: "SET_SUBMITTING", value: false });
return; return;
} }
const fd = new FormData(e.currentTarget as HTMLFormElement); const fd = new FormData(e.currentTarget as HTMLFormElement);
fd.set("brand_id", form.brandId); fd.set("brand_id", state.form.brandId);
const result = await wholesaleLoginAction(fd); const result = await wholesaleLoginAction(fd);
setSubmitting(false); dispatch({ type: "SET_SUBMITTING", value: false });
if (result.success) { if (result.success) {
router.push("/wholesale/portal"); router.push("/wholesale/portal");
router.refresh(); router.refresh();
} else { } else {
setError(result.error ?? "Login failed. Please check your credentials."); dispatch({ type: "SET_ERROR", value: result.error ?? "Login failed. Please check your credentials." });
} }
} }
@@ -190,12 +243,12 @@ export default function WholesaleLoginPage() {
<p className="mt-1 text-sm text-zinc-500">Sign in to your wholesale account.</p> <p className="mt-1 text-sm text-zinc-500">Sign in to your wholesale account.</p>
</div> </div>
{error && ( {state.error && (
<div className="mb-5 rounded-xl border border-red-900/50 bg-red-950/50 px-4 py-3 text-sm text-red-400 flex items-start gap-2"> <div className="mb-5 rounded-xl border border-red-900/50 bg-red-950/50 px-4 py-3 text-sm text-red-400 flex items-start gap-2">
<svg className="w-4 h-4 mt-0.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="w-4 h-4 mt-0.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/> <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg> </svg>
{error} {state.error}
</div> </div>
)} )}
@@ -204,10 +257,10 @@ export default function WholesaleLoginPage() {
<button <button
type="button" type="button"
onClick={handleGoogleSignIn} onClick={handleGoogleSignIn}
disabled={googleSubmitting || submitting} disabled={state.googleSubmitting || state.submitting}
className="w-full rounded-xl bg-white py-3.5 text-sm font-bold text-zinc-900 hover:bg-zinc-100 active:bg-zinc-200 disabled:opacity-50 disabled:cursor-not-allowed transition-colors border border-zinc-300 flex items-center justify-center gap-2.5" className="w-full rounded-xl bg-white py-3.5 text-sm font-bold text-zinc-900 hover:bg-zinc-100 active:bg-zinc-200 disabled:opacity-50 disabled:cursor-not-allowed transition-colors border border-zinc-300 flex items-center justify-center gap-2.5"
> >
{googleSubmitting ? ( {state.googleSubmitting ? (
<> <>
<svg className="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24"> <svg className="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /> <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
@@ -241,8 +294,8 @@ export default function WholesaleLoginPage() {
<select aria-label="Brand Id" <select aria-label="Brand Id"
id="brand_id" id="brand_id"
name="brand_id" name="brand_id"
value={form.brandId} value={state.form.brandId}
onChange={e => setForm(f => ({ ...f, brandId: e.target.value }))} onChange={e => dispatch({ type: "SET_BRAND_ID", value: e.target.value })}
className="w-full rounded-xl border border-zinc-700 bg-zinc-950 px-3 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-600 focus:ring-1 focus:ring-emerald-600 transition-colors appearance-none cursor-pointer" className="w-full rounded-xl border border-zinc-700 bg-zinc-950 px-3 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-600 focus:ring-1 focus:ring-emerald-600 transition-colors appearance-none cursor-pointer"
style={{ backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%236b7280'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'/%3E%3C/svg%3E")`, backgroundRepeat: "no-repeat", backgroundPosition: "right 12px center", backgroundSize: "16px" }} style={{ backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%236b7280'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'/%3E%3C/svg%3E")`, backgroundRepeat: "no-repeat", backgroundPosition: "right 12px center", backgroundSize: "16px" }}
> >
@@ -252,38 +305,38 @@ export default function WholesaleLoginPage() {
</select> </select>
</FormField> </FormField>
<FormField label="Email" id="email" error={fieldErrors.email}> <FormField label="Email" id="email" error={state.fieldErrors.email}>
<input aria-label="Buyer@company.com" <input aria-label="Buyer@company.com"
type="email" type="email"
id="email" id="email"
name="email" name="email"
value={form.email} value={state.form.email}
onChange={e => { setForm(f => ({ ...f, email: e.target.value })); setFieldErrors(f => ({ ...f, email: undefined })); }} onChange={e => { dispatch({ type: "SET_EMAIL", value: e.target.value }); dispatch({ type: "CLEAR_FIELD_ERROR", field: "email" }); }}
autoComplete="email" autoComplete="email"
className={`w-full rounded-xl border bg-zinc-950 px-3 py-3 text-sm text-zinc-100 outline-none transition-colors placeholder:text-zinc-600 ${fieldErrors.email ? "border-red-600 focus:border-red-500 focus:ring-1 focus:ring-red-500" : "border-zinc-700 focus:border-emerald-600 focus:ring-1 focus:ring-emerald-600"}`} className={`w-full rounded-xl border bg-zinc-950 px-3 py-3 text-sm text-zinc-100 outline-none transition-colors placeholder:text-zinc-600 ${state.fieldErrors.email ? "border-red-600 focus:border-red-500 focus:ring-1 focus:ring-red-500" : "border-zinc-700 focus:border-emerald-600 focus:ring-1 focus:ring-emerald-600"}`}
placeholder="buyer@company.com" placeholder="buyer@company.com"
/> />
</FormField> </FormField>
<FormField label="Password" id="password" error={fieldErrors.password}> <FormField label="Password" id="password" error={state.fieldErrors.password}>
<input aria-label="••••••••" <input aria-label="••••••••"
type="password" type="password"
id="password" id="password"
name="password" name="password"
value={form.password} value={state.form.password}
onChange={e => { setForm(f => ({ ...f, password: e.target.value })); setFieldErrors(f => ({ ...f, password: undefined })); }} onChange={e => { dispatch({ type: "SET_PASSWORD", value: e.target.value }); dispatch({ type: "CLEAR_FIELD_ERROR", field: "password" }); }}
autoComplete="current-password" autoComplete="current-password"
className={`w-full rounded-xl border bg-zinc-950 px-3 py-3 text-sm text-zinc-100 outline-none transition-colors placeholder:text-zinc-600 ${fieldErrors.password ? "border-red-600 focus:border-red-500 focus:ring-1 focus:ring-red-500" : "border-zinc-700 focus:border-emerald-600 focus:ring-1 focus:ring-emerald-600"}`} className={`w-full rounded-xl border bg-zinc-950 px-3 py-3 text-sm text-zinc-100 outline-none transition-colors placeholder:text-zinc-600 ${state.fieldErrors.password ? "border-red-600 focus:border-red-500 focus:ring-1 focus:ring-red-500" : "border-zinc-700 focus:border-emerald-600 focus:ring-1 focus:ring-emerald-600"}`}
placeholder="••••••••" placeholder="••••••••"
/> />
</FormField> </FormField>
<button <button
type="submit" type="submit"
disabled={submitting} disabled={state.submitting}
className="w-full rounded-xl bg-emerald-600 py-3.5 text-sm font-bold text-white hover:bg-emerald-500 active:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors shadow-lg shadow-emerald-900/30 mt-2 flex items-center justify-center gap-2" className="w-full rounded-xl bg-emerald-600 py-3.5 text-sm font-bold text-white hover:bg-emerald-500 active:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors shadow-lg shadow-emerald-900/30 mt-2 flex items-center justify-center gap-2"
> >
{submitting ? ( {state.submitting ? (
<> <>
<svg className="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24"> <svg className="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /> <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
@@ -326,4 +379,4 @@ export default function WholesaleLoginPage() {
</div> </div>
</div> </div>
); );
} }
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -58,7 +58,7 @@ const PLANS = {
name: "Farm", name: "Farm",
description: "For growing farms with more needs", description: "For growing farms with more needs",
monthlyPrice: 14900, // $149.00 monthlyPrice: 14900, // $149.00
annualPrice: 152280, // $1,522.80 (10% discount) annualPrice: 134100, // $1,341.00 (25% discount; matches marketing pricing.ts)
features: [ features: [
"Unlimited products", "Unlimited products",
"Unlimited stops", "Unlimited stops",
@@ -78,7 +78,7 @@ const PLANS = {
name: "Enterprise", name: "Enterprise",
description: "Custom solution for larger operations", description: "Custom solution for larger operations",
monthlyPrice: 39900, // $399.00 monthlyPrice: 39900, // $399.00
annualPrice: 0, // Custom pricing annualPrice: 359100, // $3,591.00 (25% discount; matches marketing pricing.ts)
features: [ features: [
"Everything in Farm", "Everything in Farm",
"AI Intelligence Pack", "AI Intelligence Pack",