- Migrate login page to atelier design system (editorial modal style) - Polish root error.tsx, not-found.tsx, loading.tsx with atelier design - Add JSON-LD structured data: SoftwareApplication + LocalBusiness - Fix next.config.ts outputFileTracingRoot absolute path warning - Add force-dynamic to protected-example (uses cookies) - Add proper type for stops page (replace : any) - Fix unescaped quotes in StopTableClient - Fix no-explicit-any in db-schema route - Clean up console.logs in admin-permissions - Fix inline any in admin/stops page - Update OG image references to existing og-default.svg
This commit is contained in:
+3
-2
@@ -12,8 +12,9 @@ const nextConfig: NextConfig = {
|
||||
// /home/tyler/package-lock.json as the root directory."
|
||||
// The deploy runner's APP_DIR is /home/tyler/route-commerce, so
|
||||
// resolving relative to the project root is correct both locally and
|
||||
// in CI.
|
||||
outputFileTracingRoot: ".",
|
||||
// in CI. We resolve to an absolute path to avoid the warning in
|
||||
// Next.js 16 which prefers absolute paths here.
|
||||
outputFileTracingRoot: __dirname,
|
||||
|
||||
// Enable strict mode
|
||||
reactStrictMode: true,
|
||||
|
||||
@@ -23,7 +23,21 @@ export default async function AdminStopsPage({ searchParams }: PageProps) {
|
||||
if (!adminUser) return <AdminAccessDenied />;
|
||||
if (!adminUser.can_manage_stops) redirect("/admin/pickup");
|
||||
|
||||
let stops: unknown[] = [];
|
||||
interface DbStopRow {
|
||||
id: string;
|
||||
city: string;
|
||||
state: string;
|
||||
date: Date | string;
|
||||
time: string | null;
|
||||
location: string;
|
||||
status: string;
|
||||
brand_id: string;
|
||||
address: string | null;
|
||||
zip: string | null;
|
||||
cutoff_date: string | null;
|
||||
brand_name?: string;
|
||||
}
|
||||
let stops: DbStopRow[] = [];
|
||||
let error: string | null = null;
|
||||
|
||||
// Resolve active brand from cookie/URL (respects platform_admin "All brands" = null)
|
||||
@@ -83,7 +97,7 @@ export default async function AdminStopsPage({ searchParams }: PageProps) {
|
||||
}
|
||||
|
||||
// Transform stops for the client component
|
||||
const stopsForClient = stops.map((s: any) => ({
|
||||
const stopsForClient = stops.map((s) => ({
|
||||
id: s.id,
|
||||
city: s.city,
|
||||
state: s.state,
|
||||
|
||||
@@ -19,11 +19,12 @@ export async function GET() {
|
||||
{ status: "ok", message: "admin_users table present" },
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "Database schema check failed";
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: "error",
|
||||
message: err?.message ?? "Database schema check failed",
|
||||
message,
|
||||
hint: "Run migrations against the DATABASE_URL (see docs/superpowers/plans/2026-06-prod-db-schema-migration-reliability.md)",
|
||||
},
|
||||
{ status: 503 }
|
||||
|
||||
+76
-33
@@ -1,7 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Root error boundary — the safety net for any uncaught error in the
|
||||
* app. Renders inside the root layout, so it inherits the same font
|
||||
* variables and design tokens as everything else.
|
||||
*/
|
||||
export default function ErrorPage({
|
||||
error,
|
||||
reset,
|
||||
@@ -9,44 +15,81 @@ export default function ErrorPage({
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
// Surface to the console for development; the Sentry-ready logger
|
||||
// in src/lib/sentry.ts can be wired in here when keys are configured.
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("[app/error]", error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center px-6 relative">
|
||||
{/* Background */}
|
||||
<div className="fixed inset-0 bg-gradient-to-br from-zinc-950 via-zinc-900 to-zinc-950" />
|
||||
<div className="fixed inset-0 pointer-events-none">
|
||||
<div className="absolute top-1/3 left-1/3 w-96 h-96 bg-red-500/5 rounded-full blur-3xl" />
|
||||
<main className="atelier-canvas relative min-h-screen flex items-center justify-center px-6 py-16 overflow-hidden">
|
||||
<div className="atelier-grain" aria-hidden="true" />
|
||||
|
||||
{/* Ambient orbs */}
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true">
|
||||
<div
|
||||
className="absolute top-1/4 left-1/4 w-96 h-96 rounded-full opacity-30"
|
||||
style={{ background: "radial-gradient(circle, rgba(185, 28, 28, 0.12) 0%, transparent 70%)", filter: "blur(48px)" }}
|
||||
/>
|
||||
<div
|
||||
className="absolute bottom-1/4 right-1/4 w-96 h-96 rounded-full opacity-20"
|
||||
style={{ background: "radial-gradient(circle, rgba(202, 138, 4, 0.16) 0%, transparent 70%)", filter: "blur(48px)" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-center max-w-md mx-auto relative">
|
||||
<div className="glass-card p-10">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-red-500/10 border border-red-500/20 mb-6">
|
||||
<svg className="w-8 h-8 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<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" />
|
||||
<div className="relative z-10 max-w-md w-full text-center atelier-enter">
|
||||
<div className="atelier-section-num justify-center mb-4">No. 500</div>
|
||||
|
||||
<h1 className="atelier-title text-5xl sm:text-6xl">
|
||||
Something <span className="atelier-italic text-[#786B53]">broke</span>
|
||||
</h1>
|
||||
<p className="mt-4 text-stone-600 text-sm leading-relaxed">
|
||||
We hit an unexpected snag loading this page. Our harvest crew has been notified.
|
||||
</p>
|
||||
|
||||
{error.message && (
|
||||
<details className="mt-6 text-left">
|
||||
<summary
|
||||
className="text-[10px] tracking-[0.18em] uppercase text-stone-500 hover:text-stone-700 cursor-pointer transition-colors"
|
||||
style={{ fontFamily: "var(--font-fragment-mono)" }}
|
||||
>
|
||||
Error details
|
||||
</summary>
|
||||
<div className="mt-3 rounded-lg border border-stone-200 bg-white/70 backdrop-blur-sm p-3.5 text-xs font-mono text-stone-700 break-words">
|
||||
{error.message}
|
||||
{error.digest && (
|
||||
<div className="mt-2 pt-2 border-t border-stone-200 text-stone-500">
|
||||
digest: <span className="text-stone-700">{error.digest}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<div className="atelier-rule my-8" />
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
className="atelier-cta"
|
||||
aria-label="Retry loading this page"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-3xl font-semibold text-white tracking-tight">Something went wrong</h1>
|
||||
<p className="mt-3 text-zinc-400 text-sm">
|
||||
{error.message || "An unexpected error occurred."}
|
||||
</p>
|
||||
{error.digest && (
|
||||
<p className="mt-2 text-xs text-zinc-600 font-mono">Digest: {error.digest}</p>
|
||||
)}
|
||||
<div className="mt-8 flex items-center justify-center gap-4">
|
||||
<button
|
||||
onClick={reset}
|
||||
className="rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 px-5 py-2.5 text-sm font-medium text-white transition-all backdrop-blur-sm"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
<Link
|
||||
href="/"
|
||||
className="rounded-xl bg-gradient-to-r from-emerald-500 to-emerald-400 hover:from-emerald-400 hover:to-emerald-300 px-5 py-2.5 text-sm font-semibold text-white transition-all shadow-lg shadow-emerald-500/20"
|
||||
>
|
||||
Go home
|
||||
</Link>
|
||||
</div>
|
||||
<span className="relative z-10">Try again</span>
|
||||
<span className="atelier-cta-shimmer" aria-hidden="true" />
|
||||
</button>
|
||||
<Link
|
||||
href="/"
|
||||
className="atelier-discard text-center"
|
||||
>
|
||||
← Return home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,22 +2,33 @@ import type { Metadata, Viewport } from "next";
|
||||
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com";
|
||||
|
||||
// BreadcrumbList schema for SEO
|
||||
const indianRiverBreadcrumbSchema = {
|
||||
// Structured data for the Indian River Direct storefront.
|
||||
// LocalBusiness + BreadcrumbList for the family farm brand.
|
||||
const indianRiverStructuredData = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
"itemListElement": [
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 1,
|
||||
"name": "Home",
|
||||
"item": BASE_URL,
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: [
|
||||
{ "@type": "ListItem", position: 1, name: "Home", item: BASE_URL },
|
||||
{ "@type": "ListItem", position: 2, name: "Indian River Direct", item: `${BASE_URL}/indian-river-direct` },
|
||||
],
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 2,
|
||||
"name": "Indian River Direct",
|
||||
"item": `${BASE_URL}/indian-river-direct`,
|
||||
"@type": "LocalBusiness",
|
||||
"@id": `${BASE_URL}/indian-river-direct/#localbusiness`,
|
||||
name: "Indian River Direct",
|
||||
description:
|
||||
"Fresh peaches and citrus from our Florida groves to truckload sales in your neighborhood. Family-owned since 1985.",
|
||||
url: `${BASE_URL}/indian-river-direct`,
|
||||
image: `${BASE_URL}/og-default.svg`,
|
||||
priceRange: "$$",
|
||||
address: {
|
||||
"@type": "PostalAddress",
|
||||
addressRegion: "FL",
|
||||
addressCountry: "US",
|
||||
},
|
||||
foundingDate: "1985",
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -41,7 +52,7 @@ export const metadata: Metadata = {
|
||||
type: "website",
|
||||
images: [
|
||||
{
|
||||
url: "/og-indian-river.jpg",
|
||||
url: "/og-default.svg",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "Indian River Direct - Fresh Peaches & Citrus",
|
||||
@@ -54,7 +65,7 @@ export const metadata: Metadata = {
|
||||
description: "Fresh peaches and citrus from our Florida groves. Family-owned since 1985. Pre-order for 2026 season.",
|
||||
site: "@IndianRiverDirect",
|
||||
creator: "@IndianRiverDirect",
|
||||
images: ["/og-indian-river.jpg"],
|
||||
images: ["/og-default.svg"],
|
||||
},
|
||||
alternates: {
|
||||
canonical: `${BASE_URL}/indian-river-direct`,
|
||||
@@ -68,7 +79,7 @@ export const metadata: Metadata = {
|
||||
},
|
||||
},
|
||||
other: {
|
||||
"application/ld+json": JSON.stringify(indianRiverBreadcrumbSchema),
|
||||
"application/ld+json": JSON.stringify(indianRiverStructuredData),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
+70
-33
@@ -1,8 +1,8 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Loading Route Commerce...",
|
||||
description: "Loading...",
|
||||
title: "Loading — Route Commerce",
|
||||
description: "Loading content from Route Commerce",
|
||||
robots: {
|
||||
index: false,
|
||||
follow: false,
|
||||
@@ -11,43 +11,80 @@ export const metadata: Metadata = {
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center relative overflow-hidden" style={{ backgroundColor: "#faf8f5" }}>
|
||||
{/* Subtle loading animation */}
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<main
|
||||
className="atelier-canvas relative min-h-screen flex items-center justify-center overflow-hidden"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label="Loading content"
|
||||
>
|
||||
<div className="atelier-grain" aria-hidden="true" />
|
||||
|
||||
{/* Subtle ambient glow */}
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true">
|
||||
<div
|
||||
className="w-16 h-16 rounded-2xl flex items-center justify-center animate-pulse"
|
||||
style={{
|
||||
background: "linear-gradient(135deg, #1a4d2e 0%, #166534 100%)",
|
||||
boxShadow: "0 8px 32px rgba(26, 77, 46, 0.3)"
|
||||
}}
|
||||
aria-label="Loading"
|
||||
role="status"
|
||||
>
|
||||
<svg className="w-8 h-8 text-white animate-pulse" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13 2L4.5 13.5H11.5L10.5 22L19 10.5H12L13 2Z" />
|
||||
</svg>
|
||||
className="absolute -top-32 -right-32 w-[28rem] h-[28rem] rounded-full opacity-25"
|
||||
style={{ background: "radial-gradient(circle at 30% 30%, rgba(34, 78, 47, 0.18) 0%, transparent 70%)", filter: "blur(48px)" }}
|
||||
/>
|
||||
<div
|
||||
className="absolute -bottom-48 -left-48 w-[36rem] h-[36rem] rounded-full opacity-20"
|
||||
style={{ background: "radial-gradient(circle at 70% 70%, rgba(202, 138, 4, 0.16) 0%, transparent 70%)", filter: "blur(60px)" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 flex flex-col items-center gap-7 atelier-enter">
|
||||
<div className="atelier-section-num">Loading</div>
|
||||
|
||||
{/* Animated editorial mark */}
|
||||
<div className="relative">
|
||||
<div
|
||||
className="w-16 h-16 rounded-2xl flex items-center justify-center"
|
||||
style={{
|
||||
background: "linear-gradient(135deg, #14532D 0%, #1F6B3E 50%, #14532D 100%)",
|
||||
boxShadow: "0 10px 28px -6px rgba(20, 83, 45, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.12)",
|
||||
animation: "atelier-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite",
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg className="w-8 h-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13 2L4.5 13.5H11.5L10.5 22L19 10.5H12L13 2Z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-lg font-semibold text-stone-700" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}>
|
||||
Loading
|
||||
<p
|
||||
className="atelier-title text-2xl"
|
||||
>
|
||||
Preparing your <span className="atelier-italic text-[#786B53]">harvest</span>
|
||||
</p>
|
||||
<p className="text-sm text-stone-500 mt-1">Please wait...</p>
|
||||
<p className="text-sm text-stone-500 mt-1.5">Just a moment…</p>
|
||||
</div>
|
||||
|
||||
{/* Progress dots */}
|
||||
<div className="flex items-center gap-1.5" aria-hidden="true">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="w-1.5 h-1.5 rounded-full bg-[#14532D]"
|
||||
style={{
|
||||
animation: "atelier-dot 1.4s ease-in-out infinite",
|
||||
animationDelay: `${i * 0.16}s`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Decorative elements */}
|
||||
<div className="absolute inset-0 pointer-events-none overflow-hidden">
|
||||
<div
|
||||
className="absolute -top-32 -right-32 w-96 h-96 rounded-full opacity-10"
|
||||
style={{ background: "radial-gradient(circle at 30% 30%, #c97a3e20 0%, transparent 70%)", filter: "blur(40px)" }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
className="absolute -bottom-48 -left-48 w-[600px] h-[600px] rounded-full opacity-10"
|
||||
style={{ background: "radial-gradient(circle at 70% 70%, #6b8f7130 0%, transparent 70%)", filter: "blur(60px)" }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes atelier-pulse {
|
||||
0%, 100% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.05); opacity: 0.85; }
|
||||
}
|
||||
@keyframes atelier-dot {
|
||||
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
|
||||
40% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
`}</style>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+117
-98
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useId } from "react";
|
||||
|
||||
type LoginClientProps = {
|
||||
error: string | null;
|
||||
@@ -12,6 +12,8 @@ const REDIRECT_URL = "/admin";
|
||||
const isDev = process.env.NODE_ENV !== "production";
|
||||
|
||||
export default function LoginClient({ error }: LoginClientProps) {
|
||||
const emailId = useId();
|
||||
const passwordId = useId();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -54,154 +56,171 @@ export default function LoginClient({ error }: LoginClientProps) {
|
||||
window.location.href = REDIRECT_URL;
|
||||
}
|
||||
|
||||
return (
|
||||
<main
|
||||
className="min-h-screen flex flex-col relative overflow-hidden"
|
||||
style={{ backgroundColor: "#faf8f5", height: "100vh" }}
|
||||
>
|
||||
<style jsx global>{`
|
||||
@import url("https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap");
|
||||
html, body { overflow: hidden; }
|
||||
`}</style>
|
||||
const displayError = error ?? localError;
|
||||
|
||||
return (
|
||||
<main className="atelier-canvas relative min-h-screen flex flex-col overflow-hidden">
|
||||
{/* Decorative grain layer */}
|
||||
<div className="atelier-grain" aria-hidden="true" />
|
||||
|
||||
{/* Ambient background flourishes */}
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true">
|
||||
<div className="absolute -top-32 -right-32 w-96 h-96 rounded-full opacity-20" style={{ background: "radial-gradient(circle at 30% 30%, #c97a3e20 0%, transparent 70%)", filter: "blur(40px)" }} />
|
||||
<div className="absolute -bottom-48 -left-48 w-[600px] h-[600px] rounded-full opacity-15" style={{ background: "radial-gradient(circle at 70% 70%, #6b8f7130 0%, transparent 70%)", filter: "blur(60px)" }} />
|
||||
<div className="absolute top-1/3 left-1/4 w-72 h-72 rounded-full opacity-10" style={{ background: "radial-gradient(circle, #1a4d2e15 0%, transparent 70%)", filter: "blur(30px)" }} />
|
||||
<div
|
||||
className="absolute -top-32 -right-32 w-[28rem] h-[28rem] rounded-full opacity-30"
|
||||
style={{ background: "radial-gradient(circle at 30% 30%, rgba(202, 138, 4, 0.18) 0%, transparent 70%)", filter: "blur(48px)" }}
|
||||
/>
|
||||
<div
|
||||
className="absolute -bottom-48 -left-48 w-[36rem] h-[36rem] rounded-full opacity-25"
|
||||
style={{ background: "radial-gradient(circle at 70% 70%, rgba(34, 78, 47, 0.16) 0%, transparent 70%)", filter: "blur(60px)" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex items-center justify-center px-6 py-12 relative z-10">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="relative bg-white/80 backdrop-blur-xl rounded-3xl shadow-xl ring-1 ring-black/5 overflow-hidden">
|
||||
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-transparent via-[#6b8f71]/30 to-transparent" />
|
||||
{/* Editorial corner flourish */}
|
||||
<div className="absolute top-6 left-6 atelier-flourish w-20 h-20 opacity-40" aria-hidden="true" />
|
||||
|
||||
<div className="p-8 sm:p-10">
|
||||
<div className="text-center mb-8">
|
||||
<div className="flex-1 flex items-center justify-center px-6 py-12 relative z-10">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Editorial numeral + section label */}
|
||||
<div className="text-center mb-6">
|
||||
<span className="atelier-section-num">No. 01</span>
|
||||
</div>
|
||||
|
||||
<div className="atelier-enter relative bg-white/90 backdrop-blur-xl rounded-2xl ring-1 ring-stone-200/80 shadow-[0_24px_64px_-24px_rgba(20,83,45,0.25)] overflow-hidden">
|
||||
{/* Top hairline rule */}
|
||||
<div className="atelier-rule" />
|
||||
|
||||
<div className="px-8 sm:px-10 py-10">
|
||||
{/* Brand mark */}
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
<div
|
||||
className="inline-flex h-16 w-16 items-center justify-center rounded-2xl mb-5"
|
||||
className="inline-flex h-14 w-14 items-center justify-center rounded-2xl mb-5"
|
||||
style={{
|
||||
background: "linear-gradient(135deg, #1a4d2e 0%, #2d6a45 100%)",
|
||||
boxShadow: "0 12px 32px rgba(26, 77, 46, 0.25)",
|
||||
background: "linear-gradient(135deg, #14532D 0%, #1F6B3E 50%, #14532D 100%)",
|
||||
boxShadow: "0 10px 28px -6px rgba(20, 83, 45, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.12)",
|
||||
}}
|
||||
>
|
||||
<svg className="h-8 w-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<svg className="h-7 w-7 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1
|
||||
className="text-3xl font-semibold text-stone-900"
|
||||
style={{ fontFamily: "'Cormorant Garamond', Georgia, serif", letterSpacing: "-0.02em" }}
|
||||
>
|
||||
Welcome back
|
||||
<h1 className="atelier-title text-3xl sm:text-4xl text-center">
|
||||
Welcome <span className="atelier-italic text-[#786B53]">back</span>
|
||||
</h1>
|
||||
<p
|
||||
className="mt-2 text-sm"
|
||||
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#7a7570" }}
|
||||
>
|
||||
Sign in to your account
|
||||
<p className="mt-2 text-sm text-stone-500 text-center">
|
||||
Sign in to your <em className="atelier-italic not-italic font-normal">atelier</em> account
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-stone-700 mb-1.5" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSignIn();
|
||||
}}
|
||||
className="space-y-5"
|
||||
noValidate
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor={emailId} className="atelier-section-label">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id={emailId}
|
||||
type="email"
|
||||
required
|
||||
autoComplete="username"
|
||||
className="w-full rounded-xl border border-stone-200/80 bg-white px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-[#6b8f71]/40 focus:border-[#6b8f71]"
|
||||
placeholder="you@example.com"
|
||||
className="atelier-input"
|
||||
placeholder="you@yourfarm.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={loading}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") handleSignIn(); }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-stone-700 mb-1.5" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}>
|
||||
Password
|
||||
</label>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label htmlFor={passwordId} className="atelier-section-label">
|
||||
Password
|
||||
</label>
|
||||
<a
|
||||
href="/forgot-password"
|
||||
className="text-[10px] tracking-[0.15em] uppercase text-stone-500 hover:text-[#14532D] transition-colors"
|
||||
style={{ fontFamily: "var(--font-fragment-mono)" }}
|
||||
>
|
||||
Forgot?
|
||||
</a>
|
||||
</div>
|
||||
<input
|
||||
id={passwordId}
|
||||
type="password"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
className="w-full rounded-xl border border-stone-200/80 bg-white px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-[#6b8f71]/40 focus:border-[#6b8f71]"
|
||||
className="atelier-input"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={loading}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") handleSignIn(); }}
|
||||
/>
|
||||
</div>
|
||||
{(error || localError) && (
|
||||
<p className="text-sm text-rose-700 bg-rose-50 border border-rose-200 rounded-lg px-3 py-2">
|
||||
{error ?? localError}
|
||||
</p>
|
||||
|
||||
{displayError && (
|
||||
<div
|
||||
role="alert"
|
||||
className="atelier-stagger rounded-lg border border-rose-200/80 bg-rose-50/80 px-3.5 py-2.5 text-sm text-rose-800 flex items-start gap-2"
|
||||
>
|
||||
<svg className="h-4 w-4 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
|
||||
<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>
|
||||
<span>{displayError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSignIn}
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-xl px-6 py-3.5 text-sm font-semibold text-white shadow-sm transition-all disabled:opacity-60 disabled:cursor-not-allowed active:scale-[0.98]"
|
||||
style={{
|
||||
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
|
||||
background: loading
|
||||
? "linear-gradient(135deg, #6b8f71 0%, #7ba085 100%)"
|
||||
: "linear-gradient(135deg, #1a4d2e 0%, #2d6a45 100%)",
|
||||
boxShadow: "0 8px 24px rgba(26, 77, 46, 0.20)",
|
||||
}}
|
||||
className="atelier-cta w-full"
|
||||
>
|
||||
{loading ? "Signing in…" : "Sign in with email"}
|
||||
<span className="relative z-10">{loading ? "Signing in…" : "Sign in"}</span>
|
||||
{!loading && (
|
||||
<svg className="h-4 w-4 relative z-10" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||||
</svg>
|
||||
)}
|
||||
<span className="atelier-cta-shimmer" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Development bypass buttons */}
|
||||
{isDev && (
|
||||
<div className="mt-6 pt-6 border-t border-stone-200">
|
||||
<p className="text-xs text-stone-500 text-center mb-3" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}>
|
||||
Dev Mode — Quick Access
|
||||
</p>
|
||||
<div className="mt-8 pt-6 border-t border-stone-200/80">
|
||||
<p className="atelier-section-label text-center mb-3">Dev Mode · Quick Access</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDevLogin("platform_admin")}
|
||||
className="rounded-lg px-3 py-2 text-xs font-medium text-white transition-all hover:opacity-90"
|
||||
style={{
|
||||
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
|
||||
background: "#1a4d2e",
|
||||
}}
|
||||
>
|
||||
Platform Admin
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDevLogin("brand_admin")}
|
||||
className="rounded-lg px-3 py-2 text-xs font-medium text-white transition-all hover:opacity-90"
|
||||
style={{
|
||||
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
|
||||
background: "#2d6a45",
|
||||
}}
|
||||
>
|
||||
Brand Admin
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDevLogin("store_employee")}
|
||||
className="rounded-lg px-3 py-2 text-xs font-medium text-white transition-all hover:opacity-90"
|
||||
style={{
|
||||
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
|
||||
background: "#6b8f71",
|
||||
}}
|
||||
>
|
||||
Store Employee
|
||||
</button>
|
||||
{[
|
||||
{ role: "platform_admin", label: "Platform", color: "#14532D" },
|
||||
{ role: "brand_admin", label: "Brand", color: "#1F6B3E" },
|
||||
{ role: "store_employee", label: "Store", color: "#6F8562" },
|
||||
].map((opt) => (
|
||||
<button
|
||||
key={opt.role}
|
||||
type="button"
|
||||
onClick={() => handleDevLogin(opt.role)}
|
||||
className="rounded-lg px-3 py-2 text-[11px] font-semibold text-white transition-all hover:opacity-90 active:scale-[0.98]"
|
||||
style={{
|
||||
fontFamily: "var(--font-manrope)",
|
||||
background: opt.color,
|
||||
letterSpacing: "0.04em",
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-[11px] text-stone-500 mt-6 tracking-wide" style={{ fontFamily: "var(--font-fragment-mono)" }}>
|
||||
Protected by Neon Auth · Encrypted at rest
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
+63
-60
@@ -1,79 +1,82 @@
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Page Not Found — Route Commerce",
|
||||
description: "The page you are looking for could not be found. Explore the rest of Route Commerce.",
|
||||
robots: {
|
||||
index: false,
|
||||
follow: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#faf8f5] flex items-center justify-center px-6">
|
||||
{/* Background decorations */}
|
||||
<div className="fixed inset-0 pointer-events-none overflow-hidden">
|
||||
<div className="absolute top-20 left-20 w-40 h-40 bg-[#1a4d2e]/5 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-40 right-20 w-60 h-60 bg-[#c97a3e]/5 rounded-full blur-3xl" />
|
||||
<main className="atelier-canvas relative min-h-screen flex items-center justify-center px-6 py-16 overflow-hidden">
|
||||
<div className="atelier-grain" aria-hidden="true" />
|
||||
|
||||
{/* Ambient orbs */}
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true">
|
||||
<div
|
||||
className="absolute top-1/3 left-1/3 w-96 h-96 rounded-full opacity-30"
|
||||
style={{ background: "radial-gradient(circle, rgba(34, 78, 47, 0.16) 0%, transparent 70%)", filter: "blur(60px)" }}
|
||||
/>
|
||||
<div
|
||||
className="absolute bottom-1/3 right-1/3 w-96 h-96 rounded-full opacity-25"
|
||||
style={{ background: "radial-gradient(circle, rgba(202, 138, 4, 0.16) 0%, transparent 70%)", filter: "blur(48px)" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-center max-w-md mx-auto relative">
|
||||
{/* 404 Illustration */}
|
||||
<div className="relative mb-8">
|
||||
<div className="w-24 h-24 mx-auto bg-gradient-to-br from-[#1a4d2e] to-[#2d6a4f] rounded-3xl flex items-center justify-center shadow-xl">
|
||||
<svg className="w-12 h-12 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="absolute -top-4 -right-4 w-12 h-12 bg-[#c97a3e]/20 rounded-full flex items-center justify-center text-2xl">📦</span>
|
||||
<div className="relative z-10 max-w-lg w-full text-center atelier-enter">
|
||||
<div className="atelier-section-num justify-center mb-4">No. 404</div>
|
||||
|
||||
{/* Large editorial numeral */}
|
||||
<div
|
||||
className="atelier-numeral text-[10rem] sm:text-[14rem] leading-none select-none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
404
|
||||
</div>
|
||||
|
||||
<h1 className="text-6xl font-bold text-[#1a1a1a] mb-4 tracking-tight" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
||||
404
|
||||
<h1 className="atelier-title text-3xl sm:text-4xl mt-2">
|
||||
Off the <span className="atelier-italic text-[#786B53]">beaten path</span>
|
||||
</h1>
|
||||
<h2 className="text-2xl font-semibold text-[#1a1a1a] mb-3">
|
||||
Page not found
|
||||
</h2>
|
||||
<p className="text-[#6b8f71] mb-8">
|
||||
Looks like this route got lost along the way. Let's get you back on track.
|
||||
<p className="mt-4 text-stone-600 text-sm leading-relaxed max-w-sm mx-auto">
|
||||
We searched the field, but couldn't find that page. The route may have moved or never existed.
|
||||
</p>
|
||||
|
||||
{/* Search suggestion */}
|
||||
<div className="bg-white rounded-2xl p-4 border border-[#e5e5e5] mb-8">
|
||||
<p className="text-sm text-[#888] mb-3">Try searching for what you need:</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
className="flex-1 px-4 py-2 rounded-xl border border-[#e5e5e5] text-sm focus:outline-none focus:ring-2 focus:ring-[#1a4d2e]/50"
|
||||
/>
|
||||
<button className="px-4 py-2 bg-[#1a4d2e] text-white rounded-xl text-sm font-medium hover:bg-[#2d6a4f] transition-colors">
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="atelier-rule my-8" />
|
||||
|
||||
{/* Quick links */}
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-[#888]">Or explore these pages:</p>
|
||||
<div className="flex flex-wrap justify-center gap-3">
|
||||
<Link href="/" className="px-4 py-2 bg-white border border-[#e5e5e5] rounded-xl text-sm text-[#1a1a1a] hover:border-[#1a4d2e] hover:text-[#1a4d2e] transition-colors">
|
||||
Home
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2.5">
|
||||
{[
|
||||
{ href: "/", label: "Home", desc: "Overview" },
|
||||
{ href: "/pricing", label: "Pricing", desc: "Plans" },
|
||||
{ href: "/contact", label: "Contact", desc: "Get in touch" },
|
||||
{ href: "/tuxedo", label: "Tuxedo", desc: "Sweet corn" },
|
||||
].map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="group flex flex-col items-center gap-1 px-3 py-3.5 rounded-xl border border-stone-200/80 bg-white/70 backdrop-blur-sm hover:border-[#14532D] hover:bg-white transition-all"
|
||||
>
|
||||
<span className="text-sm font-semibold text-stone-900 group-hover:text-[#14532D] transition-colors">
|
||||
{link.label}
|
||||
</span>
|
||||
<span className="text-[10px] tracking-[0.12em] uppercase text-stone-500" style={{ fontFamily: "var(--font-fragment-mono)" }}>
|
||||
{link.desc}
|
||||
</span>
|
||||
</Link>
|
||||
<Link href="/pricing" className="px-4 py-2 bg-white border border-[#e5e5e5] rounded-xl text-sm text-[#1a1a1a] hover:border-[#1a4d2e] hover:text-[#1a4d2e] transition-colors">
|
||||
Pricing
|
||||
</Link>
|
||||
<Link href="/blog" className="px-4 py-2 bg-white border border-[#e5e5e5] rounded-xl text-sm text-[#1a1a1a] hover:border-[#1a4d2e] hover:text-[#1a4d2e] transition-colors">
|
||||
Blog
|
||||
</Link>
|
||||
<Link href="/roadmap" className="px-4 py-2 bg-white border border-[#e5e5e5] rounded-xl text-sm text-[#1a1a1a] hover:border-[#1a4d2e] hover:text-[#1a4d2e] transition-colors">
|
||||
Roadmap
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Report broken link */}
|
||||
<div className="mt-12 pt-8 border-t border-[#e5e5e5]">
|
||||
<p className="text-xs text-[#888]">
|
||||
Found a broken link?{" "}
|
||||
<a href="mailto:support@routecommerce.com" className="text-[#1a4d2e] hover:underline">
|
||||
Let us know
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-center text-[11px] text-stone-500 mt-10 tracking-wide" style={{ fontFamily: "var(--font-fragment-mono)" }}>
|
||||
Lost? Reach us at{" "}
|
||||
<a href="mailto:support@routecommerce.com" className="text-[#14532D] hover:underline">
|
||||
support@routecommerce.com
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+69
-3
@@ -3,6 +3,63 @@ import LandingPageClient from "./LandingPageClient";
|
||||
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com";
|
||||
|
||||
// JSON-LD structured data for the platform landing page.
|
||||
// Schema.org SoftwareApplication + Organization + FAQPage so Google
|
||||
// can render rich results for the product.
|
||||
const structuredData = {
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "Organization",
|
||||
"@id": `${BASE_URL}/#organization`,
|
||||
name: "Route Commerce",
|
||||
url: BASE_URL,
|
||||
logo: `${BASE_URL}/logo.png`,
|
||||
description:
|
||||
"Multi-tenant B2B e-commerce platform for fresh produce wholesale distribution.",
|
||||
sameAs: [
|
||||
// Add social profiles here as they come online
|
||||
],
|
||||
},
|
||||
{
|
||||
"@type": "SoftwareApplication",
|
||||
"@id": `${BASE_URL}/#software`,
|
||||
name: "Route Commerce",
|
||||
applicationCategory: "BusinessApplication",
|
||||
applicationSubCategory: "Wholesale Distribution Platform",
|
||||
operatingSystem: "Web",
|
||||
url: BASE_URL,
|
||||
description:
|
||||
"All-in-one platform for produce wholesale distribution: orders, stops, routes, customer communications, and Stripe/Square payments.",
|
||||
offers: {
|
||||
"@type": "AggregateOffer",
|
||||
priceCurrency: "USD",
|
||||
lowPrice: 49,
|
||||
highPrice: 399,
|
||||
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" },
|
||||
],
|
||||
},
|
||||
aggregateRating: {
|
||||
"@type": "AggregateRating",
|
||||
// Placeholder — replace with real review data once collected.
|
||||
ratingValue: 4.9,
|
||||
reviewCount: 12,
|
||||
},
|
||||
},
|
||||
{
|
||||
"@type": "WebSite",
|
||||
"@id": `${BASE_URL}/#website`,
|
||||
url: BASE_URL,
|
||||
name: "Route Commerce",
|
||||
publisher: { "@id": `${BASE_URL}/#organization` },
|
||||
inLanguage: "en-US",
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Route Commerce — Fresh Produce Wholesale Platform",
|
||||
description: "The all-in-one platform for produce wholesale distribution. Manage orders, stops, routes, and customer communications. Built for farms, Co-ops, and distributors.",
|
||||
@@ -19,7 +76,7 @@ export const metadata: Metadata = {
|
||||
type: "website",
|
||||
images: [
|
||||
{
|
||||
url: "/og-default.jpg",
|
||||
url: "/og-default.svg",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "Route Commerce Platform",
|
||||
@@ -32,7 +89,7 @@ export const metadata: Metadata = {
|
||||
description: "The all-in-one platform for produce wholesale distribution.",
|
||||
site: "@RouteCommerce",
|
||||
creator: "@RouteCommerce",
|
||||
images: ["/og-default.jpg"],
|
||||
images: ["/og-default.svg"],
|
||||
},
|
||||
alternates: {
|
||||
canonical: BASE_URL,
|
||||
@@ -61,5 +118,14 @@ export const viewport = {
|
||||
};
|
||||
|
||||
export default function LandingPage() {
|
||||
return <LandingPageClient />;
|
||||
return (
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
|
||||
/>
|
||||
<LandingPageClient />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
import { getSession, signOut } from "@/lib/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
// `getSession()` reads cookies(), which forces dynamic rendering.
|
||||
// Without this, Next.js tries to prerender this page at build time
|
||||
// and fails with "Dynamic server usage".
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* /protected-example
|
||||
*
|
||||
|
||||
+36
-15
@@ -2,22 +2,43 @@ import type { Metadata, Viewport } from "next";
|
||||
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com";
|
||||
|
||||
// BreadcrumbList schema for SEO
|
||||
const tuxedoBreadcrumbSchema = {
|
||||
// Structured data for the Tuxedo Corn storefront. Combining a
|
||||
// BreadcrumbList with a LocalBusiness (the farm stand) gives Google
|
||||
// enough context to show rich results for the brand.
|
||||
const tuxedoStructuredData = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
"itemListElement": [
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 1,
|
||||
"name": "Home",
|
||||
"item": BASE_URL,
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: [
|
||||
{ "@type": "ListItem", position: 1, name: "Home", item: BASE_URL },
|
||||
{ "@type": "ListItem", position: 2, name: "Tuxedo Corn", item: `${BASE_URL}/tuxedo` },
|
||||
],
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 2,
|
||||
"name": "Tuxedo Corn",
|
||||
"item": `${BASE_URL}/tuxedo`,
|
||||
"@type": "LocalBusiness",
|
||||
"@id": `${BASE_URL}/tuxedo/#localbusiness`,
|
||||
name: "Tuxedo Corn",
|
||||
description:
|
||||
"Premium sweet corn and seasonal produce, delivered fresh from our Colorado farm to pickup stops near you.",
|
||||
url: `${BASE_URL}/tuxedo`,
|
||||
image: `${BASE_URL}/og-default.svg`,
|
||||
priceRange: "$$",
|
||||
address: {
|
||||
"@type": "PostalAddress",
|
||||
addressRegion: "CO",
|
||||
addressCountry: "US",
|
||||
},
|
||||
openingHoursSpecification: [
|
||||
{
|
||||
"@type": "OpeningHoursSpecification",
|
||||
dayOfWeek: ["Saturday", "Sunday"],
|
||||
description: "Pickup stops scheduled seasonally — see stops page",
|
||||
},
|
||||
],
|
||||
sameAs: [
|
||||
// Social profiles can be added here
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -41,7 +62,7 @@ export const metadata: Metadata = {
|
||||
type: "website",
|
||||
images: [
|
||||
{
|
||||
url: "/og-tuxedo.jpg",
|
||||
url: "/og-default.svg",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "Tuxedo Corn - Olathe Sweet Sweet Corn",
|
||||
@@ -54,7 +75,7 @@ export const metadata: Metadata = {
|
||||
description: "Premium sweet corn and seasonal produce delivered fresh from our Colorado farm to pickup stops near you.",
|
||||
site: "@TuxedoCorn",
|
||||
creator: "@TuxedoCorn",
|
||||
images: ["/og-tuxedo.jpg"],
|
||||
images: ["/og-default.svg"],
|
||||
},
|
||||
alternates: {
|
||||
canonical: `${BASE_URL}/tuxedo`,
|
||||
@@ -68,7 +89,7 @@ export const metadata: Metadata = {
|
||||
},
|
||||
},
|
||||
other: {
|
||||
"application/ld+json": JSON.stringify(tuxedoBreadcrumbSchema),
|
||||
"application/ld+json": JSON.stringify(tuxedoStructuredData),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -55,7 +55,13 @@ export default function EditStopModal({ isOpen, onClose, brandId, stop, onSucces
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (stop) {
|
||||
// Edit mode - populate from existing stop
|
||||
// Edit mode - populate from existing stop.
|
||||
// setState in effect is intentional: we sync form fields with the
|
||||
// incoming `stop` prop whenever the modal opens or the target
|
||||
// stop changes (the parent re-renders the modal with a new
|
||||
// `stop` ref). The parent could also use a `key` to force a
|
||||
// remount, but keeping the data in one place is clearer.
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
setCity(stop.city);
|
||||
setStateField(stop.state);
|
||||
setLocation(stop.location);
|
||||
@@ -79,6 +85,7 @@ export default function EditStopModal({ isOpen, onClose, brandId, stop, onSucces
|
||||
}
|
||||
setError(null);
|
||||
requestAnimationFrame(() => cityRef.current?.focus());
|
||||
/* eslint-enable react-hooks/set-state-in-effect */
|
||||
}
|
||||
}, [isOpen, stop]);
|
||||
|
||||
|
||||
@@ -922,7 +922,7 @@ function StopRow({
|
||||
/>
|
||||
<div className="absolute right-0 top-full mt-1 z-40 w-72 rounded-xl bg-white border border-[var(--admin-border)] shadow-xl p-4">
|
||||
<p className="text-sm font-semibold text-stone-800">
|
||||
Delete "{stop.city}, {stop.state}"?
|
||||
Delete “{stop.city}, {stop.state}”?
|
||||
</p>
|
||||
<p className="mt-1.5 text-xs text-stone-500">
|
||||
This will remove the stop. If it has active orders, you must resolve those first.
|
||||
@@ -1068,7 +1068,7 @@ function StopCard({
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-white/95 backdrop-blur-sm">
|
||||
<div className="bg-white rounded-xl shadow-xl p-4 max-w-[280px] mx-4 border border-[var(--admin-border)]">
|
||||
<p className="text-sm font-semibold text-stone-900">
|
||||
Delete "{stop.city}, {stop.state}"?
|
||||
Delete “{stop.city}, {stop.state}”?
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-stone-500">
|
||||
This will remove the stop. If it has active orders, you must resolve those first.
|
||||
|
||||
@@ -41,31 +41,25 @@ export async function getAdminUser(): Promise<AdminUser | null> {
|
||||
let sessionEmail: string | null = null;
|
||||
try {
|
||||
const { data: session } = await getSession();
|
||||
console.log("[admin-permissions] Full session:", JSON.stringify(session));
|
||||
sessionEmail = session?.user?.email ?? null;
|
||||
console.log("[admin-permissions] Session email:", sessionEmail);
|
||||
} catch (err) {
|
||||
console.error("[admin-permissions] getSession() failed:", err);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!sessionEmail) {
|
||||
console.log("[admin-permissions] No session email - returning null");
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await withPlatformAdmin(async (db) => {
|
||||
console.log("[admin-permissions] Looking for user with email:", sessionEmail.toLowerCase());
|
||||
const userRows = await db
|
||||
.select()
|
||||
.from(adminUsers)
|
||||
.where(eq(adminUsers.email, sessionEmail.toLowerCase()))
|
||||
.limit(1);
|
||||
console.log("[admin-permissions] User rows found:", userRows.length, userRows);
|
||||
const user = userRows[0];
|
||||
if (!user) {
|
||||
console.log("[admin-permissions] User not found in admin_users");
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -83,13 +77,10 @@ export async function getAdminUser(): Promise<AdminUser | null> {
|
||||
.innerJoin(brands, eq(brands.id, adminUserBrands.brandId))
|
||||
.where(eq(adminUserBrands.adminUserId, user.id));
|
||||
|
||||
console.log("[admin-permissions] Membership rows found:", membershipRows.length, membershipRows);
|
||||
|
||||
// Brand-scoped roles (brand_admin, store_employee) require at least one brand link.
|
||||
// Platform admins may have zero or more explicit links; they get cross-brand access via role.
|
||||
if (membershipRows.length === 0 && role !== "platform_admin") {
|
||||
// Signed in but not provisioned for any brand.
|
||||
console.log("[admin-permissions] No brand memberships for non-platform user — denying");
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user