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:
@@ -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 3–5 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.5–3× 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.
|
||||
@@ -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:53–98
|
||||
|
||||
| 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:54–58) | 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` | **LOW–MEDIUM** — 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.")
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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,336 +95,490 @@ export default function PricingClientPage() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
{/* ── Nav bar ─────────────────────────────────────────────────────────── */}
|
||||
<header className="sticky top-0 z-50 border-b border-slate-100 bg-white/95 backdrop-blur">
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-emerald-600 to-emerald-500 text-white font-bold text-sm" aria-hidden="true">
|
||||
RC
|
||||
</div>
|
||||
<span className="text-lg font-bold text-slate-900">Route Commerce</span>
|
||||
</div>
|
||||
<nav className="flex items-center gap-6" aria-label="Main navigation">
|
||||
<Link href="/about" className="text-sm text-slate-500 hover:text-slate-900 transition-colors">About</Link>
|
||||
<Link href="/pricing" className="text-sm font-medium text-slate-900" aria-current="page">Pricing</Link>
|
||||
<Link
|
||||
href="/admin"
|
||||
className="rounded-lg bg-gradient-to-r from-emerald-600 to-emerald-500 px-4 py-2 text-sm font-medium text-white hover:from-emerald-500 hover:to-emerald-400 transition-all shadow-md"
|
||||
>
|
||||
Get Started
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ── Hero ────────────────────────────────────────────────────────────── */}
|
||||
<section className="bg-gradient-to-b from-slate-50 to-white px-4 sm:px-6 py-16 sm:py-20 text-center" aria-labelledby="pricing-heading">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<div className="mb-4 inline-flex items-center gap-2 rounded-full bg-emerald-50 px-3 sm:px-4 py-1.5 text-xs sm:text-sm font-medium text-emerald-700">
|
||||
<span className="text-xs" aria-hidden="true">✦</span>
|
||||
Built for produce wholesale operations
|
||||
</div>
|
||||
<h1 id="pricing-heading" className="text-4xl sm:text-5xl md:text-6xl font-bold tracking-tight text-slate-900">
|
||||
Pricing that scales<br className="hidden sm:block" /> with your operation
|
||||
</h1>
|
||||
<p className="mt-4 sm:mt-6 text-lg sm:text-xl text-slate-500">
|
||||
From small farms to enterprise distributors — everything you need to manage orders, stops, communications, and billing in one platform.
|
||||
</p>
|
||||
<div className="mt-6 sm:mt-8 flex items-center justify-center gap-4 flex-wrap">
|
||||
<BillingToggle cycle={billingCycle} onChange={setBillingCycle} />
|
||||
<span className="text-xs sm:text-sm text-emerald-600 font-medium">Save 25% with annual</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Plan cards ───────────────────────────────────────────────────────── */}
|
||||
<section className="mx-auto max-w-6xl px-4 sm:px-6 py-4 sm:py-6" aria-labelledby="plans-heading">
|
||||
<h2 id="plans-heading" className="sr-only">Available Plans</h2>
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{(Object.entries(PLAN_TIERS) as [keyof typeof PLAN_TIERS, typeof PLAN_TIERS[keyof typeof PLAN_TIERS]][]).map(([key, plan]) => {
|
||||
const price = billingCycle === "annual" ? plan.annualPrice : plan.monthlyPrice;
|
||||
const monthlyEquivalent = billingCycle === "annual" && plan.annualPrice ? Math.round(plan.annualPrice / 12) : null;
|
||||
const isMostPopular = plan.highlighted;
|
||||
|
||||
return (
|
||||
<article
|
||||
key={key}
|
||||
className={`relative flex flex-col rounded-2xl border-2 p-5 sm:p-6 transition-transform hover:-translate-y-1 ${
|
||||
isMostPopular ? "border-emerald-500 shadow-lg shadow-emerald-100" : "border-slate-200 shadow-sm"
|
||||
}`}
|
||||
>
|
||||
{isMostPopular && (
|
||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2 rounded-full bg-gradient-to-r from-emerald-600 to-emerald-500 px-3 sm:px-4 py-1 text-xs font-bold text-white uppercase tracking-wide shadow-md">
|
||||
Most Popular
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-4 sm:mb-5">
|
||||
<h3 className="text-base sm:text-lg font-bold text-slate-900">{plan.label}</h3>
|
||||
<p className="mt-1 text-xs sm:text-sm text-slate-500">{plan.description}</p>
|
||||
</div>
|
||||
<div className="mb-1">
|
||||
<span className="text-3xl sm:text-4xl font-bold text-slate-900">${price}</span>
|
||||
<span className="ml-1 text-slate-400 text-sm">/{billingCycle === "annual" ? "yr" : "mo"}</span>
|
||||
</div>
|
||||
{monthlyEquivalent !== null && (
|
||||
<p className="mb-3 sm:mb-4 text-xs text-slate-400">${monthlyEquivalent}/mo equivalent</p>
|
||||
)}
|
||||
<Link
|
||||
href="/admin"
|
||||
className={`mt-auto rounded-xl px-4 py-2.5 text-center text-sm font-medium transition-all ${
|
||||
isMostPopular
|
||||
? "bg-gradient-to-r from-emerald-600 to-emerald-500 text-white hover:from-emerald-500 hover:to-emerald-400 shadow-md"
|
||||
: "border border-slate-300 text-slate-700 hover:bg-slate-50"
|
||||
}`}
|
||||
>
|
||||
{key === "enterprise" ? "Contact Sales" : "Get Started"}
|
||||
</Link>
|
||||
<ul className="mt-6 space-y-2.5" aria-label={`${plan.label} features`}>
|
||||
{(plan.features as readonly string[]).map((f) => (
|
||||
<li key={f} className="flex items-start gap-2.5 text-sm text-slate-600">
|
||||
<svg className="mt-0.5 h-4 w-4 text-emerald-500 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex items-center justify-center">
|
||||
<button type="button"
|
||||
onClick={() => setCompareOpen(!compareOpen)}
|
||||
className="text-sm text-emerald-600 hover:underline font-medium transition-colors"
|
||||
aria-expanded={compareOpen}
|
||||
aria-controls="compare-table"
|
||||
>
|
||||
{compareOpen ? "Hide" : "Compare"} all features →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Compare table */}
|
||||
{compareOpen && (
|
||||
<div id="compare-table" className="mt-6 rounded-2xl border border-slate-200 overflow-hidden shadow-sm">
|
||||
<div className="border-b border-slate-100 bg-slate-50 px-6 py-3 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-slate-900">Full Feature Comparison</h3>
|
||||
<button type="button"
|
||||
onClick={() => setCompareOpen(false)}
|
||||
className="text-xs text-slate-400 hover:text-slate-600 transition-colors"
|
||||
aria-label="Close comparison table"
|
||||
>
|
||||
✕ Close
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-100">
|
||||
<th className="py-3 pr-6 text-left font-semibold text-slate-500 w-2/5" scope="col">Feature</th>
|
||||
{(["starter", "farm", "enterprise"] as const).map((tier) => {
|
||||
const p = PLAN_TIERS[tier];
|
||||
return (
|
||||
<th key={tier} className="py-3 px-4 text-center" scope="col">
|
||||
<span className={`rounded-full px-3 py-0.5 text-xs font-bold uppercase ${p.color}`}>{p.label}</span>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[
|
||||
["Products catalog", { starter: true, farm: true, enterprise: true }],
|
||||
["Stop / route management", { starter: "10/mo", farm: "Unlimited", enterprise: "Unlimited" }],
|
||||
["Orders processing", { starter: true, farm: true, enterprise: true }],
|
||||
["Pickup & fulfillment", { starter: true, farm: true, enterprise: true }],
|
||||
["Admin users", { starter: "1", farm: "5", enterprise: "Unlimited" }],
|
||||
["Products limit", { starter: "25", farm: "Unlimited", enterprise: "Unlimited" }],
|
||||
["Reporting", { starter: "Standard", farm: "Advanced", enterprise: "Advanced + AI" }],
|
||||
["Email support", { starter: true, farm: true, enterprise: "Dedicated" }],
|
||||
["Wholesale Portal", { starter: false, farm: true, enterprise: true }],
|
||||
["Harvest Reach (Email & SMS)", { starter: false, farm: true, enterprise: true }],
|
||||
["AI Intelligence Pack", { starter: false, farm: false, enterprise: true }],
|
||||
["SMS Campaigns", { starter: false, farm: false, enterprise: true }],
|
||||
["Square Inventory Sync", { starter: false, farm: false, enterprise: true }],
|
||||
["Water Log", { starter: false, farm: false, enterprise: true }],
|
||||
["Unlimited brands", { starter: false, farm: false, enterprise: true }],
|
||||
["Custom development", { starter: false, farm: false, enterprise: true }],
|
||||
["SLA guarantee", { starter: false, farm: false, enterprise: true }],
|
||||
].map(([feature, tiers], rowIndex) => {
|
||||
const t = tiers as Record<string, boolean | string>;
|
||||
return (
|
||||
<tr key={feature as string} className={`border-t ${rowIndex % 2 === 0 ? 'bg-white' : 'bg-slate-50/50'}`}>
|
||||
<td className="py-2.5 pr-6 text-slate-600">{feature as string}</td>
|
||||
{(["starter", "farm", "enterprise"] as const).map((tier) => {
|
||||
const val = t[tier];
|
||||
return (
|
||||
<td key={tier} className="py-2.5 px-4 text-center text-sm">
|
||||
{val === true ? (
|
||||
<svg className="h-4 w-4 text-emerald-500 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-label="Included">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : val === false ? (
|
||||
<span className="text-slate-300" aria-label="Not included">—</span>
|
||||
) : (
|
||||
<span className="text-slate-700">{val}</span>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── Add-ons ───────────────────────────────────────────────────────────── */}
|
||||
<section className="border-t border-slate-100 bg-slate-50 px-6 py-16" aria-labelledby="addons-heading">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<h2 id="addons-heading" className="text-center text-3xl font-bold text-slate-900 mb-2">Power-Up with Add-ons</h2>
|
||||
<p className="text-center text-slate-500 mb-10">Add capabilities à la carte on any plan. No bundles required.</p>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{ADDON_LIST.map(({ key, label, icon, description }) => {
|
||||
const addon = ADDONS[key as keyof typeof ADDONS];
|
||||
if (!addon) return null;
|
||||
const price = billingCycle === "annual" ? addon.annualPrice : addon.monthlyPrice;
|
||||
const monthlyEquiv = billingCycle === "annual" ? Math.round(addon.annualPrice / 12) : null;
|
||||
return (
|
||||
<article key={key} className="flex items-start gap-4 rounded-2xl border border-slate-200 bg-white p-5 transition-all hover:border-emerald-200 hover:shadow-md">
|
||||
<span className="text-2xl leading-none" aria-hidden="true">{icon}</span>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-slate-900">{label}</h3>
|
||||
<p className="mt-1 text-xs text-slate-500">{description}</p>
|
||||
<div className="mt-3 flex items-baseline gap-1">
|
||||
<span className="text-xl font-bold text-slate-900">${price}</span>
|
||||
<span className="text-xs text-slate-400">/{billingCycle === "annual" ? "yr" : "mo"}</span>
|
||||
{monthlyEquiv !== null && (
|
||||
<span className="ml-1 text-xs text-slate-400">(${monthlyEquiv}/mo equiv.)</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Social proof ─────────────────────────────────────────────────────── */}
|
||||
<section className="border-t border-slate-100 px-6 py-16" aria-labelledby="testimonials-heading">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<h2 id="testimonials-heading" className="text-center text-2xl font-bold text-slate-900 mb-8">Trusted by produce operations across the US</h2>
|
||||
<div className="grid gap-6 sm:grid-cols-3">
|
||||
{[
|
||||
{ quote: "We went from managing 12 spreadsheets to one platform. Route Commerce cut our order chaos by 80%.", name: "Marcus T., Fresh Fields Farm", location: "California" },
|
||||
{ quote: "Harvest Reach alone paid for the subscription. Our pickup rate went from 70% to 94% in two months.", name: "Sandra K., Pacific Produce Co-op", location: "Oregon" },
|
||||
{ quote: "The wholesale portal saved us 6 hours a week on order entry. Buyers love the self-service.", name: "James R., Gulf Coast Distribution", location: "Florida" },
|
||||
].map((item, i) => (
|
||||
<article key={`${item.name}-${i}`} className="rounded-2xl border border-slate-200 p-6 transition-all hover:shadow-md">
|
||||
<div className="flex gap-1 mb-3" aria-label="5 star rating">
|
||||
{["★", "★", "★", "★", "★"].map((s, j) => (
|
||||
<span key={`star-${i}-${j}`} className="text-amber-400 text-sm" aria-hidden="true">{s}</span>
|
||||
))}
|
||||
</div>
|
||||
<blockquote>
|
||||
<p className="text-slate-700 text-sm leading-relaxed">“{item.quote}”</p>
|
||||
</blockquote>
|
||||
<p className="mt-3 text-xs font-semibold text-slate-900">{item.name}</p>
|
||||
<p className="text-xs text-slate-400">{item.location}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── FAQ ─────────────────────────────────────────────────────────────── */}
|
||||
<section className="border-t border-slate-100 bg-slate-50 px-6 py-16" aria-labelledby="faq-heading">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<h2 id="faq-heading" className="text-center text-3xl font-bold text-slate-900 mb-10">Frequently Asked Questions</h2>
|
||||
<div className="space-y-3">
|
||||
{FAQ_ITEMS.map((item, i) => (
|
||||
<div key={item.q} className="rounded-xl border border-slate-200 bg-white">
|
||||
<button type="button"
|
||||
onClick={() => toggleFaq(i)}
|
||||
className="flex w-full items-center justify-between px-5 py-4 text-left transition-colors hover:bg-slate-50"
|
||||
aria-expanded={openFaq === i}
|
||||
aria-controls={`faq-answer-${i}`}
|
||||
>
|
||||
<span className="text-sm font-medium text-slate-900">{item.q}</span>
|
||||
<span
|
||||
className={`ml-3 text-slate-400 text-lg transition-transform ${openFaq === i ? "rotate-45" : ""}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
+
|
||||
</span>
|
||||
</button>
|
||||
{openFaq === i && (
|
||||
<div id={`faq-answer-${i}`} className="border-t border-slate-50 px-5 pb-4">
|
||||
<p className="text-sm text-slate-600 leading-relaxed">{item.a}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── CTA ──────────────────────────────────────────────────────────────── */}
|
||||
<section className="bg-gradient-to-br from-slate-900 to-slate-800 px-6 py-20 text-center" aria-labelledby="cta-heading">
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<h2 id="cta-heading" className="text-4xl font-bold text-white">Ready to grow your operation?</h2>
|
||||
<p className="mt-4 text-lg text-slate-400">Start free on Starter. No credit card required. Upgrade when you're ready.</p>
|
||||
<div className="mt-8 flex items-center justify-center gap-4 flex-wrap">
|
||||
<Link
|
||||
href="/admin"
|
||||
className="rounded-xl bg-gradient-to-r from-emerald-500 to-emerald-400 px-8 py-3 text-base font-bold text-white hover:from-emerald-400 hover:to-emerald-300 transition-all shadow-lg shadow-emerald-500/25"
|
||||
>
|
||||
Start for Free →
|
||||
</Link>
|
||||
<Link
|
||||
href="/contact"
|
||||
className="rounded-xl border border-slate-600 px-8 py-3 text-base font-medium text-slate-300 hover:border-slate-500 hover:text-white transition-all"
|
||||
>
|
||||
Talk to Us
|
||||
</Link>
|
||||
</div>
|
||||
<p className="mt-6 text-xs text-slate-500">
|
||||
Invoiced by Cielo Hermosa, LLC · <a href="mailto:billing@cielohermosa.com" className="underline hover:text-slate-400 transition-colors">billing@routecommerce.com</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Footer ──────────────────────────────────────────────────────────── */}
|
||||
<footer className="border-t border-slate-200 bg-white px-6 py-8">
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between flex-wrap gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-emerald-600 to-emerald-500 text-white font-bold text-xs" aria-hidden="true">RC</div>
|
||||
<span className="text-sm font-bold text-slate-700">Route Commerce</span>
|
||||
</div>
|
||||
<nav className="flex items-center gap-6" aria-label="Footer navigation">
|
||||
<Link href="/privacy-policy" className="text-xs text-slate-400 hover:text-slate-600 transition-colors">Privacy</Link>
|
||||
<Link href="/terms-and-conditions" className="text-xs text-slate-400 hover:text-slate-600 transition-colors">Terms</Link>
|
||||
<a href="mailto:team@cielohermosa.com" className="text-xs text-slate-400 hover:text-slate-600 transition-colors">Contact</a>
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
<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">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-emerald-600 to-emerald-500 text-white font-bold text-sm" aria-hidden="true">
|
||||
RC
|
||||
</div>
|
||||
<span className="text-lg font-bold text-slate-900">Route Commerce</span>
|
||||
</div>
|
||||
<nav className="flex items-center gap-6" aria-label="Main navigation">
|
||||
<Link href="/about" className="text-sm text-slate-500 hover:text-slate-900 transition-colors">About</Link>
|
||||
<Link href="/pricing" className="text-sm font-medium text-slate-900" aria-current="page">Pricing</Link>
|
||||
<Link
|
||||
href="/admin"
|
||||
className="rounded-lg bg-gradient-to-r from-emerald-600 to-emerald-500 px-4 py-2 text-sm font-medium text-white hover:from-emerald-500 hover:to-emerald-400 transition-all shadow-md"
|
||||
>
|
||||
Get Started
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function Hero({ billingCycle, onCycleChange }: { billingCycle: BillingCycle; onCycleChange: (c: BillingCycle) => void }) {
|
||||
return (
|
||||
<section className="bg-gradient-to-b from-slate-50 to-white px-4 sm:px-6 py-16 sm:py-20 text-center" aria-labelledby="pricing-heading">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<div className="mb-4 inline-flex items-center gap-2 rounded-full bg-emerald-50 px-3 sm:px-4 py-1.5 text-xs sm:text-sm font-medium text-emerald-700">
|
||||
<span className="text-xs" aria-hidden="true">✦</span>
|
||||
Built for produce wholesale operations
|
||||
</div>
|
||||
<h1 id="pricing-heading" className="text-4xl sm:text-5xl md:text-6xl font-bold tracking-tight text-slate-900">
|
||||
Pricing that scales<br className="hidden sm:block" /> with your operation
|
||||
</h1>
|
||||
<p className="mt-4 sm:mt-6 text-lg sm:text-xl text-slate-500">
|
||||
From small farms to enterprise distributors — everything you need to manage orders, stops, communications, and billing in one platform.
|
||||
</p>
|
||||
<div className="mt-6 sm:mt-8 flex items-center justify-center gap-4 flex-wrap">
|
||||
<BillingToggle cycle={billingCycle} onChange={onCycleChange} />
|
||||
<span className="text-xs sm:text-sm text-emerald-600 font-medium">Save 25% with annual</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function PlansSection({
|
||||
billingCycle,
|
||||
compareOpen,
|
||||
onToggleCompare,
|
||||
onCloseCompare,
|
||||
}: {
|
||||
billingCycle: BillingCycle;
|
||||
compareOpen: boolean;
|
||||
onToggleCompare: () => void;
|
||||
onCloseCompare: () => void;
|
||||
}) {
|
||||
return (
|
||||
<section className="mx-auto max-w-6xl px-4 sm:px-6 py-4 sm:py-6" aria-labelledby="plans-heading">
|
||||
<h2 id="plans-heading" className="sr-only">Available Plans</h2>
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{(Object.entries(PLAN_TIERS) as [keyof typeof PLAN_TIERS, typeof PLAN_TIERS[keyof typeof PLAN_TIERS]][]).map(([key, plan]) => (
|
||||
<PlanCard
|
||||
key={key}
|
||||
planKey={key}
|
||||
plan={plan}
|
||||
billingCycle={billingCycle}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex items-center justify-center">
|
||||
<button type="button"
|
||||
onClick={onToggleCompare}
|
||||
className="text-sm text-emerald-600 hover:underline font-medium transition-colors"
|
||||
aria-expanded={compareOpen}
|
||||
aria-controls="compare-table"
|
||||
>
|
||||
{compareOpen ? "Hide" : "Compare"} all features →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{compareOpen && <CompareTable onClose={onCloseCompare} />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function PlanCard({
|
||||
planKey,
|
||||
plan,
|
||||
billingCycle,
|
||||
}: {
|
||||
planKey: keyof typeof PLAN_TIERS;
|
||||
plan: typeof PLAN_TIERS[keyof typeof PLAN_TIERS];
|
||||
billingCycle: BillingCycle;
|
||||
}) {
|
||||
const price = billingCycle === "annual" ? plan.annualPrice : plan.monthlyPrice;
|
||||
const monthlyEquivalent = billingCycle === "annual" && plan.annualPrice ? Math.round(plan.annualPrice / 12) : null;
|
||||
const isMostPopular = plan.highlighted;
|
||||
const isEnterprise = planKey === "enterprise";
|
||||
|
||||
return (
|
||||
<article
|
||||
className={`relative flex flex-col rounded-2xl border-2 p-5 sm:p-6 transition-transform hover:-translate-y-1 ${
|
||||
isMostPopular ? "border-emerald-500 shadow-lg shadow-emerald-100" : "border-slate-200 shadow-sm"
|
||||
}`}
|
||||
>
|
||||
{isMostPopular && (
|
||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2 rounded-full bg-gradient-to-r from-emerald-600 to-emerald-500 px-3 sm:px-4 py-1 text-xs font-bold text-white uppercase tracking-wide shadow-md">
|
||||
Most Popular
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-4 sm:mb-5">
|
||||
<h3 className="text-base sm:text-lg font-bold text-slate-900">{plan.label}</h3>
|
||||
<p className="mt-1 text-xs sm:text-sm text-slate-500">{plan.description}</p>
|
||||
</div>
|
||||
<div className="mb-1">
|
||||
<span className="text-3xl sm:text-4xl font-bold text-slate-900">${price}</span>
|
||||
<span className="ml-1 text-slate-400 text-sm">/{billingCycle === "annual" ? "yr" : "mo"}</span>
|
||||
</div>
|
||||
{monthlyEquivalent !== null && (
|
||||
<p className="mb-3 sm:mb-4 text-xs text-slate-400">${monthlyEquivalent}/mo equivalent</p>
|
||||
)}
|
||||
<Link
|
||||
href="/admin"
|
||||
className={`mt-auto rounded-xl px-4 py-2.5 text-center text-sm font-medium transition-all ${
|
||||
isMostPopular
|
||||
? "bg-gradient-to-r from-emerald-600 to-emerald-500 text-white hover:from-emerald-500 hover:to-emerald-400 shadow-md"
|
||||
: "border border-slate-300 text-slate-700 hover:bg-slate-50"
|
||||
}`}
|
||||
>
|
||||
{isEnterprise ? "Contact Sales" : "Get Started"}
|
||||
</Link>
|
||||
<ul className="mt-6 space-y-2.5" aria-label={`${plan.label} features`}>
|
||||
{(plan.features as readonly string[]).map((f) => (
|
||||
<PlanFeature key={f} feature={f} />
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function PlanFeature({ feature }: { feature: string }) {
|
||||
return (
|
||||
<li className="flex items-start gap-2.5 text-sm text-slate-600">
|
||||
<svg className="mt-0.5 h-4 w-4 text-emerald-500 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{feature}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function CompareTable({ onClose }: { onClose: () => void }) {
|
||||
return (
|
||||
<div id="compare-table" className="mt-6 rounded-2xl border border-slate-200 overflow-hidden shadow-sm">
|
||||
<div className="border-b border-slate-100 bg-slate-50 px-6 py-3 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-slate-900">Full Feature Comparison</h3>
|
||||
<button type="button"
|
||||
onClick={onClose}
|
||||
className="text-xs text-slate-400 hover:text-slate-600 transition-colors"
|
||||
aria-label="Close comparison table"
|
||||
>
|
||||
✕ Close
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-100">
|
||||
<th className="py-3 pr-6 text-left font-semibold text-slate-500 w-2/5" scope="col">Feature</th>
|
||||
{(["starter", "farm", "enterprise"] as const).map((tier) => (
|
||||
<CompareHeaderCell key={tier} tier={tier} />
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{COMPARE_ROWS.map((row, rowIndex) => (
|
||||
<CompareRow key={row.feature} row={row} rowIndex={rowIndex} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CompareHeaderCell({ tier }: { tier: CompareTier }) {
|
||||
const p = PLAN_TIERS[tier];
|
||||
return (
|
||||
<th className="py-3 px-4 text-center" scope="col">
|
||||
<span className={`rounded-full px-3 py-0.5 text-xs font-bold uppercase ${p.color}`}>{p.label}</span>
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
function CompareRow({ row, rowIndex }: { row: CompareRow; rowIndex: number }) {
|
||||
return (
|
||||
<tr className={`border-t ${rowIndex % 2 === 0 ? 'bg-white' : 'bg-slate-50/50'}`}>
|
||||
<td className="py-2.5 pr-6 text-slate-600">{row.feature}</td>
|
||||
{(["starter", "farm", "enterprise"] as const).map((tier) => (
|
||||
<CompareCell key={tier} value={row[tier]} />
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function CompareCell({ value }: { value: boolean | string }) {
|
||||
if (value === true) {
|
||||
return (
|
||||
<td className="py-2.5 px-4 text-center text-sm">
|
||||
<svg className="h-4 w-4 text-emerald-500 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-label="Included">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
if (value === false) {
|
||||
return (
|
||||
<td className="py-2.5 px-4 text-center text-sm">
|
||||
<span className="text-slate-300" aria-label="Not included">—</span>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<td className="py-2.5 px-4 text-center text-sm">
|
||||
<span className="text-slate-700">{value}</span>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
function AddOnsSection({ billingCycle }: { billingCycle: BillingCycle }) {
|
||||
return (
|
||||
<section className="border-t border-slate-100 bg-slate-50 px-6 py-16" aria-labelledby="addons-heading">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<h2 id="addons-heading" className="text-center text-3xl font-bold text-slate-900 mb-2">Power-Up with Add-ons</h2>
|
||||
<p className="text-center text-slate-500 mb-10">Add capabilities à la carte on any plan. No bundles required.</p>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{ADDON_LIST.map(({ key, label, icon, description }) => (
|
||||
<AddOnCard
|
||||
key={key}
|
||||
addonKey={key as keyof typeof ADDONS}
|
||||
label={label}
|
||||
icon={icon}
|
||||
description={description}
|
||||
billingCycle={billingCycle}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AddOnCard({
|
||||
addonKey,
|
||||
label,
|
||||
icon,
|
||||
description,
|
||||
billingCycle,
|
||||
}: {
|
||||
addonKey: keyof typeof ADDONS;
|
||||
label: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
billingCycle: BillingCycle;
|
||||
}) {
|
||||
const addon = ADDONS[addonKey];
|
||||
if (!addon) return null;
|
||||
const price = billingCycle === "annual" ? addon.annualPrice : addon.monthlyPrice;
|
||||
const monthlyEquiv = billingCycle === "annual" ? Math.round(addon.annualPrice / 12) : null;
|
||||
return (
|
||||
<article className="flex items-start gap-4 rounded-2xl border border-slate-200 bg-white p-5 transition-all hover:border-emerald-200 hover:shadow-md">
|
||||
<span className="text-2xl leading-none" aria-hidden="true">{icon}</span>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-slate-900">{label}</h3>
|
||||
<p className="mt-1 text-xs text-slate-500">{description}</p>
|
||||
<div className="mt-3 flex items-baseline gap-1">
|
||||
<span className="text-xl font-bold text-slate-900">${price}</span>
|
||||
<span className="text-xs text-slate-400">/{billingCycle === "annual" ? "yr" : "mo"}</span>
|
||||
{monthlyEquiv !== null && (
|
||||
<span className="ml-1 text-xs text-slate-400">(${monthlyEquiv}/mo equiv.)</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function Testimonials() {
|
||||
// Audit 2026-06-26: previously rendered fabricated named testimonials.
|
||||
// Replaced with a single honest line until real, attributed customer
|
||||
// quotes are collected and consented.
|
||||
if (TESTIMONIALS.length === 0) {
|
||||
return (
|
||||
<section className="border-t border-slate-100 px-6 py-16" aria-labelledby="testimonials-heading">
|
||||
<div className="mx-auto max-w-3xl text-center">
|
||||
<h2 id="testimonials-heading" className="text-2xl font-bold text-slate-900 mb-3">
|
||||
Early access
|
||||
</h2>
|
||||
<p className="text-slate-500">
|
||||
We're in early access with produce brands across the country. Customer stories coming soon —
|
||||
<a href="/contact" className="text-emerald-600 hover:underline font-medium">
|
||||
talk to us
|
||||
</a>
|
||||
if you'd like to be a reference customer.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<section className="border-t border-slate-100 px-6 py-16" aria-labelledby="testimonials-heading">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<h2 id="testimonials-heading" className="text-center text-2xl font-bold text-slate-900 mb-8">Trusted by produce operations across the US</h2>
|
||||
<div className="grid gap-6 sm:grid-cols-3">
|
||||
{TESTIMONIALS.map((item, i) => (
|
||||
<TestimonialCard key={`${item.name}-${i}`} item={item} index={i} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TestimonialCard({ item, index }: { item: { quote: string; name: string; location: string }; index: number }) {
|
||||
return (
|
||||
<article className="rounded-2xl border border-slate-200 p-6 transition-all hover:shadow-md">
|
||||
<div className="flex gap-1 mb-3" aria-label="5 star rating">
|
||||
{["★", "★", "★", "★", "★"].map((s, j) => (
|
||||
<span key={`star-${index}-${j}`} className="text-amber-400 text-sm" aria-hidden="true">{s}</span>
|
||||
))}
|
||||
</div>
|
||||
<blockquote>
|
||||
<p className="text-slate-700 text-sm leading-relaxed">“{item.quote}”</p>
|
||||
</blockquote>
|
||||
<p className="mt-3 text-xs font-semibold text-slate-900">{item.name}</p>
|
||||
<p className="text-xs text-slate-400">{item.location}</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function FAQSection({
|
||||
openFaq,
|
||||
onToggle,
|
||||
}: {
|
||||
openFaq: number | null;
|
||||
onToggle: (index: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<section className="border-t border-slate-100 bg-slate-50 px-6 py-16" aria-labelledby="faq-heading">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<h2 id="faq-heading" className="text-center text-3xl font-bold text-slate-900 mb-10">Frequently Asked Questions</h2>
|
||||
<div className="space-y-3">
|
||||
{FAQ_ITEMS.map((item, i) => (
|
||||
<FAQItem
|
||||
key={item.q}
|
||||
question={item.q}
|
||||
answer={item.a}
|
||||
isOpen={openFaq === i}
|
||||
onToggle={() => onToggle(i)}
|
||||
index={i}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function FAQItem({
|
||||
question,
|
||||
answer,
|
||||
isOpen,
|
||||
onToggle,
|
||||
index,
|
||||
}: {
|
||||
question: string;
|
||||
answer: string;
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
index: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white">
|
||||
<button type="button"
|
||||
onClick={onToggle}
|
||||
className="flex w-full items-center justify-between px-5 py-4 text-left transition-colors hover:bg-slate-50"
|
||||
aria-expanded={isOpen}
|
||||
aria-controls={`faq-answer-${index}`}
|
||||
>
|
||||
<span className="text-sm font-medium text-slate-900">{question}</span>
|
||||
<span
|
||||
className={`ml-3 text-slate-400 text-lg transition-transform ${isOpen ? "rotate-45" : ""}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
+
|
||||
</span>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div id={`faq-answer-${index}`} className="border-t border-slate-50 px-5 pb-4">
|
||||
<p className="text-sm text-slate-600 leading-relaxed">{answer}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CTA() {
|
||||
return (
|
||||
<section className="bg-gradient-to-br from-slate-900 to-slate-800 px-6 py-20 text-center" aria-labelledby="cta-heading">
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<h2 id="cta-heading" className="text-4xl font-bold text-white">Ready to grow your operation?</h2>
|
||||
<p className="mt-4 text-lg text-slate-400">Start free on Starter. No credit card required. Upgrade when you're ready.</p>
|
||||
<div className="mt-8 flex items-center justify-center gap-4 flex-wrap">
|
||||
<Link
|
||||
href="/admin"
|
||||
className="rounded-xl bg-gradient-to-r from-emerald-500 to-emerald-400 px-8 py-3 text-base font-bold text-white hover:from-emerald-400 hover:to-emerald-300 transition-all shadow-lg shadow-emerald-500/25"
|
||||
>
|
||||
Start for Free →
|
||||
</Link>
|
||||
<Link
|
||||
href="/contact"
|
||||
className="rounded-xl border border-slate-600 px-8 py-3 text-base font-medium text-slate-300 hover:border-slate-500 hover:text-white transition-all"
|
||||
>
|
||||
Talk to Us
|
||||
</Link>
|
||||
</div>
|
||||
<p className="mt-6 text-xs text-slate-500">
|
||||
Invoiced by Cielo Hermosa, LLC · <a href="mailto:billing@cielohermosa.com" className="underline hover:text-slate-400 transition-colors">billing@routecommerce.com</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SiteFooter() {
|
||||
return (
|
||||
<footer className="border-t border-slate-200 bg-white px-6 py-8">
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between flex-wrap gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-emerald-600 to-emerald-500 text-white font-bold text-xs" aria-hidden="true">RC</div>
|
||||
<span className="text-sm font-bold text-slate-700">Route Commerce</span>
|
||||
</div>
|
||||
<nav className="flex items-center gap-6" aria-label="Footer navigation">
|
||||
<Link href="/privacy-policy" className="text-xs text-slate-400 hover:text-slate-600 transition-colors">Privacy</Link>
|
||||
<Link href="/terms-and-conditions" className="text-xs text-slate-400 hover:text-slate-600 transition-colors">Terms</Link>
|
||||
<a href="mailto:team@cielohermosa.com" className="text-xs text-slate-400 hover:text-slate-600 transition-colors">Contact</a>
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
function BillingToggle({ cycle, onChange }: { cycle: BillingCycle; onChange: (c: BillingCycle) => void }) {
|
||||
return (
|
||||
<fieldset className="flex items-center gap-3 border-0 p-0 m-0" aria-label="Billing cycle selection">
|
||||
<button type="button"
|
||||
onClick={() => onChange("monthly")}
|
||||
className={`rounded-lg border px-4 py-1.5 text-sm font-medium transition-all ${
|
||||
cycle === "monthly"
|
||||
? "border-emerald-300 bg-white text-slate-900 shadow-sm"
|
||||
cycle === "monthly"
|
||||
? "border-emerald-300 bg-white text-slate-900 shadow-sm"
|
||||
: "border-slate-200 bg-slate-50 text-slate-400 hover:border-slate-300"
|
||||
}`}
|
||||
aria-pressed={cycle === "monthly"}
|
||||
@@ -400,8 +588,8 @@ function BillingToggle({ cycle, onChange }: { cycle: BillingCycle; onChange: (c:
|
||||
<button type="button"
|
||||
onClick={() => onChange("annual")}
|
||||
className={`rounded-lg border px-4 py-1.5 text-sm font-medium transition-all flex items-center gap-1.5 ${
|
||||
cycle === "annual"
|
||||
? "border-2 border-emerald-600 bg-emerald-50 text-emerald-700 shadow-sm"
|
||||
cycle === "annual"
|
||||
? "border-2 border-emerald-600 bg-emerald-50 text-emerald-700 shadow-sm"
|
||||
: "border border-slate-200 bg-slate-50 text-slate-400 hover:border-slate-300"
|
||||
}`}
|
||||
aria-pressed={cycle === "annual"}
|
||||
@@ -411,4 +599,4 @@ function BillingToggle({ cycle, onChange }: { cycle: BillingCycle; onChange: (c:
|
||||
</button>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`,
|
||||
|
||||
+20
-53
@@ -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'd love to hear it. Share your suggestion and vote on others.</p>
|
||||
<p className="text-[#666]">Have an idea? Email us and we'll add it to the list.</p>
|
||||
</div>
|
||||
<div className="bg-[#faf8f5] rounded-2xl p-6 border border-[#e5e5e5] text-center">
|
||||
<a
|
||||
href="mailto:hello@routecommerce.com?subject=Roadmap%20suggestion"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-[#1a4d2e] to-[#2d6a4f] text-white rounded-xl font-medium hover:from-[#2d6a4f] hover:to-[#1a4d2e] transition-all"
|
||||
>
|
||||
Suggest a Feature
|
||||
</a>
|
||||
<p className="mt-3 text-xs text-[#888]">
|
||||
Opens your email client to hello@routecommerce.com
|
||||
</p>
|
||||
</div>
|
||||
<form className="bg-[#faf8f5] rounded-2xl p-6 border border-[#e5e5e5]">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="feature-title" className="block text-sm font-medium text-[#1a1a1a] mb-2">
|
||||
Feature Title
|
||||
</label>
|
||||
<input aria-label="., Export Orders To CSV"
|
||||
type="text"
|
||||
id="feature-title"
|
||||
placeholder="e.g., Export orders to CSV"
|
||||
className="w-full px-4 py-3 rounded-xl border border-[#e5e5e5] bg-white text-[#1a1a1a] placeholder-[#888] focus:outline-none focus:ring-2 focus:ring-[#1a4d2e]/50 focus:border-[#1a4d2e] transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="feature-description" className="block text-sm font-medium text-[#1a1a1a] mb-2">
|
||||
Description
|
||||
</label>
|
||||
<textarea aria-label="Describe The Feature And How It Would Help You..."
|
||||
id="feature-description"
|
||||
rows={4}
|
||||
placeholder="Describe the feature and how it would help you..."
|
||||
className="w-full px-4 py-3 rounded-xl border border-[#e5e5e5] bg-white text-[#1a1a1a] placeholder-[#888] focus:outline-none focus:ring-2 focus:ring-[#1a4d2e]/50 focus:border-[#1a4d2e] transition-all resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="feature-category" className="block text-sm font-medium text-[#1a1a1a] mb-2">
|
||||
Category
|
||||
</label>
|
||||
<select aria-label="Feature Category"
|
||||
id="feature-category"
|
||||
className="w-full px-4 py-3 rounded-xl border border-[#e5e5e5] bg-white text-[#1a1a1a] focus:outline-none focus:ring-2 focus:ring-[#1a4d2e]/50 focus:border-[#1a4d2e] transition-all"
|
||||
>
|
||||
<option value="">Select a category</option>
|
||||
{CATEGORIES.reduce<typeof CATEGORIES>((acc, c) => {
|
||||
if (c !== "All") acc.push(c);
|
||||
return acc;
|
||||
}, []).map((category) => (
|
||||
<option key={category} value={category.toLowerCase()}>{category}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full px-6 py-3 bg-gradient-to-r from-[#1a4d2e] to-[#2d6a4f] text-white rounded-xl font-medium hover:from-[#2d6a4f] hover:to-[#1a4d2e] transition-all"
|
||||
>
|
||||
Submit Suggestion
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
+33
-27
@@ -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">
|
||||
|
||||
@@ -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(
|
||||
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);
|
||||
dispatch({
|
||||
type: "SET_ERROR",
|
||||
value:
|
||||
result.error ??
|
||||
"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" />
|
||||
@@ -326,4 +379,4 @@ export default function WholesaleLoginPage() {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+1152
-986
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user