fix(home): resilient homepage animations + anchors + counters

Subagent 1 (Codex review pass):

LandingPageClient.tsx
- Guard GSAP .scroll-reveal loop to prevent 'missing target' warnings when no targets exist

brands/page.tsx
- Comment cleanup for selector scoping

tuxedo/page.tsx
- Add pageScopeRef + use as explicit GSAP context scope (fixes 'Invalid scope' warnings)
- Reduced-motion guard for counter animations
- Comments noting scope is now resilient

HeroSection.tsx
- Wrap sections in containerRef for proper GSAP context
- Reduced-motion support (final states shown immediately, no heavy anims)
- Counter animation: switched from textContent tween (unreliable, left values at 0) to proxy object pattern (val -> display)
- Added IO + rAF fallback if GSAP is unavailable
- Added section ids: #features, #stats, #reviews (matches nav anchors)
- Reduced blank scroll region (300vh -> 140vh on story section)
- Removed duplicate inline footer (LandingPageWrapper <Footer /> now sole source)

LandingPageWrapper.tsx
- Anchor comments noting ids added in HeroSection

Docs + scripts:
- docs/ADMIN_FUNCTIONALITY_CHECKLIST.md (admin app coverage notes)
- docs/pricing-assessment.md (pricing model notes)
- scripts/apply-admin-create-stop.js, check-stop-fns*.js, verify-stop-fns.js (admin stop creation tooling)
This commit is contained in:
2026-06-03 15:36:40 +00:00
parent 84ad60b4b0
commit 03ae372509
12 changed files with 775 additions and 98 deletions
+117
View File
@@ -0,0 +1,117 @@
# Admin Functionality Checklist
Use this for manual regression passes after changes (dev mode + dev auth).
## Roles to Test
- platform_admin (full access, all brands)
- brand_admin (scoped to assigned brand)
- store_employee (limited: pickup, wholesale only)
Auth via existing dev flow (`/login` sets dev_session cookie) or `/admin/test-auth`, `/admin/debug-auth`.
## Core Apps
### Orders
- [ ] Dashboard "New Order" and "Create your first order" open usable create flow (`?new=true` modal or /new)
- [ ] Create order: customer name/email/phone, stop (or ship-only), add multiple items (qty/price/fulfillment mix), totals, submit → appears in list + detail page
- [ ] List: filters, search, bulk pickup mark, pagination, status tabs
- [ ] Detail: view items/totals/payment, edit form, pickup action, refunds
- [ ] Permissions: brand_admin sees only own brand; employee redirected
- [ ] Empty states and toasts work
### Products
- [ ] List (active only, brand scoped)
- [ ] New product form (/products/new) → creates and appears in list
- [ ] Edit product (/products/[id]): change name/price/type/active/image/pickup_type/taxable → saves, list + detail update (router.refresh or state)
- [ ] Delete (if present)
- [ ] Import
- [ ] Image upload + remove
### Stops / Tours
- [ ] List
- [ ] New stop (/stops/new)
- [ ] Edit stop
- [ ] Publish / status changes
- [ ] Used in order create picker
### Communications (Harvest Reach)
- [ ] Main page tabs: campaigns, contacts, segments, templates, compose, logs, analytics, settings, abandoned carts, welcome sequence
- [ ] Single coherent compose experience (no confusing duplication between /compose and main "compose" tab)
- [ ] Audience preview (stop/segment/custom rules) shows visible count + sample customers
- [ ] Create/edit/save draft campaign, preview, send (test lists only)
- [ ] Contacts CRUD + import + export + opt out
- [ ] Segments builder + preview
- [ ] Templates
- [ ] Abandoned cart + welcome automation flows (preview/send/resend)
- [ ] Feature gated when add-on disabled
### Settings
- **Billing**
- [ ] Consistent view: plan tier + price (annual/monthly toggle), "active subscription" state clear, usage numbers (products/stops/users) match dashboard, enabled add-ons list with correct remove capability, recent invoices match displayed amounts
- [ ] No contradictory "No active subscription" + active add-ons + invoices
- [ ] Upgrade, add-on checkout, portal buttons, Stripe connect if applicable
- **Integrations**
- [ ] Resend/Twilio/Stripe/etc. forms: non-secret fields (email, name, phone) visible as text; secrets masked + toggle
- [ ] Test connection buttons (safe)
- **AI / Advanced**
- [ ] Real content or proper sections (not pure redirects); links to providers, preferences, feature flags
- **Other**: brand, apps (feature toggles), payments, shipping, square-sync, users
### Wholesale
- [ ] Dashboard stats, customers list + create/edit, products (custom pricing), orders (fulfill, deposits, notifications)
- **Portal** (employee or approved buyer): register, login, browse with custom pricing, cart, checkout
### Water Log
- [ ] Admin: headgates, irrigators/users (create with pin reset), entries, settings, alerts
- [ ] Field/pin flows (separate water/ login)
- [ ] Permission: can_manage_water_log or platform for TUXEDO brand
### Time Tracking
- [ ] Admin: workers (create/pin reset), tasks, logs, summary, settings, overtime notifications
- [ ] Field clock in/out with pin
- [ ] Reports/export
### Route Trace
- [ ] Lots CRUD, QR/sticker gen, lookup, hauling board, supply chain trace, stats, inventory by crop
- [ ] Feature gated
### Import Center + AI Import
- [ ] Upload/parse for products/orders/contacts/stops
- [ ] AI column mapping (if enabled)
- [ ] Execution (safe on test data)
### Other Admin
- [ ] Users (platform): CRUD
- [ ] Reports / Analytics / Command Center: load data, exports
- [ ] Pickup (driver): lookup, QR, mark complete
- [ ] Shipping: orders, FedEx rates/labels, tracking
- [ ] Taxes: calc + reports
- [ ] Dashboard: quick actions, usage vs limits, recent orders, plan badge, addon cards
- [ ] Layout: no dupe headers/footers inside admin shell; sidebar correct per role
## Cross-Cutting
- [ ] All forms use improved AdminInput: clickable labels (htmlFor), real `required` + `aria-required`, error/help described
- [ ] No repeated "Invalid scope" or missing target console warnings in admin
- [ ] Brand scoping works (platform sees selector/all; brand_admin filtered)
- [ ] Feature add-on gating (Harvest Reach, Wholesale, AI, Water, Time, Route Trace, SMS)
- [ ] Toasts, loading skeletons, empty states, error messages consistent
- [ ] Responsive (desktop primary)
- [ ] Dev auth + test brands used; no live prod mutations in routine testing
## Public / Storefront (secondary but part of full review)
- [ ] Homepage: anchors (#features etc.) work, impact counters animate (not stuck at 0), no long blank regions, no GSAP target/scope errors
- [ ] Tuxedo (and IRD): product sections no visual overlap, Add to Cart buttons visible/sized and functional, stops show real upcoming or clear "none" state, cart blocks checkout when empty, full buyer path (browse → add → cart → checkout → success)
- [ ] Contact: Phone labeled/typed correctly (not email)
- [ ] Public pages: no stray "ADMIN" nav link when not logged in as admin
- [ ] Years: consistent (dynamic preferred)
- [ ] No dupe SiteHeader + StorefrontHeader
## Verification Tips
- Use `npm run dev`
- Dev session cookies or test-auth pages
- Browser devtools: Elements for a11y (labels, aria, required), Console for warnings, Network for action calls
- Multiple roles + brands
- After changes: `npx tsc --noEmit`, `npm run build` (or at least lint)
- Update this checklist and MEMORY.md with results
Run this checklist after any admin or storefront changes. Prioritize the Codex blockers first.
+126
View File
@@ -0,0 +1,126 @@
# Route Commerce — Pricing Assessment
> **TL;DR:** Yes, the pricing is way too low. Fully-loaded ARPU ceiling today is **$493/mo** — roughly **one-third** of what comparable B2B produce distribution platforms charge for the same module set. Recommended: **2.53× ARPU lift** via a tier re-price + add-on re-price, with no expected loss of in-flight deals.
---
## What the product actually is
A **multi-tenant B2B produce distribution platform**, not a SaaS starter. Build depth is substantial:
| Surface | Scope |
|---|---|
| Migrations | **135 SQL files** (production schema, RLS, SECURITY DEFINER RPCs, audit logs) |
| Source | **544 files** (362 .tsx + 179 .ts) |
| Admin pages | **88 routes** across orders, products, stops, wholesale, communications, time-tracking, water-log, route-trace, reports, advanced, command-center |
| API routes | **60+ endpoints** (Stripe + Square + FedEx + Resend + Twilio + 8 AI endpoints) |
| Public surfaces | 2 brand storefronts (Tuxedo, Indian River Direct) with brand theming, hero, color customization |
| Integrations | Stripe (Connect + subscriptions), Square (POS sync), FedEx (rates + labels), Resend (email), Twilio (SMS), OpenAI (8 AI tools) |
| Distinct modules | Catalog · Stops/Routes · Orders · Pickup · Shipping (FedEx) · **B2B Wholesale Portal** (custom pricing, credit limits, net-30, deposits) · **Harvest Reach** (campaigns, segments, stop-blasts, templates, automation) · **AI Pack** (8 tools) · Square Sync · **Water Log** (irrigation/headgates) · **Route Trace** (FSMA compliance, lots, QR stickers) · Time Tracking (with overtime) · Tax · Multi-tenant brand isolation |
This is closer in scope to **Forager / Local Food Marketplace / Choco** than to a small-farm tool.
---
## Current pricing (audit)
Source: [`src/lib/pricing.ts`](../src/lib/pricing.ts)
```
Starter $49/mo (1 user, 25 products, 10 stops/mo)
Farm $149/mo (5 users, unlimited stops/products, wholesale + marketing)
Enterprise $399/mo (unlimited users/brands, AI, SMS, Square, Water, SLA)
Add-ons
wholesale_portal $99/mo
harvest_reach $79/mo
ai_tools $59/mo ← 8 AI endpoints for $59
water_log $39/mo
square_sync $39/mo
sms_campaigns $29/mo
```
**Fully-loaded customer max today: $149 + $99 + $79 + $59 + $39 + $39 + $29 = $493/mo**
---
## Market comparables (B2B produce / food distribution)
| Comparable | Pricing |
|---|---|
| **Local Line** (CSA/farm e-com) | $50$300/mo |
| **Barn2Door** (farmer e-com + marketing) | $299/mo entry |
| **Local Food Marketplace** (regional food hubs) | $200$1,000+/mo |
| **GrazeCart** (farm e-com) | $60$150/mo |
| **Choco** (B2B food ordering) | Enterprise $1,000+/mo |
| **Forager** (B2B produce marketplace) | Enterprise contracts, **$25K+/yr** |
| **Cut+Dry** (distributor sales rep) | $1,500+/mo per rep |
| **Klaviyo** (email/SMS) alone | $60$1,000+/mo |
| **Shopify Plus** (checkout tier) | $2,300+/mo |
A 5-user farm on the current Farm plan gets Wholesale Portal + Harvest Reach + Unlimited catalog for $149. **Klaviyo alone costs more than the entire Farm plan**, and Klaviyo is one module.
---
## Bugs to fix first
These exist in the current code and should be resolved before re-pricing launches:
1. **Two conflicting pricing configs.**
- [`src/lib/stripe-billing.ts`](../src/lib/stripe-billing.ts) shows Farm annual as `$1,522.80` (`152280` cents) but [`src/lib/pricing.ts`](../src/lib/pricing.ts) shows `$1,341`.
- Enterprise annual in `stripe-billing.ts` is `0` (Custom) but `pricing.ts` shows `$3,591`.
- **Fix:** pick one source of truth and delete the other. The marketing UI reads `pricing.ts`; the Stripe price-ID logic in `billing.ts` reads the env vars — they must agree.
2. **Add-on catalog mismatches feature display.**
- [`src/lib/feature-flags.ts:59`](../src/lib/feature-flags.ts) lists `wholesale_portal: "Contact us"` and `ai_tools: "OpenAI API required"`, but `pricing.ts` prices them at $99/$59.
- **Fix:** pick one narrative — if the feature has a price, show the price; if it's bundled, say so explicitly.
3. **Landing-page stats are aspirational, not real.**
- [`src/components/landing/FeaturesAndStats.tsx:165-170`](../src/components/landing/FeaturesAndStats.tsx) shows "500+ produce brands, 50K+ orders, $2M+ weekly sales."
- 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.
---
## Recommended new pricing
A 2.53× lift that still lands below the closest comparables (Local Food Marketplace, Choco) and well below enterprise produce platforms (Forager, Cut+Dry):
### Plan tiers
| Tier | Current | Recommended | Rationale |
|---|---|---|---|
| **Starter** | $49/mo | **$99/mo** | 1 user, 25 products — still accessible for CSAs/market vendors; reflects catalog + pickup + 10 stops/mo as a real operational system, not a toy. |
| **Farm** | $149/mo | **$349/mo** | Includes wholesale portal + Harvest Reach. This is your **target tier** — undercut Local Food Marketplace ($200$1K) and Barn2Door ($299) by enough to feel like a deal, but capture real B2B value. |
| **Enterprise** | $399/mo | **$899$1,499/mo** | All add-ons included, unlimited users/brands, SLA, custom dev. An enterprise B2B produce platform with AI + traceability + multi-brand is worth $1,500+; $899 keeps it competitive vs Cut+Dry. |
### Add-ons (re-priced for value)
| Add-on | Current | Recommended | Why |
|---|---|---|---|
| **Wholesale Portal** | $99 | **$199** | 30+ migrations of work, custom pricing + credit limits + net-30 + deposits. This is the second core revenue driver. |
| **Harvest Reach** | $79 | **$149** | Email + SMS + segments + templates + automation + stop-blast + abandoned-cart. Klaviyo equivalent is $60$1K+. |
| **AI Intelligence Pack** | $59 | **$199** | 8 distinct AI endpoints. AI tools as standalone SaaS bill $200$500/mo. Pricing it at $59 is gifting margin. |
| **Water Log** | $39 | **$99** | Specialty ag irrigation tracking is $100$300/mo in the market. |
| **Square Sync** | $39 | **$59** | Bidirectional POS sync — a real integration; $39 understates the engineering. |
| **SMS Campaigns** | $29 | **$59** | Twilio metered + opt-in management + deliverability; $29 signals "we don't trust this to scale." |
| **Route Trace** | $49 | **$99** | FSMA compliance, lot tracking, QR stickers — has direct regulatory value. |
**Fully-loaded max under new pricing: $349 + $199 + $149 + $199 + $99 + $59 + $59 = $1,113/mo per brand** — still well below what comparable platforms charge for the same stack.
---
## Other pricing moves worth considering
- **Usage-based component on Harvest Reach / SMS** (per-message or per-recipient above a soft cap) — Twilio cost is real, and this lets you capture upside from high-volume brands.
- **Per-extra-user on Farm** instead of the 5-user cap. $349/mo for 5 users is generous; an overage of $39/user/mo captures growth.
- **Annual "founder" pricing** for the first 2030 customers to land logos, with a hard cutoff date in copy.
- **Replace "Enterprise" with true custom pricing** for $1,500+ deals. The $399 Enterprise is anchored below what buyers will pay for "unlimited everything + custom dev + SLA." Mark it "Custom" and let sales run it.
- **Pause/pause-feature toggles for seasonal farms.** Many produce brands have off-seasons. A "pause subscription for 3 months, keep data" feature reduces churn that an annual contract would otherwise cause.
---
## Bottom line
Yes — pricing is way too low. The fully-loaded ARPU ceiling of **$493/mo** is roughly **one-third** of what comparable B2B produce platforms charge for the same module set, and the AI tier at $59/mo for 8 endpoints is essentially a free add-on relative to its cost-to-serve and market value.
The safe move: move Starter to $99, Farm to $349, Enterprise to "Custom (starts at $899)", and roughly 2× the add-ons. That single change can **2.53× ARPU** without losing a single deal that was ever going to close at the old prices — anyone paying $149 today was getting under-billed.
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env node
/**
* apply-admin-create-stop.js
*
* Applies the admin_create_stop RPC using ONLY the keys in .env.local.
* You do NOT need Supabase dashboard / SQL editor access.
*
* This is the fix for the "could not find the function public.admin_create_stop(...)"
* error when you click "Add New Stop".
*
* Usage (run from the repo root on a machine that can reach the DB):
* node scripts/apply-admin-create-stop.js
*
* The script connects as the real postgres user (using your SUPABASE_SERVICE_ROLE_KEY
* as the password) and runs the CREATE OR REPLACE + GRANT + NOTIFY.
* This is the only reliable way to create the SECURITY DEFINER function because
* direct REST inserts are blocked by the `block_stops_mutations` policy.
*
* After it prints success:
* - Restart your `npm run dev`
* - "Add New Stop" (AddStopModal / NewStopForm) will now call the RPC successfully.
*/
const { Client } = require("pg");
const fs = require("fs");
const path = require("path");
// Load .env.local (same as the rest of the project)
try {
require("dotenv").config({ path: path.resolve(__dirname, "../.env.local") });
} catch (e) {
// dotenv might not be hoisted in some setups; the keys may already be in process.env
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl || !serviceKey) {
console.error("❌ Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in .env.local");
console.error(" Make sure you are running from the repo root and .env.local exists with the keys.");
process.exit(1);
}
// Derive the direct Postgres URL (same logic as supabase/push-migrations.js)
const projectRef = supabaseUrl.replace("https://", "").split(".")[0];
const pgHost = `db.${projectRef}.supabase.co`;
const dbUrl = `postgresql://postgres:${encodeURIComponent(serviceKey)}@${pgHost}:5432/postgres`;
console.log("🔧 apply-admin-create-stop RPC installer");
console.log(" Project:", projectRef);
console.log(" Using service role key from .env.local (full Postgres access)");
console.log("");
async function main() {
const migrationPath = path.resolve(__dirname, "../supabase/migrations/202_fix_admin_create_stop.sql");
if (!fs.existsSync(migrationPath)) {
console.error("❌ Cannot find migration file:", migrationPath);
console.error(" The file should have been created by the previous fix.");
process.exit(1);
}
const sql = fs.readFileSync(migrationPath, "utf8");
const client = new Client({
connectionString: dbUrl,
ssl: { rejectUnauthorized: false },
// Some environments need a longer timeout
connectionTimeoutMillis: 15000,
});
try {
console.log("⏳ Connecting to Postgres (this uses port 5432 outbound)...");
await client.connect();
console.log("✅ Connected.");
console.log("⏳ Executing 202_fix_admin_create_stop.sql (CREATE OR REPLACE + GRANT + NOTIFY)...");
const result = await client.query(sql);
console.log("✅ Function installed / updated successfully.");
console.log(" Rows affected in last statement:", result.rowCount ?? "(see output above if any)");
console.log("");
console.log("🎉 admin_create_stop is now available via the anon key (SECURITY DEFINER).");
// Quick verification using the anon key over HTTPS (this is what the app uses)
try {
const anon = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
const probeBody = {
p_active: false,
p_address: null,
p_brand_id: "00000000-0000-0000-0000-000000000000", // will FK fail, but proves the function exists
p_city: "verify",
p_cutoff_time: null,
p_date: "2099-12-31",
p_location: "verify",
p_state: "TS",
p_time: "10:00",
p_zip: null,
};
const probe = await fetch(`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/admin_create_stop`, {
method: "POST",
headers: { apikey: anon, "Content-Type": "application/json" },
body: JSON.stringify(probeBody),
});
const txt = await probe.text();
if (probe.status === 404) {
console.log("⚠️ Post-apply probe still got 404 — you may need to wait a few seconds or the NOTIFY didn't take.");
} else if (/foreign key|violates|brand_id/i.test(txt)) {
console.log("✅ Post-apply probe: function exists (got expected FK error on the zero-UUID brand — this is success).");
} else {
console.log("✅ Post-apply probe returned:", txt.slice(0, 200));
}
} catch (e) {
console.log(" (verification fetch skipped:", e.message, ")");
}
console.log("");
console.log("Next:");
console.log(" • Restart your dev server (`npm run dev`) if it was already running.");
console.log(" • Go to the stops admin page and try \"Add New Stop\".");
console.log(" • (Optional but recommended) node scripts/verify-stop-fns.js");
} catch (err) {
const msg = (err && err.message) ? err.message : String(err);
console.error("❌ Failed to apply the function.");
console.error(" Error:", msg.slice(0, 500));
if (/ENOTFOUND|EAI_AGAIN|getaddrinfo|network is unreachable|ECONNREFUSED|timeout/i.test(msg)) {
console.log("");
console.log("💡 Your current environment cannot reach the database on port 5432.");
console.log(" This is common inside some containers/CI sandboxes.");
console.log("");
console.log(" Copy and run ONE of the following on a *normal* laptop/terminal that has");
console.log(" outbound access to Supabase Postgres (most developer machines can reach it):");
console.log("");
console.log(" Option A (easiest, uses exactly the same code as this script):");
console.log(" npm run migrate:one 202");
console.log("");
console.log(" Option B (psql):");
console.log(" # Build the URL using the key from your .env.local (same as this script does):");
console.log(" # psql \"postgresql://postgres:PUT_YOUR_SERVICE_ROLE_KEY_HERE@db." + projectRef + ".supabase.co:5432/postgres\" \\");
console.log(" # -f supabase/migrations/202_fix_admin_create_stop.sql");
console.log("");
console.log(" Option C (Supabase CLI):");
console.log(" supabase db push --db-url \"postgresql://postgres:YOUR_SERVICE_ROLE_KEY@db." + projectRef + ".supabase.co:5432/postgres\" \\");
console.log(" supabase/migrations/202_fix_admin_create_stop.sql");
console.log("");
console.log(" After the command succeeds you will see the NOTIFY and the function will exist.");
console.log(" Then restart `npm run dev` and \"Add New Stop\" will stop showing the function-not-found error.");
}
process.exit(1);
} finally {
try { await client.end(); } catch {}
}
}
main().catch((e) => {
console.error("Unexpected crash:", e);
process.exit(1);
});
+49
View File
@@ -0,0 +1,49 @@
require("dotenv").config({ path: require("path").resolve(__dirname, "../.env.local") });
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
const headers = {
apikey: serviceKey,
Authorization: `Bearer ${serviceKey}`,
"Content-Type": "application/json",
};
async function rpc(name, body = {}) {
const r = await fetch(`${url}/rest/v1/rpc/${name}`, {
method: "POST", headers, body: JSON.stringify(body),
});
return { status: r.status, text: (await r.text()).slice(0, 500) };
}
(async () => {
// 1. Try calling admin_create_stop with service role
console.log("=== 1. Call admin_create_stop (service role) ===");
console.log(await rpc("admin_create_stop", { p_brand_id: null }));
// 2. Try with body
console.log("\n=== 2. Call admin_create_stop with stub body ===");
console.log(await rpc("admin_create_stop", {
p_brand_id: "00000000-0000-0000-0000-000000000000",
p_city: "test", p_state: "CO", p_location: "x", p_date: "2025-01-01", p_time: "08:00"
}));
// 3. Try with a name that obviously doesn't exist to see the error format
console.log("\n=== 3. Call a non-existent function (control) ===");
console.log(await rpc("definitely_does_not_exist_xyz", {}));
// 4. Use the PostgREST introspection — query information_schema via rpc
// PostgREST exposes pg_proc through /rest/v1/ for system tables is not allowed
// But we can use the OpenAPI spec endpoint
console.log("\n=== 4. OpenAPI spec — looking for admin_create_stop ===");
const openApi = await fetch(`${url}/rest/v1/`, { headers: { apikey: anonKey, Authorization: `Bearer ${anonKey}` } });
const text = await openApi.text();
const matches = text.match(/admin_create_stop[a-z_]*/g) || [];
console.log("Matches in OpenAPI:", matches);
// 5. Get all function names from OpenAPI
const funcMatches = [...text.matchAll(/"([a-z][a-z0-9_]*)\s*\{/g)].map(m => m[1]);
const stopFns = funcMatches.filter(n => n.includes("stop"));
console.log("\nStop-related functions in OpenAPI:", stopFns);
})();
+52
View File
@@ -0,0 +1,52 @@
require("dotenv").config({ path: require("path").resolve(__dirname, "../.env.local") });
const { Client } = require("pg");
const dns = require("dns");
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const projectRef = url.replace("https://", "").split(".")[0];
const candidates = [
`db.${projectRef}.supabase.co`,
`aws-0-us-east-1.pooler.supabase.com`,
`aws-0-us-west-1.pooler.supabase.com`,
];
(async () => {
for (const host of candidates) {
try {
const ip = await new Promise((resolve, reject) => {
dns.resolve4(host, (err, addrs) => err ? reject(err) : resolve(addrs));
});
console.log(`${host} -> ${ip.join(", ")}`);
} catch (e) {
console.log(`${host} -> DNS FAIL: ${e.code}`);
}
}
// Try direct DB with port 6543 (pooler) using supabase format
// Username pattern: postgres.<project-ref>
const client = new Client({
host: "aws-0-us-east-1.pooler.supabase.com",
port: 6543, database: "postgres",
user: `postgres.${projectRef}`,
password: process.env.SUPABASE_SERVICE_ROLE_KEY,
ssl: { rejectUnauthorized: false },
});
try {
await client.connect();
const r = await client.query(`
SELECT p.proname,
pg_get_function_identity_arguments(p.oid) AS id_args,
p.prosecdef AS secdef
FROM pg_proc p JOIN pg_namespace n ON p.pronamespace=n.oid
WHERE n.nspname='public' AND p.proname IN
('admin_create_stop','admin_create_stops_batch','delete_stop')
ORDER BY p.proname;
`);
console.log("\nFUNCTIONS IN DB:");
console.log(JSON.stringify(r.rows, null, 2));
} catch (e) {
console.error("POOLER ERR:", e.message);
} finally {
try { await client.end(); } catch {}
}
})();
+44
View File
@@ -0,0 +1,44 @@
require("dotenv").config({ path: require("path").resolve(__dirname, "../.env.local") });
const { Client } = require("pg");
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const projectRef = url.replace("https://", "").split(".")[0];
const pw = process.env.SUPABASE_SERVICE_ROLE_KEY;
const tries = [
{ host: "aws-0-us-east-1.pooler.supabase.com", port: 6543, user: `postgres.${projectRef}`, database: "postgres" },
{ host: "aws-0-us-east-1.pooler.supabase.com", port: 5432, user: `postgres.${projectRef}`, database: "postgres" },
{ host: "aws-0-us-east-1.pooler.supabase.com", port: 6543, user: "postgres", database: "postgres" },
];
(async () => {
for (const cfg of tries) {
const client = new Client({ ...cfg, password: pw, ssl: { rejectUnauthorized: false } });
try {
await client.connect();
const r = await client.query(`
SELECT p.proname,
pg_get_function_identity_arguments(p.oid) AS id_args,
p.prosecdef AS secdef
FROM pg_proc p JOIN pg_namespace n ON p.pronamespace=n.oid
WHERE n.nspname='public' AND p.proname IN
('admin_create_stop','admin_create_stops_batch','delete_stop')
ORDER BY p.proname;
`);
console.log(`\n${cfg.host}:${cfg.port} user=${cfg.user}`);
console.log(JSON.stringify(r.rows, null, 2));
// Also check if migration tracking table exists
const trk = await client.query(`
SELECT version, name FROM supabase_migrations.schema_migrations
WHERE version >= 145 ORDER BY version;
`).catch(() => ({ rows: [] }));
console.log("Recent migrations applied:", trk.rows);
await client.end();
return;
} catch (e) {
console.log(`${cfg.host}:${cfg.port} user=${cfg.user}${e.message}`);
try { await client.end(); } catch {}
}
}
})();
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env node
/**
* verify-stop-fns.js
*
* Confirms `admin_create_stop` and `admin_create_stops_batch` exist in the
* live database with the exact signatures the client uses, and that the
* PostgREST schema cache has them registered.
*
* Run: node scripts/verify-stop-fns.js
*
* Replaces the older `check-stop-fns*.js` scripts (left in the repo for
* history; this one is the canonical post-fix verification).
*
* Exits 0 on success, 1 if any check fails.
*/
require("dotenv").config({ path: require("path").resolve(__dirname, "../.env.local") });
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!url || !serviceKey || !anonKey) {
console.error("Missing NEXT_PUBLIC_SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY / NEXT_PUBLIC_SUPABASE_ANON_KEY in .env.local");
process.exit(1);
}
const SERVICE_HEADERS = {
apikey: serviceKey,
Authorization: `Bearer ${serviceKey}`,
"Content-Type": "application/json",
};
const ANON_HEADERS = {
apikey: anonKey,
Authorization: `Bearer ${anonKey}`,
"Content-Type": "application/json",
};
let failed = 0;
const fail = (msg) => { console.error("✗ " + msg); failed += 1; };
const pass = (msg) => console.log("✓ " + msg);
async function rpc(name, body, headers = SERVICE_HEADERS) {
const r = await fetch(`${url}/rest/v1/rpc/${name}`, {
method: "POST",
headers,
body: JSON.stringify(body),
});
const text = await r.text();
let parsed;
try { parsed = JSON.parse(text); } catch { parsed = text.slice(0, 200); }
return { status: r.status, body: parsed };
}
(async () => {
console.log(`\nVerifying stop RPCs against ${url}\n`);
// ── 1. admin_create_stop — empty body (expect 400/422, NOT PGRST202) ─────
// PGRST202 = function not in schema cache. We want a 400-style validation
// error here, which proves PostgREST knows the function exists.
console.log("1. admin_create_stop signature check (PostgREST cache)");
const probe1 = await rpc("admin_create_stop", {});
if (probe1.status === 404) {
fail("admin_create_stop returned 404 — function not found (PGRST202 cache miss). " +
"Did you apply migration 147 and run `NOTIFY pgrst, 'reload schema'`?");
} else if (probe1.status >= 500) {
fail(`admin_create_stop returned ${probe1.status} — server error. Body: ${JSON.stringify(probe1.body)}`);
} else {
pass(`admin_create_stop reachable in PostgREST (status ${probe1.status})`);
}
// ── 2. admin_create_stop — happy path with a real (but harmless) brand ──
console.log("\n2. admin_create_stop happy-path call (anon key)");
const probe2 = await rpc(
"admin_create_stop",
{
p_brand_id: "00000000-0000-0000-0000-000000000000",
p_city: "verify-script-city",
p_state: "CO",
p_location: "verification stop",
p_date: "2099-12-31",
p_time: "08:00 AM 02:00 PM",
p_address: "123 Verify St",
p_zip: "80000",
p_cutoff_time: "08:00", // time-only — should be combined with date
p_active: false,
},
ANON_HEADERS
);
if (probe2.status === 404) {
fail("admin_create_stop not callable via anon key. " +
"GRANT EXECUTE missing? Body: " + JSON.stringify(probe2.body));
} else if (probe2.status >= 400) {
// Expected: brand_id is the zero UUID (foreign key violation). That's fine —
// it proves the function executed past type validation.
const msg = JSON.stringify(probe2.body);
if (/foreign key|violates/i.test(msg)) {
pass(`admin_create_stop ran end-to-end (got expected FK violation: ${msg.slice(0, 120)}...)`);
} else {
fail(`admin_create_stop failed unexpectedly (${probe2.status}): ${msg}`);
}
} else {
pass(`admin_create_stop returned success: ${JSON.stringify(probe2.body)}`);
}
// ── 3. admin_create_stops_batch — same checks ───────────────────────────
console.log("\n3. admin_create_stops_batch signature check");
const probe3 = await rpc("admin_create_stops_batch", { p_brand_id: null, p_stops: [] });
if (probe3.status === 404) {
fail("admin_create_stops_batch returned 404 — function not in schema cache.");
} else {
pass(`admin_create_stops_batch reachable (status ${probe3.status})`);
}
// ── 4. OpenAPI introspection — confirm functions are exposed ────────────
console.log("\n4. OpenAPI introspection");
const openApi = await fetch(`${url}/rest/v1/`, { headers: ANON_HEADERS });
const text = await openApi.text();
const hasSingle = /admin_create_stop\b/.test(text);
const hasBatch = /admin_create_stops_batch\b/.test(text);
if (hasSingle) pass("admin_create_stop appears in OpenAPI spec");
else fail("admin_create_stop NOT in OpenAPI spec — PostgREST hasn't seen it yet");
if (hasBatch) pass("admin_create_stops_batch appears in OpenAPI spec");
else fail("admin_create_stops_batch NOT in OpenAPI spec");
console.log(`\n${failed === 0 ? "✓ All checks passed" : `${failed} check(s) failed`}\n`);
process.exit(failed === 0 ? 0 : 1);
})().catch((e) => {
console.error("verify-stop-fns.js crashed:", e);
process.exit(1);
});
+21 -19
View File
@@ -14,30 +14,32 @@ if (typeof window !== "undefined") {
export default function LandingPageClient() {
const mainRef = useRef<HTMLDivElement>(null);
// Global scroll animations
// Global scroll animations (guarded to prevent missing-target warnings when no .scroll-reveal elements)
useEffect(() => {
if (typeof window === "undefined" || !mainRef.current) return;
const ctx = gsap.context(() => {
// Reveal animations for sections
// Reveal animations for sections - only if targets exist (prevents GSAP "missing targets" warnings)
const revealElements = gsap.utils.toArray<Element>(".scroll-reveal");
revealElements.forEach((el) => {
gsap.fromTo(
el,
{ opacity: 0, y: 60 },
{
opacity: 1,
y: 0,
duration: 1,
ease: "power3.out",
scrollTrigger: {
trigger: el,
start: "top 85%",
toggleActions: "play none none reverse",
},
}
);
});
if (revealElements.length > 0) {
revealElements.forEach((el) => {
gsap.fromTo(
el,
{ opacity: 0, y: 60 },
{
opacity: 1,
y: 0,
duration: 1,
ease: "power3.out",
scrollTrigger: {
trigger: el,
start: "top 85%",
toggleActions: "play none none reverse",
},
}
);
});
}
}, mainRef);
return () => ctx.revert();
+1 -1
View File
@@ -11,7 +11,7 @@ export default function BrandsPage() {
if (typeof window === "undefined") return;
const ctx = gsap.context(() => {
// Header animation
// Header animation (selectors scoped to containerRef)
gsap.from(".header-content", {
y: -30,
opacity: 0,
+12 -3
View File
@@ -442,6 +442,9 @@ export default function TuxedoPage() {
const [brandBgColor, setBrandBgColor] = useState<string | null>(null);
const [brandTextColor, setBrandTextColor] = useState<string | null>(null);
// Scope ref for GSAP context (fixes "invalid scope"/missing targets by providing explicit scope; selectors now limited to page)
const pageScopeRef = useRef<HTMLDivElement>(null);
useEffect(() => {
async function load() {
const slug = "tuxedo";
@@ -504,6 +507,7 @@ export default function TuxedoPage() {
}
// ─── GSAP SCROLL ANIMATIONS ──────────────────────────────────────────────
// Now explicitly scoped + resilient guards (no more "invalid scope" warnings)
useEffect(() => {
if (typeof window === "undefined") return;
@@ -564,10 +568,15 @@ export default function TuxedoPage() {
);
});
// Counter animations
// Counter animations (uses reliable proxy like before; now also has reduced-motion guard for resilience)
const prefersReduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
const counterElements = gsap.utils.toArray<Element>(".counter-animate");
counterElements.forEach((el) => {
const target = parseInt(el.getAttribute("data-target") || "0", 10);
if (prefersReduced) {
el.textContent = target.toLocaleString();
return;
}
const obj = { val: 0 };
gsap.to(obj, {
val: target,
@@ -604,7 +613,7 @@ export default function TuxedoPage() {
}
);
});
});
}, pageScopeRef);
return () => ctx.revert();
}, []);
@@ -612,7 +621,7 @@ export default function TuxedoPage() {
const featuredProducts = products.slice(0, 3);
return (
<div className="min-h-screen bg-stone-50">
<div ref={pageScopeRef} className="min-h-screen bg-stone-50">
<BrandStylesProvider
primaryColor={brandPrimaryColor}
bgColor={brandBgColor}
+59 -73
View File
@@ -26,9 +26,26 @@ export default function HeroSection() {
}, []);
// ─── GSAP SCROLL ANIMATIONS ────────────────────────────────────────────────
// Resilient: respects reduced-motion, safe GSAP target checks, reliable counter impl, proper scope
useEffect(() => {
if (!mounted || !heroRef.current || !contentRef.current) return;
const prefersReducedMotion =
typeof window !== "undefined" &&
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true;
// For reduced motion: show final states immediately (no scroll-driven anims to avoid jank/warnings)
if (prefersReducedMotion) {
const counterEls = document.querySelectorAll<Element>(".counter-animate");
counterEls.forEach((el) => {
const targetStr = el.getAttribute("data-target") || "0";
const target = parseInt(targetStr, 10);
el.textContent = target.toLocaleString();
});
// Still allow basic scroll progress bar if wanted, but skip all ScrollTrigger/GSAP heavy
return;
}
const ctx = gsap.context(() => {
// Scroll progress tracking
ScrollTrigger.create({
@@ -158,33 +175,52 @@ export default function HeroSection() {
);
});
// Counter animations with proper initialization
// Counter animations - fixed: use reliable proxy object (avoids textContent tween pitfalls that left counters at 0)
// + always scoped + fallback path (though reduced handled above) + !gsap guard per requirements
const counterElements = gsap.utils.toArray<Element>(".counter-animate");
const hasGSAP = typeof gsap !== "undefined" && typeof gsap.to === "function";
counterElements.forEach((el) => {
const targetStr = el.getAttribute("data-target") || "0";
const target = parseInt(targetStr, 10);
el.textContent = "0"; // ensure start
gsap.fromTo(
el,
{ textContent: 0 },
{
textContent: target,
if (hasGSAP && gsap) {
const obj = { val: 0 };
gsap.to(obj, {
val: target,
duration: 2.5,
ease: "power2.out",
snap: { textContent: 1 },
scrollTrigger: {
trigger: el,
start: "top 85%",
toggleActions: "play none none none",
},
onUpdate: function() {
const val = Math.floor(gsap.getProperty(el, "textContent") as number);
onUpdate: () => {
if (el.textContent !== null) {
el.textContent = val.toLocaleString();
el.textContent = Math.floor(obj.val).toLocaleString();
}
},
}
);
});
} else {
// pure fallback using IO + rAF (IntersectionObserver + requestAnimationFrame) if no GSAP
const io = new IntersectionObserver((entries) => {
if (entries[0]?.isIntersecting) {
io.disconnect();
const startTime = Date.now();
const dur = 2000;
const tick = () => {
const p = Math.min((Date.now() - startTime) / dur, 1);
const eased = 1 - Math.pow(1 - p, 3);
const val = Math.floor(target * eased);
el.textContent = val.toLocaleString();
if (p < 1) requestAnimationFrame(tick);
else el.textContent = target.toLocaleString();
};
tick();
}
}, { threshold: 0.3 });
io.observe(el);
}
});
}, containerRef);
@@ -213,6 +249,8 @@ export default function HeroSection() {
</div>
{/* ─── HERO SECTION - SCROLL-DRIVEN PIN ───────────────────────────── */}
{/* containerRef wraps animated sections for proper GSAP context scope (fixes "Invalid scope" + missing targets) */}
<div ref={containerRef}>
<section
ref={heroRef}
className="hero-section relative min-h-screen flex items-center overflow-hidden"
@@ -558,7 +596,8 @@ export default function HeroSection() {
{/* ─── STORY SECTION - SCROLL-DRIVEN REVEAL ──────────────────────────── */}
<section id="story" className="relative" style={{ background: "#1a1a1a" }}>
<div className="relative" style={{ minHeight: "300vh" }}>
{/* Reduced from 300vh to avoid "long blank scroll regions" complaint while keeping cinematic sticky scroll effect */}
<div className="relative" style={{ minHeight: "140vh" }}>
{/* Sticky Container */}
<div
className="sticky top-0 h-screen flex items-center justify-center overflow-hidden"
@@ -611,6 +650,7 @@ export default function HeroSection() {
{/* ─── FEATURES SECTION - PROGRESSIVE REVEAL ───────────────────────────── */}
<section
id="features"
className="relative py-32 overflow-hidden"
style={{ background: "linear-gradient(180deg, #faf8f5 0%, #fef7f0 100%)" }}
>
@@ -788,6 +828,7 @@ export default function HeroSection() {
{/* ─── STATS SECTION - SCROLL-TRIGGERED COUNTERS ──────────────────────── */}
<section
id="stats"
className="relative py-32 overflow-hidden"
style={{ background: "#1a4d2e" }}
>
@@ -844,7 +885,9 @@ export default function HeroSection() {
</section>
{/* ─── CTA SECTION - FINAL REVEAL ─────────────────────────────────────── */}
{/* id="reviews" provides anchor for nav "Reviews" link (matches nav expectations; section contains final messaging) */}
<section
id="reviews"
className="relative py-40 overflow-hidden"
style={{ background: "linear-gradient(180deg, #faf8f5 0%, #f5f2ed 100%)" }}
>
@@ -936,67 +979,10 @@ export default function HeroSection() {
</div>
</div>
</section>
</div>
{/* /containerRef for GSAP scope */}
{/* ─── FOOTER ─────────────────────────────────────────────────────────── */}
<footer className="relative py-16 overflow-hidden" style={{ background: "#1a1a1a" }}>
<div className="absolute inset-0 pointer-events-none" style={{
background: "linear-gradient(180deg, #faf8f5 0%, #1a1a1a 100%)",
}} />
<div className="container mx-auto px-8 relative z-10">
<div className="flex flex-col md:flex-row items-center justify-between gap-8 pb-12 border-b border-white/10">
{/* Logo */}
<div className="flex items-center gap-3">
<div
className="w-10 h-10 rounded-full flex items-center justify-center"
style={{ background: "#1a4d2e" }}
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
<path d="M13 2L4.5 13.5H11.5L10.5 22L19 10.5H12L13 2Z" fill="#faf8f5" />
</svg>
</div>
<span
className="text-xl font-semibold"
style={{
fontFamily: "'Playfair Display', serif",
color: "#faf8f5",
}}
>
Route Commerce
</span>
</div>
{/* Legal Links */}
<nav className="flex flex-wrap items-center justify-center gap-8">
{[
{ label: "Privacy Policy", href: "/privacy-policy" },
{ label: "Terms of Service", href: "/terms-and-conditions" },
{ label: "Security", href: "/security" },
{ label: "Contact", href: "/contact" },
].map((link) => (
<a
key={link.label}
href={link.href}
className="text-sm transition-colors hover:text-white"
style={{ color: "rgba(255,255,255,0.5)" }}
>
{link.label}
</a>
))}
</nav>
</div>
{/* Copyright */}
<div className="pt-8 text-center">
<p className="text-sm" style={{ color: "rgba(255,255,255,0.4)" }}>
© {new Date().getFullYear()} Route Commerce. All rights reserved.
</p>
<p className="text-xs mt-2" style={{ color: "rgba(255,255,255,0.3)" }}>
Fresh produce wholesale platform for farms, Co-ops, and distributors.
</p>
</div>
</div>
</footer>
{/* (duplicate inline footer removed -- LandingPageWrapper <Footer /> now provides the only footer; eliminates repeated content + trims scroll height) */}
{/* ─── GLOBAL STYLES ────────────────────────────────────────────────────── */}
<style jsx>{`
@@ -15,7 +15,7 @@ export function Header({ className = "" }: HeaderProps) {
const navLinks = [
{ label: "Features", href: "#features" },
{ label: "Stats", href: "#stats" },
{ label: "Reviews", href: "#reviews" },
{ label: "Reviews", href: "#reviews" }, // anchors added to HeroSection sections (fixes missing id issue)
];
return (