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",
images: [
{
url: "/og-default.jpg",
// Audit 2026-06-26: only og-default.svg exists in /public.
url: "/og-default.svg",
width: 1200,
height: 630,
alt: "Route Commerce Blog",
+3 -2
View File
@@ -19,7 +19,8 @@ export const metadata: Metadata = {
type: "website",
images: [
{
url: "/og-default.jpg",
// Audit 2026-06-26: only og-default.svg exists in /public.
url: "/og-default.svg",
width: 1200,
height: 630,
alt: "Contact Route Commerce",
@@ -32,7 +33,7 @@ export const metadata: Metadata = {
description: "Get in touch with Route Commerce.",
site: "@RouteCommerce",
creator: "@RouteCommerce",
images: ["/og-default.jpg"],
images: ["/og-default.svg"],
},
alternates: {
canonical: `${BASE_URL}/contact`,
+4 -7
View File
@@ -40,15 +40,12 @@ const structuredData = {
priceSpecification: [
{ "@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: "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: {
"@type": "AggregateRating",
// Placeholder — replace with real review data once collected.
ratingValue: 4.9,
reviewCount: 12,
},
// Audit 2026-06-26: removed placeholder aggregateRating (4.9/12).
// Re-add only when there is real, attributed review data.
},
{
"@type": "WebSite",
+304 -116
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() {
const [billingCycle, setBillingCycle] = useState<BillingCycle>("annual");
const [compareOpen, setCompareOpen] = useState(false);
@@ -61,7 +95,28 @@ export default function PricingClientPage() {
return (
<div className="min-h-screen bg-white">
{/* ── Nav bar ─────────────────────────────────────────────────────────── */}
<NavBar />
<Hero
billingCycle={billingCycle}
onCycleChange={setBillingCycle}
/>
<PlansSection
billingCycle={billingCycle}
compareOpen={compareOpen}
onToggleCompare={() => setCompareOpen((v) => !v)}
onCloseCompare={() => setCompareOpen(false)}
/>
<AddOnsSection billingCycle={billingCycle} />
<Testimonials />
<FAQSection openFaq={openFaq} onToggle={toggleFaq} />
<CTA />
<SiteFooter />
</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">
@@ -82,8 +137,11 @@ export default function PricingClientPage() {
</nav>
</div>
</header>
);
}
{/* ── Hero ────────────────────────────────────────────────────────────── */}
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">
@@ -97,24 +155,71 @@ export default function PricingClientPage() {
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} />
<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>
);
}
{/* ── Plan cards ───────────────────────────────────────────────────────── */}
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]) => {
{(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
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"
}`}
@@ -143,41 +248,35 @@ export default function PricingClientPage() {
: "border border-slate-300 text-slate-700 hover:bg-slate-50"
}`}
>
{key === "enterprise" ? "Contact Sales" : "Get Started"}
{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) => (
<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>
<PlanFeature key={f} feature={f} />
))}
</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>
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>
);
}
{/* Compare table */}
{compareOpen && (
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={() => setCompareOpen(false)}
onClick={onClose}
className="text-xs text-slate-400 hover:text-slate-600 transition-colors"
aria-label="Close comparison table"
>
@@ -189,79 +288,108 @@ export default function PricingClientPage() {
<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>
);
})}
{(["starter", "farm", "enterprise"] as const).map((tier) => (
<CompareHeaderCell key={tier} tier={tier} />
))}
</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>
);
})}
{COMPARE_ROWS.map((row, rowIndex) => (
<CompareRow key={row.feature} row={row} rowIndex={rowIndex} />
))}
</tbody>
</table>
</div>
</div>
)}
</section>
);
}
{/* ── Add-ons ───────────────────────────────────────────────────────────── */}
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 }) => {
const addon = ADDONS[key as keyof typeof ADDONS];
{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 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">
<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>
@@ -276,25 +404,50 @@ export default function PricingClientPage() {
</div>
</article>
);
})}
</div>
}
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>
{/* ── Social proof ─────────────────────────────────────────────────────── */}
);
}
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">
{[
{ 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">
{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-${i}-${j}`} className="text-amber-400 text-sm" aria-hidden="true">{s}</span>
<span key={`star-${index}-${j}`} className="text-amber-400 text-sm" aria-hidden="true">{s}</span>
))}
</div>
<blockquote>
@@ -303,44 +456,77 @@ export default function PricingClientPage() {
<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 ─────────────────────────────────────────────────────────────── */}
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) => (
<div key={item.q} className="rounded-xl border border-slate-200 bg-white">
<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={() => toggleFaq(i)}
onClick={onToggle}
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}`}
aria-expanded={isOpen}
aria-controls={`faq-answer-${index}`}
>
<span className="text-sm font-medium text-slate-900">{item.q}</span>
<span className="text-sm font-medium text-slate-900">{question}</span>
<span
className={`ml-3 text-slate-400 text-lg transition-transform ${openFaq === i ? "rotate-45" : ""}`}
className={`ml-3 text-slate-400 text-lg transition-transform ${isOpen ? "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>
{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>
))}
</div>
</div>
</section>
);
}
{/* ── CTA ──────────────────────────────────────────────────────────────── */}
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>
@@ -364,8 +550,11 @@ export default function PricingClientPage() {
</p>
</div>
</section>
);
}
{/* ── Footer ──────────────────────────────────────────────────────────── */}
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">
@@ -379,7 +568,6 @@ export default function PricingClientPage() {
</nav>
</div>
</footer>
</div>
);
}
+3 -2
View File
@@ -19,7 +19,8 @@ export const metadata: Metadata = {
type: "website",
images: [
{
url: "/og-default.jpg",
// Audit 2026-06-26: only og-default.svg exists in /public.
url: "/og-default.svg",
width: 1200,
height: 630,
alt: "Route Commerce Pricing",
@@ -32,7 +33,7 @@ export const metadata: Metadata = {
description: "Simple, transparent pricing for produce wholesale operations.",
site: "@RouteCommerce",
creator: "@RouteCommerce",
images: ["/og-default.jpg"],
images: ["/og-default.svg"],
},
alternates: {
canonical: `${BASE_URL}/pricing`,
+18 -51
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: 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 },
// 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: [
{ 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 },
],
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: 11, title: "Customer Loyalty Program", description: "Points, rewards, and referral tracking", category: "Marketing", upvotes: 112 },
],
@@ -190,64 +192,29 @@ export default function RoadmapPage() {
</div>
</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]">
<div className="max-w-2xl mx-auto px-6">
<div className="text-center mb-8">
<h2 className="text-3xl font-bold text-[#1a1a1a] mb-2" style={{ fontFamily: "var(--font-fraunces)" }}>
Suggest a Feature
</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>
<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"
<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"
>
<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>
Suggest a Feature
</a>
<p className="mt-3 text-xs text-[#888]">
Opens your email client to hello@routecommerce.com
</p>
</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>
</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 = {
title: "Security — Route Commerce",
description: "Learn about Route Commerce security practices, data protection, and compliance. Enterprise-grade encryption and SOC 2 compliance.",
keywords: ["security", "data protection", "encryption", "SOC 2", "GDPR", "CCPA", "Route Commerce security", "compliance"],
description: "Learn about Route Commerce security practices and data protection. Built on Vercel, Neon Postgres, and Stripe.",
keywords: ["security", "data protection", "encryption", "TLS", "GDPR", "CCPA", "Route Commerce security", "privacy"],
authors: [{ name: "Route Commerce" }],
creator: "Route Commerce",
publisher: "Route Commerce",
@@ -38,44 +38,50 @@ export const viewport: Viewport = {
// — avoids hydration mismatch from `new Date()` in JSX.
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 = [
{
title: "Bank-Level Encryption",
description: "All data is encrypted in transit using TLS 1.3 and at rest using AES-256 encryption.",
title: "Encrypted in Transit & At Rest",
description: "All traffic uses TLS 1.3; data is encrypted at rest by our providers (Vercel, Neon Postgres).",
icon: "🔒",
},
{
title: "SOC 2 Compliant",
description: "Our infrastructure meets SOC 2 Type II standards for security, availability, and confidentiality.",
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.",
title: "Vercel Edge Network",
description: "Application hosted on Vercel with automatic DDoS protection and a global CDN.",
icon: "⚡",
},
{
title: "GDPR & CCPA Compliant",
description: "We comply with GDPR and CCPA regulations, giving you full control over your data.",
title: "Stripe Handles Payments",
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: "🛡",
},
{
title: "Secure Authentication",
description: "Supabase Auth provides secure, compliant authentication with 2FA support.",
title: "Neon Auth (Better Auth)",
description: "Authentication is handled by Neon Auth. Passwords are hashed with bcrypt; sessions are server-signed cookies.",
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 = [
{ label: "SSL Secured", icon: "🔒" },
{ label: "SOC 2 Compliant", icon: "" },
{ label: "GDPR Ready", icon: "🛡" },
{ label: "PCI Compliant", icon: "💳" },
{ label: "TLS 1.3 in Transit", icon: "🔒" },
{ label: "Hosted on Vercel", icon: "" },
{ label: "Database on Neon", icon: "🗄" },
{ label: "Payments by Stripe", icon: "💳" },
];
export default function SecurityPage() {
@@ -167,11 +173,11 @@ export default function SecurityPage() {
</div>
<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">
<span className="text-[#1a4d2e] font-bold text-sm">S</span>
<span className="text-[#1a4d2e] font-bold text-sm">N</span>
</div>
<div>
<h3 className="font-semibold text-[#1a1a1a]">Supabase</h3>
<p className="text-sm text-[#666]">PostgreSQL database with built-in encryption and real-time capabilities</p>
<h3 className="font-semibold text-[#1a1a1a]">Neon Postgres</h3>
<p className="text-sm text-[#666]">Postgres with at-rest encryption, branch isolation, and connection pooling.</p>
</div>
</div>
<div className="flex items-start gap-4">
+117 -64
View File
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useReducer } from "react";
import { wholesaleLoginAction } from "@/actions/wholesale-auth";
import { useRouter } from "next/navigation";
import WholesaleBenefits from "@/components/wholesale/WholesaleBenefits";
@@ -14,6 +14,81 @@ const BRANDS = [
{ 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() {
return (
<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() {
const router = useRouter();
const [form, setForm] = useState({ email: "", password: "", brandId: BRANDS[1].id });
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 }>({});
const [state, dispatch] = useReducer(reducer, initialState);
// 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.
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() {
setGoogleSubmitting(true);
setError(null);
dispatch({ type: "SET_GOOGLE_SUBMITTING", value: true });
dispatch({ type: "SET_ERROR", value: null });
try {
const { signInWithGoogleAction } = await import("@/actions/auth-actions");
const result = await signInWithGoogleAction({
@@ -98,53 +149,55 @@ export default function WholesaleLoginPage() {
// recognized by the wholesale auth path. Once wholesale auth
// is migrated to Neon Auth, this will start working for them
// too.
setError(
dispatch({
type: "SET_ERROR",
value:
result.error ??
"Google sign-in is available, but the wholesale portal is not yet wired up to it. Please use email + password for now.",
);
setGoogleSubmitting(false);
"Google sign-in failed. Please use email and password to sign in.",
});
dispatch({ type: "SET_GOOGLE_SUBMITTING", value: false });
return;
}
window.location.href = result.url;
} catch {
setError("Google sign-in failed. Please try again.");
setGoogleSubmitting(false);
dispatch({ type: "SET_ERROR", value: "Google sign-in failed. Please try again." });
dispatch({ type: "SET_GOOGLE_SUBMITTING", value: false });
}
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSubmitting(true);
setError(null);
setFieldErrors({});
dispatch({ type: "SET_SUBMITTING", value: true });
dispatch({ type: "SET_ERROR", value: null });
dispatch({ type: "CLEAR_FIELD_ERRORS" });
// Client-side validation
const errors: { email?: string; password?: string } = {};
if (!form.email) {
const errors: FieldErrors = {};
if (!state.form.email) {
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";
}
if (!form.password) {
if (!state.form.password) {
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";
}
if (Object.keys(errors).length > 0) {
setFieldErrors(errors);
setSubmitting(false);
dispatch({ type: "SET_FIELD_ERRORS", value: errors });
dispatch({ type: "SET_SUBMITTING", value: false });
return;
}
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);
setSubmitting(false);
dispatch({ type: "SET_SUBMITTING", value: false });
if (result.success) {
router.push("/wholesale/portal");
router.refresh();
} 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>
</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">
<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"/>
</svg>
{error}
{state.error}
</div>
)}
@@ -204,10 +257,10 @@ export default function WholesaleLoginPage() {
<button
type="button"
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"
>
{googleSubmitting ? (
{state.googleSubmitting ? (
<>
<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" />
@@ -241,8 +294,8 @@ export default function WholesaleLoginPage() {
<select aria-label="Brand Id"
id="brand_id"
name="brand_id"
value={form.brandId}
onChange={e => setForm(f => ({ ...f, brandId: e.target.value }))}
value={state.form.brandId}
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"
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>
</FormField>
<FormField label="Email" id="email" error={fieldErrors.email}>
<FormField label="Email" id="email" error={state.fieldErrors.email}>
<input aria-label="Buyer@company.com"
type="email"
id="email"
name="email"
value={form.email}
onChange={e => { setForm(f => ({ ...f, email: e.target.value })); setFieldErrors(f => ({ ...f, email: undefined })); }}
value={state.form.email}
onChange={e => { dispatch({ type: "SET_EMAIL", value: e.target.value }); dispatch({ type: "CLEAR_FIELD_ERROR", field: "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"
/>
</FormField>
<FormField label="Password" id="password" error={fieldErrors.password}>
<FormField label="Password" id="password" error={state.fieldErrors.password}>
<input aria-label="••••••••"
type="password"
id="password"
name="password"
value={form.password}
onChange={e => { setForm(f => ({ ...f, password: e.target.value })); setFieldErrors(f => ({ ...f, password: undefined })); }}
value={state.form.password}
onChange={e => { dispatch({ type: "SET_PASSWORD", value: e.target.value }); dispatch({ type: "CLEAR_FIELD_ERROR", field: "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="••••••••"
/>
</FormField>
<button
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"
>
{submitting ? (
{state.submitting ? (
<>
<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" />
+323 -157
View File
@@ -1,6 +1,6 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
import React, { useEffect, useRef, useReducer } from "react";
import Link from "next/link";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
@@ -20,73 +20,159 @@ function scrollToContent() {
}
}
export default function HeroSection() {
const containerRef = useRef<HTMLDivElement>(null);
const heroRef = useRef<HTMLElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const [scrollProgress, setScrollProgress] = useState(0);
// ── State / Reducer ────────────────────────────────────────────────────────────
// ─── GSAP SCROLL ANIMATIONS ────────────────────────────────────────────────
// Resilient: respects reduced-motion, safe GSAP target checks, reliable counter impl, proper scope
useEffect(() => {
if (!heroRef.current || !contentRef.current) return;
type State = {
scrollProgress: number;
};
const prefersReducedMotion =
typeof window !== "undefined" &&
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true;
type Action = { type: "SET_SCROLL_PROGRESS"; value: number };
// For reduced motion: show final states immediately (no scroll-driven anims to avoid jank/warnings)
if (prefersReducedMotion) {
const counterEls = document.querySelectorAll<Element>(".counter-animate");
counterEls.forEach((el) => {
const targetStr = el.getAttribute("data-target") || "0";
const target = parseInt(targetStr, 10);
el.textContent = target.toLocaleString();
});
// Still allow basic scroll progress bar if wanted, but skip all ScrollTrigger/GSAP heavy
return;
const initialState: State = {
scrollProgress: 0,
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_SCROLL_PROGRESS":
return { ...state, scrollProgress: action.value };
}
}
const ctx = gsap.context(() => {
// Scroll progress tracking
// ── Module-level data (feature cards, counters, hero stats) ────────────────────
// Audit 2026-06-26: previously "500+ Farm Brands / 98% On-Time / 50K+ Deliveries"
// — fabricated numbers with no source. Stripped. Re-add when there is a real
// data source (DB row counts, Stripe MRR, etc.).
const HERO_STATS: Array<{ stat: string; label: string }> = [];
const FEATURE_CARDS = [
{
title: "Product Catalogs",
description: "Showcase seasonal produce with rich media galleries and real-time availability updates.",
color: "#1a4d2e",
icon: (
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M12 2L2 7l10 5 10-5-10-5z" />
<path d="M2 17l10 5 10-5" />
<path d="M2 12l10 5 10-5" />
</svg>
),
},
{
title: "Route Planning",
description: "Optimize delivery routes with intelligent mapping and real-time scheduling.",
color: "#c97a3e",
icon: (
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="12" cy="12" r="10" />
<polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76" />
</svg>
),
},
{
title: "Order Management",
description: "Track orders from placement through delivery with automated status updates.",
color: "#7a5c9e",
icon: (
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M9 11l3 3L22 4" />
<path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11" />
</svg>
),
},
{
title: "Wholesale Portal",
description: "Give buyers a dedicated space to browse pricing and place bulk orders.",
color: "#5c8a8f",
icon: (
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75" />
</svg>
),
},
{
title: "Harvest Reach",
description: "Send email and SMS campaigns about seasonal availability and new harvests.",
color: "#c97a3e",
icon: (
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
<polyline points="22,6 12,13 2,6" />
</svg>
),
},
{
title: "Smart Analytics",
description: "Uncover sales trends and operational insights with real-time dashboards.",
color: "#6b8f71",
icon: (
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<line x1="18" y1="20" x2="18" y2="10" />
<line x1="12" y1="20" x2="12" y2="4" />
<line x1="6" y1="20" x2="6" y2="14" />
</svg>
),
},
];
// Audit 2026-06-26: previously "500 brands / 50K orders / 98% on-time / $2M weekly sales"
// — fabricated. Removed until a real data source is wired.
const STAT_COUNTERS: Array<{ value: number; prefix?: string; suffix?: string; label: string }> = [];
// ── Custom hook: GSAP scroll-driven animations ────────────────────────────────
// Note: GSAP setup is implemented as a set of top-level setup functions used by the
// useEffect inside HeroSection. This keeps the useEffect small and the main component
// under 300 lines. The deps array is intentionally empty to match the original behavior
// (refs and useReducer dispatch are stable across renders).
function setupScrollProgress(
heroEl: HTMLElement,
dispatch: React.Dispatch<Action>
) {
ScrollTrigger.create({
trigger: heroRef.current,
trigger: heroEl,
start: "top top",
end: "bottom top",
scrub: true,
onUpdate: (self) => {
setScrollProgress(self.progress);
dispatch({ type: "SET_SCROLL_PROGRESS", value: self.progress });
},
});
}
// Hero content pin - fade out on scroll
gsap.to(contentRef.current, {
function setupHeroContentPin(heroEl: HTMLElement, contentEl: HTMLDivElement) {
gsap.to(contentEl, {
y: -120,
opacity: 0,
scale: 0.95,
ease: "power2.inOut",
scrollTrigger: {
trigger: heroRef.current,
trigger: heroEl,
start: "top top",
end: "40% top",
scrub: 1.2,
},
});
}
// Hero visual scale + parallax
function setupHeroVisualScale(heroEl: HTMLElement) {
gsap.to(".hero-visual", {
scale: 1.25,
y: 80,
ease: "power2.inOut",
scrollTrigger: {
trigger: heroRef.current,
trigger: heroEl,
start: "top top",
end: "50% top",
scrub: 1.5,
},
});
}
// Staggered entrance for content elements
function setupContentReveal() {
const contentElements = gsap.utils.toArray<Element>(".hero-reveal");
contentElements.forEach((el, i) => {
gsap.fromTo(
@@ -107,8 +193,9 @@ export default function HeroSection() {
}
);
});
}
// Parallax floating elements
function setupParallaxFloat() {
const floatElements = gsap.utils.toArray<Element>(".parallax-float");
floatElements.forEach((el, i) => {
gsap.to(el, {
@@ -121,20 +208,22 @@ export default function HeroSection() {
},
});
});
}
// Scroll indicator fade on scroll
function setupScrollIndicatorFade(heroEl: HTMLElement) {
gsap.to(".scroll-indicator", {
y: 20,
opacity: 0,
scrollTrigger: {
trigger: heroRef.current,
trigger: heroEl,
start: "10% top",
end: "30% top",
scrub: 1,
},
});
}
// Staggered text reveal on scroll for other sections
function setupTextReveal() {
gsap.utils.toArray<Element>(".reveal-text").forEach((el) => {
gsap.fromTo(
el,
@@ -154,8 +243,9 @@ export default function HeroSection() {
}
);
});
}
// Scale reveal for cards
function setupScaleReveal() {
gsap.utils.toArray<Element>(".reveal-scale").forEach((el, i) => {
gsap.fromTo(
el,
@@ -175,7 +265,9 @@ export default function HeroSection() {
}
);
});
}
function setupCounterAnimations() {
// Counter animations - fixed: use reliable proxy object (avoids textContent tween pitfalls that left counters at 0)
// + always scoped + fallback path (though reduced handled above) + !gsap guard per requirements
const counterElements = gsap.utils.toArray<Element>(".counter-animate");
@@ -223,28 +315,106 @@ export default function HeroSection() {
io.observe(el);
}
});
}
function runHeroAnimations(
containerRef: React.RefObject<HTMLDivElement | null>,
heroRef: React.RefObject<HTMLElement | null>,
contentRef: React.RefObject<HTMLDivElement | null>,
dispatch: React.Dispatch<Action>
) {
if (!heroRef.current || !contentRef.current) return;
const prefersReducedMotion =
typeof window !== "undefined" &&
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true;
// For reduced motion: show final states immediately (no scroll-driven anims to avoid jank/warnings)
if (prefersReducedMotion) {
const counterEls = document.querySelectorAll<Element>(".counter-animate");
counterEls.forEach((el) => {
const targetStr = el.getAttribute("data-target") || "0";
const target = parseInt(targetStr, 10);
el.textContent = target.toLocaleString();
});
// Still allow basic scroll progress bar if wanted, but skip all ScrollTrigger/GSAP heavy
return;
}
const heroEl = heroRef.current;
const contentEl = contentRef.current;
const ctx = gsap.context(() => {
setupScrollProgress(heroEl, dispatch);
setupHeroContentPin(heroEl, contentEl);
setupHeroVisualScale(heroEl);
setupContentReveal();
setupParallaxFloat();
setupScrollIndicatorFade(heroEl);
setupTextReveal();
setupScaleReveal();
setupCounterAnimations();
}, containerRef);
return () => ctx.revert();
}, []);
}
// ── Main component ─────────────────────────────────────────────────────────────
export default function HeroSection() {
const containerRef = useRef<HTMLDivElement>(null);
const heroRef = useRef<HTMLElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const [state, dispatch] = useReducer(reducer, initialState);
// ─── GSAP SCROLL ANIMATIONS ────────────────────────────────────────────────
// Resilient: respects reduced-motion, safe GSAP target checks, reliable counter impl, proper scope
useEffect(() => {
return runHeroAnimations(containerRef, heroRef, contentRef, dispatch);
}, [containerRef, heroRef, contentRef, dispatch]);
return (
<>
{/* ─── SCROLL PROGRESS BAR ──────────────────────────────────────────── */}
<ScrollProgressBar progress={state.scrollProgress} />
<div ref={containerRef}>
<HeroMainSection heroRef={heroRef} contentRef={contentRef} />
<StorySection />
<FeaturesSection />
<StatsSection />
<CTASection />
</div>
<GlobalStyles />
</>
);
}
// ── Scroll Progress Bar ────────────────────────────────────────────────────────
function ScrollProgressBar({ progress }: { progress: number }) {
return (
<div className="fixed top-0 left-0 right-0 h-1 z-[1000] bg-white/10">
<div
className="h-full bg-gradient-to-r from-amber-500 via-amber-400 to-sage-500"
style={{
transform: `scaleX(${scrollProgress})`,
transform: `scaleX(${progress})`,
transformOrigin: "left",
transition: "transform 0.1s ease-out",
}}
/>
</div>
);
}
{/* ─── HERO SECTION - SCROLL-DRIVEN PIN ───────────────────────────── */}
{/* containerRef wraps animated sections for proper GSAP context scope (fixes "Invalid scope" + missing targets) */}
<div ref={containerRef}>
// ── Hero Main Section ──────────────────────────────────────────────────────────
function HeroMainSection({
heroRef,
contentRef,
}: {
heroRef: React.RefObject<HTMLElement | null>;
contentRef: React.RefObject<HTMLDivElement | null>;
}) {
return (
<section
ref={heroRef}
className="hero-section relative min-h-screen flex items-center overflow-hidden"
@@ -252,7 +422,26 @@ export default function HeroSection() {
background: "linear-gradient(180deg, #faf8f5 0%, #fef7f0 50%, #fdf6f0 100%)",
}}
>
{/* ─── PARALLAX BACKGROUND LAYERS ─────────────────────────────────────── */}
<ParallaxBgLayer />
<div
ref={contentRef}
className="container mx-auto px-4 sm:px-6 lg:px-16 py-16 sm:py-24 lg:py-32 relative z-10"
>
<div className="grid lg:grid-cols-2 gap-8 lg:gap-16 items-center min-h-[70vh] lg:min-h-[80vh]">
<HeroContentLeft />
<HeroContentRight />
</div>
</div>
<ScrollIndicator />
<DecorativeLeaves />
</section>
);
}
// ── Parallax Background Layer ──────────────────────────────────────────────────
function ParallaxBgLayer() {
return (
<div className="parallax-bg-layer absolute inset-0 pointer-events-none">
{/* Gradient orbs */}
<div className="parallax-float absolute top-20 right-20 w-80 h-80 rounded-full opacity-30 blur-3xl"
@@ -278,15 +467,13 @@ export default function HeroSection() {
}}
/>
</div>
);
}
{/* ── HERO CONTENT ────────────────────────────────────────────────────── */}
<div
ref={contentRef}
className="container mx-auto px-4 sm:px-6 lg:px-16 py-16 sm:py-24 lg:py-32 relative z-10"
>
<div className="grid lg:grid-cols-2 gap-8 lg:gap-16 items-center min-h-[70vh] lg:min-h-[80vh]">
// ── Hero Content (Left Column) ─────────────────────────────────────────────────
{/* Left Column - Animated Content */}
function HeroContentLeft() {
return (
<div className="space-y-6 sm:space-y-8 lg:space-y-10 order-2 lg:order-1">
{/* Animated Badge */}
<div className="hero-reveal hero-badge inline-flex items-center gap-2 sm:gap-3 px-3 sm:px-6 py-2 sm:py-3 rounded-full border text-xs sm:text-sm" style={{
@@ -376,13 +563,10 @@ export default function HeroSection() {
</Link>
</div>
{/* Stats */}
{/* Stats — only render when there are real stats to show (audit 2026-06-26). */}
{HERO_STATS.length > 0 && (
<div className="hero-reveal hero-stats flex flex-wrap gap-6 sm:gap-8 pt-6 sm:pt-8">
{[
{ stat: "500+", label: "Farm Brands" },
{ stat: "98%", label: "On-Time" },
{ stat: "50K+", label: "Deliveries" },
].map((item) => (
{HERO_STATS.map((item) => (
<div key={item.label} className="text-center">
<div
className="text-2xl sm:text-3xl lg:text-4xl font-bold"
@@ -399,11 +583,26 @@ export default function HeroSection() {
</div>
))}
</div>
)}
</div>
);
}
{/* Right Column - Hero Visual */}
// ── Hero Content (Right Column - SVG Visual) ───────────────────────────────────
function HeroContentRight() {
return (
<div className="hero-visual relative lg:h-[500px] xl:h-[700px]">
{/* SVG Route Map Visual */}
<HeroRouteMapSVG />
<FloatingCardTopLeft />
<FloatingCardBottomRight />
<FloatingCardMiddleRight />
</div>
);
}
function HeroRouteMapSVG() {
return (
<svg
viewBox="0 0 500 500"
className="w-full h-full"
@@ -488,7 +687,7 @@ export default function HeroSection() {
))}
{/* Decorative Trees */}
{[[60, 100], [440, 360], [50, 440], [450, 180]].map((coords, i) => (
{([[60, 100], [440, 360], [50, 440], [450, 180]] as const).map((coords, i) => (
<circle
key={i}
cx={coords[0]}
@@ -507,8 +706,11 @@ export default function HeroSection() {
<text x="0" y="-30" textAnchor="middle" fill="#1a4d2e" fontSize="12" fontWeight="700">N</text>
</g>
</svg>
);
}
{/* Floating Cards */}
function FloatingCardTopLeft() {
return (
<div className="absolute top-4 sm:top-12 -left-2 sm:-left-8 parallax-float hidden sm:block" style={{ animation: "float 950ms ease-in-out infinite" }}>
<div
className="rounded-2xl p-4 sm:p-5 shadow-2xl border"
@@ -522,7 +724,11 @@ export default function HeroSection() {
<div className="text-xs sm:text-sm text-white/90">Miles Saved</div>
</div>
</div>
);
}
function FloatingCardBottomRight() {
return (
<div className="absolute bottom-16 sm:bottom-24 -right-2 sm:-right-4 parallax-float hidden sm:block" style={{ animation: "float-delayed 950ms ease-in-out infinite" }}>
<div
className="rounded-2xl p-4 sm:p-5 shadow-2xl border"
@@ -536,7 +742,11 @@ export default function HeroSection() {
<div className="text-xs sm:text-sm text-white/90">Avg Delivery</div>
</div>
</div>
);
}
function FloatingCardMiddleRight() {
return (
<div className="absolute top-1/3 sm:top-1/2 -right-4 sm:-right-12 parallax-float hidden sm:block" style={{ animation: "float 950ms ease-in-out infinite 1s" }}>
<div
className="rounded-2xl p-3 sm:p-4 shadow-xl border flex items-center gap-2 sm:gap-3"
@@ -550,11 +760,13 @@ export default function HeroSection() {
<span className="text-xs sm:text-sm font-bold text-white/90">Live Tracking</span>
</div>
</div>
</div>
</div>
</div>
);
}
{/* ─── SCROLL INDICATOR ────────────────────────────────────────────────── */}
// ── Scroll Indicator ───────────────────────────────────────────────────────────
function ScrollIndicator() {
return (
<button
type="button"
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") scrollToContent(); }}
@@ -574,8 +786,14 @@ export default function HeroSection() {
/>
</div>
</button>
);
}
{/* Decorative leaves */}
// ── Decorative Leaves ──────────────────────────────────────────────────────────
function DecorativeLeaves() {
return (
<>
<div className="parallax-float absolute bottom-32 left-16 opacity-15" style={{ animationDelay: "0.5s" }}>
<svg className="w-20 h-20" viewBox="0 0 60 60" fill="none">
<path d="M30 5 C45 15, 50 35, 30 55 C10 35, 15 15, 30 5" fill="#6b8f71" />
@@ -588,9 +806,14 @@ export default function HeroSection() {
<path d="M20 3 L20 37" stroke="#1a4d2e" strokeWidth="1.5" />
</svg>
</div>
</section>
</>
);
}
{/* ── STORY SECTION - SCROLL-DRIVEN REVEAL ──────────────────────────── */}
// ── Story Section ──────────────────────────────────────────────────────────────
function StorySection() {
return (
<section id="story" className="relative" style={{ background: "#1a1a1a" }}>
{/* Reduced from 300vh to avoid "long blank scroll regions" complaint while keeping cinematic sticky scroll effect */}
<div className="relative" style={{ minHeight: "140vh" }}>
@@ -643,8 +866,13 @@ export default function HeroSection() {
</div>
</div>
</section>
);
}
{/* ── FEATURES SECTION - PROGRESSIVE REVEAL ───────────────────────────── */}
// ── Features Section ───────────────────────────────────────────────────────────
function FeaturesSection() {
return (
<section
id="features"
className="relative py-32 overflow-hidden"
@@ -682,77 +910,7 @@ export default function HeroSection() {
{/* Feature Cards Grid */}
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
{[
{
icon: (
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M12 2L2 7l10 5 10-5-10-5z" />
<path d="M2 17l10 5 10-5" />
<path d="M2 12l10 5 10-5" />
</svg>
),
title: "Product Catalogs",
description: "Showcase seasonal produce with rich media galleries and real-time availability updates.",
color: "#1a4d2e",
},
{
icon: (
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="12" cy="12" r="10" />
<polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76" />
</svg>
),
title: "Route Planning",
description: "Optimize delivery routes with intelligent mapping and real-time scheduling.",
color: "#c97a3e",
},
{
icon: (
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M9 11l3 3L22 4" />
<path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11" />
</svg>
),
title: "Order Management",
description: "Track orders from placement through delivery with automated status updates.",
color: "#7a5c9e",
},
{
icon: (
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75" />
</svg>
),
title: "Wholesale Portal",
description: "Give buyers a dedicated space to browse pricing and place bulk orders.",
color: "#5c8a8f",
},
{
icon: (
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
<polyline points="22,6 12,13 2,6" />
</svg>
),
title: "Harvest Reach",
description: "Send email and SMS campaigns about seasonal availability and new harvests.",
color: "#c97a3e",
},
{
icon: (
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<line x1="18" y1="20" x2="18" y2="10" />
<line x1="12" y1="20" x2="12" y2="4" />
<line x1="6" y1="20" x2="6" y2="14" />
</svg>
),
title: "Smart Analytics",
description: "Uncover sales trends and operational insights with real-time dashboards.",
color: "#6b8f71",
},
].map((feature) => (
{FEATURE_CARDS.map((feature) => (
<div
key={feature.title}
className="reveal-scale group relative p-8 rounded-3xl transition-all duration-500 cursor-pointer"
@@ -821,8 +979,17 @@ export default function HeroSection() {
</div>
</div>
</section>
);
}
{/* ─── STATS SECTION - SCROLL-TRIGGERED COUNTERS ──────────────────────── */}
// ── Stats Section ──────────────────────────────────────────────────────────────
function StatsSection() {
// Audit 2026-06-26: previously rendered fabricated counts ("500 brands / 50K
// orders / 98% on-time / $2M weekly sales"). Section is hidden entirely
// until a real data source (DB row counts, Stripe MRR, etc.) is wired up.
if (STAT_COUNTERS.length === 0) return null;
return (
<section
id="stats"
className="relative py-32 overflow-hidden"
@@ -847,14 +1014,8 @@ export default function HeroSection() {
</span>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-12">
{[
{ value: 500, suffix: "+", label: "Produce Brands" },
{ value: 50000, suffix: "+", label: "Orders Delivered" },
{ value: 98, suffix: "%", label: "On-Time Delivery" },
{ value: 2000000, prefix: "$", suffix: "+", label: "Weekly Sales" },
].map((stat, i) => (
{STAT_COUNTERS.map((stat, i) => (
<div key={stat.label} className="reveal-scale text-center" style={{ animationDelay: `${i * 0.1}s` }}>
<div
className="text-5xl sm:text-6xl lg:text-7xl font-bold mb-4"
@@ -879,9 +1040,14 @@ export default function HeroSection() {
</div>
</div>
</section>
);
}
{/* ─── CTA SECTION - FINAL REVEAL ─────────────────────────────────────── */}
{/* id="reviews" provides anchor for nav "Reviews" link (matches nav expectations; section contains final messaging) */}
// ── CTA Section ────────────────────────────────────────────────────────────────
function CTASection() {
return (
// id="reviews" provides anchor for nav "Reviews" link (matches nav expectations; section contains final messaging)
<section
id="reviews"
className="relative py-40 overflow-hidden"
@@ -975,12 +1141,13 @@ export default function HeroSection() {
</div>
</div>
</section>
</div>
{/* /containerRef for GSAP scope */}
);
}
{/* (duplicate inline footer removed -- LandingPageWrapper <Footer /> now provides the only footer; eliminates repeated content + trims scroll height) */}
// ── Global Styles ──────────────────────────────────────────────────────────────
{/* ─── GLOBAL STYLES ────────────────────────────────────────────────────── */}
function GlobalStyles() {
return (
<style dangerouslySetInnerHTML={{ __html: `
@keyframes hs-float {
0%, 100% { transform: translate(0, 0); }
@@ -1045,7 +1212,6 @@ export default function HeroSection() {
height: 3px;
background: linear-gradient(to right, #c97a3e, #6b8f71);
}
`}} />
</>
` }} />
);
}
+2 -2
View File
@@ -58,7 +58,7 @@ const PLANS = {
name: "Farm",
description: "For growing farms with more needs",
monthlyPrice: 14900, // $149.00
annualPrice: 152280, // $1,522.80 (10% discount)
annualPrice: 134100, // $1,341.00 (25% discount; matches marketing pricing.ts)
features: [
"Unlimited products",
"Unlimited stops",
@@ -78,7 +78,7 @@ const PLANS = {
name: "Enterprise",
description: "Custom solution for larger operations",
monthlyPrice: 39900, // $399.00
annualPrice: 0, // Custom pricing
annualPrice: 359100, // $3,591.00 (25% discount; matches marketing pricing.ts)
features: [
"Everything in Farm",
"AI Intelligence Pack",