feat: smooth view transitions, no skeleton flash
Deploy to route.crispygoat.com / deploy (push) Successful in 4m21s

User pain point: skeleton loading.tsx files made the app feel like
a sequence of page reloads, exposing backend latency. Replaced with
a single 1px shimmer bar + crossfade via React's <ViewTransition>.

Changes:
- Enable experimental.viewTransition in next.config.ts
- Add SmoothViewTransition wrapper (ViewTransition name=page-content)
- Add LoadingFade component: thin animated bar instead of skeleton
- Add RouteAnnouncer for a11y (screen readers + focus reset)
- Add ::view-transition-old/new CSS for the crossfade (220ms, no
  jarring slide, respects prefers-reduced-motion)
- Wrap admin/tuxedo/IRD layout children in SmoothViewTransition
  (sidebar/header/footer stay mounted; only the body fades)
- Replace 19 skeleton loading.tsx files with the fade component

Result: navigation now feels like a single app, not a series of
preload-and-render events. The user never sees a 'skeleton of the
page they're about to load.'
This commit is contained in:
Tyler
2026-06-16 23:37:00 -06:00
parent 9458fd0506
commit 4ebbc6dacf
27 changed files with 304 additions and 1105 deletions
+4 -3
View File
@@ -1,5 +1,6 @@
import CommunicationsLoading from "@/components/admin/CommunicationsLoading";
import { LoadingFade } from "@/components/transitions/LoadingFade";
/** Subtle navigation indicator — see LoadingFade for rationale. */
export default function Loading() {
return <CommunicationsLoading activeTab="campaigns" />;
}
return <LoadingFade />;
}
+13 -2
View File
@@ -8,6 +8,8 @@ import { getSession } from "@/lib/auth";
import "@/styles/admin-design-system.css";
import { ToastProvider } from "@/components/admin/Toast";
import { ToastContainer } from "@/components/admin/ToastContainer";
import { SmoothViewTransition } from "@/components/transitions/SmoothViewTransition";
import { RouteAnnouncer } from "@/components/transitions/RouteAnnouncer";
// Admin layout calls getAdminUser() which reads cookies(). Without this,
// Next.js tries to prerender the entire /admin/* tree statically and the
@@ -105,8 +107,17 @@ export default async function AdminLayout({ children }: { children: React.ReactN
activeBrandId={activeBrandId}
brands={brands}
/>
<div className="min-h-screen lg:pl-60 admin-section" style={{ backgroundColor: "var(--admin-bg)" }}>
{children}
{/* The main content area swaps on every navigation. Wrapping
it in <SmoothViewTransition> opts the swap into a soft
crossfade via the View Transitions API — sidebar and
background stay mounted, only the body fades. */}
<div
id="page-content"
className="min-h-screen lg:pl-60 admin-section outline-none"
style={{ backgroundColor: "var(--admin-bg)" }}
>
<SmoothViewTransition>{children}</SmoothViewTransition>
<RouteAnnouncer />
</div>
</ToastProviderWrapper>
);
+13 -111
View File
@@ -1,112 +1,14 @@
import { LoadingFade } from "@/components/transitions/LoadingFade";
/**
* Admin shell loading state.
*
* Replaces the previous skeleton (header + 4 stat cards + 2 panels +
* 5-row table) with a 1px-thick animated bar at the top of the page.
* The AdminSidebar and parchment background stay mounted, so the
* transition is a subtle fade — the user never sees an "empty" version
* of the admin while the next page streams in.
*/
export default function AdminLoading() {
return (
<main className="min-h-screen px-4 sm:px-6 md:px-8 py-6" style={{ backgroundColor: "var(--admin-bg)" }}>
{/* Header skeleton */}
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-3">
<div className="animate-pulse w-9 h-9 rounded-lg" style={{ backgroundColor: "var(--admin-border)" }} />
<div className="space-y-2">
<div className="animate-pulse h-5 w-36 rounded" style={{ backgroundColor: "var(--admin-border)" }} />
<div className="animate-pulse h-3 w-24 rounded" style={{ backgroundColor: "var(--admin-border)" }} />
</div>
</div>
<div className="animate-pulse h-8 w-28 rounded-lg" style={{ backgroundColor: "var(--admin-border)" }} />
</div>
{/* Stats cards skeleton */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-5">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="rounded-xl bg-white border overflow-hidden">
<div className="flex items-center gap-3 p-4">
<div className="animate-pulse w-9 h-9 rounded-xl flex-shrink-0" style={{ backgroundColor: "var(--admin-border)" }} />
<div className="space-y-1.5 flex-1">
<div className="animate-pulse h-2.5 w-16 rounded" style={{ backgroundColor: "var(--admin-border)" }} />
<div className="animate-pulse h-6 w-12 rounded" style={{ backgroundColor: "var(--admin-border)" }} />
</div>
</div>
</div>
))}
</div>
{/* Quick Actions + Usage skeleton */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3 mb-5">
<div className="rounded-xl bg-white border overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 border-b" style={{ borderColor: "var(--admin-border-light)" }}>
<div className="animate-pulse h-3 w-20 rounded" style={{ backgroundColor: "var(--admin-border)" }} />
</div>
<div className="grid grid-cols-2 gap-2 p-3">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="animate-pulse rounded-lg h-10" style={{ backgroundColor: "var(--admin-border)" }} />
))}
</div>
</div>
<div className="lg:col-span-2 rounded-xl bg-white border overflow-hidden">
<div className="flex items-center gap-2 px-4 py-3 border-b" style={{ borderColor: "var(--admin-border-light)" }}>
<div className="animate-pulse h-5 w-16 rounded-full" style={{ backgroundColor: "var(--admin-border)" }} />
<div className="animate-pulse h-3 w-24 rounded" style={{ backgroundColor: "var(--admin-border)" }} />
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 p-4">
{[1, 2, 3].map((i) => (
<div key={i} className="space-y-2">
<div className="flex items-center justify-between">
<div className="animate-pulse h-2.5 w-10 rounded" style={{ backgroundColor: "var(--admin-border)" }} />
<div className="animate-pulse h-3 w-12 rounded" style={{ backgroundColor: "var(--admin-border)" }} />
</div>
<div className="animate-pulse h-1.5 rounded-full" style={{ backgroundColor: "var(--admin-border)" }} />
</div>
))}
</div>
</div>
</div>
{/* Recent orders skeleton */}
<div className="rounded-xl bg-white border overflow-hidden mb-5">
<div className="flex items-center justify-between px-4 py-3 border-b" style={{ borderColor: "var(--admin-border-light)" }}>
<div className="animate-pulse h-3 w-20 rounded" style={{ backgroundColor: "var(--admin-border)" }} />
<div className="animate-pulse h-3 w-14 rounded" style={{ backgroundColor: "var(--admin-border)" }} />
</div>
<div className="divide-y" style={{ borderColor: "var(--admin-border-light)" }}>
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="flex items-center justify-between px-4 py-3">
<div className="flex items-center gap-3">
<div className="animate-pulse w-8 h-8 rounded-lg flex-shrink-0" style={{ backgroundColor: "var(--admin-border)" }} />
<div className="space-y-1">
<div className="animate-pulse h-3 w-28 rounded" style={{ backgroundColor: "var(--admin-border)" }} />
<div className="animate-pulse h-2.5 w-16 rounded" style={{ backgroundColor: "var(--admin-border)" }} />
</div>
</div>
<div className="flex items-center gap-3">
<div className="animate-pulse h-3 w-14 rounded" style={{ backgroundColor: "var(--admin-border)" }} />
<div className="animate-pulse h-5 w-16 rounded-full" style={{ backgroundColor: "var(--admin-border)" }} />
</div>
</div>
))}
</div>
</div>
{/* Filter tabs skeleton */}
<div className="flex gap-2 mb-4">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="animate-pulse h-9 w-20 rounded-lg" style={{ backgroundColor: "var(--admin-border)" }} />
))}
</div>
{/* Section cards skeleton */}
<div className="grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-4 gap-3">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="rounded-xl bg-white border overflow-hidden">
<div className="flex items-start justify-between p-4">
<div className="animate-pulse w-9 h-9 rounded-xl" style={{ backgroundColor: "var(--admin-border)" }} />
<div className="animate-pulse w-12 h-4 rounded-full" style={{ backgroundColor: "var(--admin-border)" }} />
</div>
<div className="px-4 pb-4 space-y-1.5">
<div className="animate-pulse h-3 w-24 rounded" style={{ backgroundColor: "var(--admin-border)" }} />
<div className="animate-pulse h-2.5 w-full rounded" style={{ backgroundColor: "var(--admin-border)" }} />
<div className="animate-pulse h-2.5 w-3/4 rounded" style={{ backgroundColor: "var(--admin-border)" }} />
</div>
</div>
))}
</div>
</main>
);
}
return <LoadingFade />;
}
+4 -74
View File
@@ -1,76 +1,6 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Your Cart — Route Commerce",
description: "Review and manage your shopping cart. Select pickup stops, adjust quantities, and proceed to checkout.",
keywords: ["cart", "shopping cart", "produce order", "checkout", "pickup"],
robots: {
index: false,
follow: false,
},
};
import { LoadingFade } from "@/components/transitions/LoadingFade";
/** Subtle navigation indicator — see LoadingFade for rationale. */
export default function Loading() {
return (
<div className="min-h-screen relative">
{/* Dark background matching cart page */}
<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-0 right-0 w-[500px] h-[500px] bg-emerald-500/10 rounded-full blur-[120px]" aria-hidden="true" />
</div>
{/* Minimal header skeleton */}
<div className="h-20 border-b border-white/5" />
{/* Content skeleton */}
<main className="px-6 py-12 relative">
<div className="mx-auto grid max-w-6xl gap-10 lg:grid-cols-[1fr_380px]">
<div>
{/* Title skeleton */}
<div className="h-10 w-56 rounded-lg bg-white/5 animate-pulse" />
<div className="h-5 w-80 rounded mt-3 bg-white/5 animate-pulse" />
{/* Cart items skeleton */}
<div className="mt-10 space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="glass-card p-6">
<div className="flex items-center justify-between">
<div className="space-y-2">
<div className="h-5 w-40 rounded bg-white/5 animate-pulse" />
<div className="h-4 w-24 rounded bg-white/5 animate-pulse" />
</div>
<div className="flex items-center gap-3">
<div className="h-11 w-11 rounded-xl bg-white/5 animate-pulse" />
<div className="h-11 w-10 rounded bg-white/5 animate-pulse" />
<div className="h-11 w-11 rounded-xl bg-white/5 animate-pulse" />
</div>
</div>
<div className="mt-4 flex items-center justify-between pt-3 border-t border-white/5">
<div className="h-5 w-20 rounded bg-white/5 animate-pulse" />
<div className="h-4 w-16 rounded bg-white/5 animate-pulse" />
</div>
</div>
))}
</div>
</div>
{/* Sidebar skeleton */}
<aside>
<div className="glass-card p-6 sticky top-6">
<div className="h-6 w-32 rounded bg-white/5 animate-pulse" />
<div className="mt-5 space-y-3">
<div className="h-4 w-full rounded bg-white/5 animate-pulse" />
<div className="h-4 w-3/4 rounded bg-white/5 animate-pulse" />
</div>
<div className="mt-5 h-14 w-full rounded-xl bg-white/5 animate-pulse" />
<div className="mt-4 h-4 w-32 mx-auto rounded bg-white/5 animate-pulse" />
</div>
</aside>
</div>
</main>
{/* Status for accessibility */}
<span role="status" className="sr-only">Loading your cart...</span>
</div>
);
}
return <LoadingFade />;
}
+4 -63
View File
@@ -1,65 +1,6 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Processing...",
description: "Loading checkout...",
robots: {
index: false,
follow: false,
},
};
import { LoadingFade } from "@/components/transitions/LoadingFade";
/** Subtle navigation indicator — see LoadingFade for rationale. */
export default function Loading() {
return (
<div className="min-h-screen bg-gradient-to-br from-stone-50 via-white to-emerald-50/30">
<div className="h-20 border-b border-slate-100/50" />
<main className="px-6 py-12">
<div className="mx-auto grid max-w-6xl gap-8 md:grid-cols-[1fr_400px]">
<div>
{/* Title skeleton */}
<div className="h-10 w-32 rounded-lg bg-slate-200 animate-pulse" />
<div className="h-5 w-64 rounded mt-3 bg-slate-100 animate-pulse" />
{/* Form skeleton */}
<div className="mt-8 space-y-6">
<div className="rounded-2xl bg-white p-6 shadow-sm border border-slate-100">
<div className="h-6 w-40 rounded bg-slate-100 animate-pulse" />
<div className="mt-4 space-y-4">
<div className="space-y-2">
<div className="h-4 w-20 rounded bg-slate-100 animate-pulse" />
<div className="h-12 w-full rounded-xl bg-slate-100 animate-pulse" />
</div>
<div className="space-y-2">
<div className="h-4 w-16 rounded bg-slate-100 animate-pulse" />
<div className="h-12 w-full rounded-xl bg-slate-100 animate-pulse" />
</div>
</div>
</div>
{/* Button skeleton */}
<div className="h-14 w-full rounded-xl bg-slate-100 animate-pulse" />
</div>
</div>
{/* Sidebar skeleton */}
<aside>
<div className="rounded-2xl bg-white p-6 shadow-sm border border-slate-100 sticky top-6">
<div className="h-6 w-32 rounded bg-slate-100 animate-pulse" />
<div className="mt-4 space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="flex justify-between">
<div className="h-4 w-32 rounded bg-slate-100 animate-pulse" />
<div className="h-4 w-16 rounded bg-slate-100 animate-pulse" />
</div>
))}
</div>
</div>
</aside>
</div>
</main>
<span role="status" className="sr-only">Loading checkout...</span>
</div>
);
}
return <LoadingFade />;
}
+4 -76
View File
@@ -1,78 +1,6 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Loading contact...",
description: "Loading...",
robots: {
index: false,
follow: false,
},
};
import { LoadingFade } from "@/components/transitions/LoadingFade";
/** Subtle navigation indicator — see LoadingFade for rationale. */
export default function Loading() {
return (
<div className="min-h-screen bg-white">
{/* Header skeleton */}
<header className="fixed top-0 left-0 right-0 z-50 bg-white/90 backdrop-blur-xl border-b border-[#e5e5e5]">
<div className="max-w-5xl mx-auto px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-11 h-11 rounded-2xl bg-gradient-to-br from-[#e5e5e5] to-[#f5f5f5] animate-pulse" />
<div className="h-6 w-36 rounded bg-[#e5e5e5] animate-pulse" />
</div>
<div className="h-5 w-24 rounded bg-[#e5e5e5] animate-pulse" />
</div>
</header>
<main className="max-w-5xl mx-auto px-6 py-32">
{/* Page header skeleton */}
<div className="text-center mb-16">
<div className="h-4 w-20 rounded-full bg-[#e5e5e5] mx-auto mb-4 animate-pulse" />
<div className="h-12 w-64 rounded-lg bg-[#e5e5e5] mx-auto mb-4 animate-pulse" />
<div className="h-5 w-96 rounded bg-[#e5e5e5] mx-auto animate-pulse" />
</div>
{/* Contact cards skeleton */}
<div className="grid gap-6 md:grid-cols-3 mb-16">
{[1, 2, 3].map((i) => (
<div key={i} className="rounded-3xl bg-white border border-[#e5e5e5] p-8">
<div className="h-14 w-14 rounded-2xl bg-[#e5e5e5] mx-auto mb-5 animate-pulse" />
<div className="h-5 w-24 rounded bg-[#e5e5e5] mx-auto mb-3 animate-pulse" />
<div className="h-4 w-40 rounded bg-[#e5e5e5] mx-auto animate-pulse" />
</div>
))}
</div>
{/* Form skeleton */}
<div className="rounded-3xl bg-white border border-[#e5e5e5] p-10">
<div className="h-5 w-20 rounded-full bg-[#e5e5e5] mb-3 animate-pulse" />
<div className="h-8 w-40 rounded bg-[#e5e5e5] mb-4 animate-pulse" />
<div className="h-1 w-12 bg-[#e5e5e5] mb-8 animate-pulse" />
<div className="space-y-6">
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<div className="h-4 w-20 rounded bg-[#e5e5e5] animate-pulse" />
<div className="h-12 rounded-xl bg-[#e5e5e5] animate-pulse" />
</div>
<div className="space-y-2">
<div className="h-4 w-16 rounded bg-[#e5e5e5] animate-pulse" />
<div className="h-12 rounded-xl bg-[#e5e5e5] animate-pulse" />
</div>
</div>
<div className="space-y-2">
<div className="h-4 w-16 rounded bg-[#e5e5e5] animate-pulse" />
<div className="h-12 rounded-xl bg-[#e5e5e5] animate-pulse" />
</div>
<div className="space-y-2">
<div className="h-4 w-20 rounded bg-[#e5e5e5] animate-pulse" />
<div className="h-32 rounded-xl bg-[#e5e5e5] animate-pulse" />
</div>
<div className="h-12 w-32 rounded-xl bg-[#e5e5e5] animate-pulse" />
</div>
</div>
</main>
<span role="status" className="sr-only">Loading contact page...</span>
</div>
);
}
return <LoadingFade />;
}
+53
View File
@@ -177,6 +177,59 @@ select:-webkit-autofill:focus {
color: #999999;
}
/* ─── View Transitions (route fades) ────────────────────────────────────── */
/*
* Native browser View Transitions API, opt-in via the
* <ViewTransition name="page-content"> component and Next.js's
* experimental.viewTransition config. These rules define the actual
* crossfade / slide. Without them, transitions still work but use
* the browser default (instant cut).
*
* 220ms is the sweet spot for route changes: long enough to soften
* the cut, short enough to feel like a single app. The cubic-bezier
* keeps the start crisp and the finish gentle.
*/
::view-transition-old(page-content) {
animation: route-fade-out 180ms cubic-bezier(0.4, 0, 0.2, 1) both;
}
::view-transition-new(page-content) {
animation: route-fade-in 240ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes route-fade-out {
to {
opacity: 0;
transform: translateY(-4px);
}
}
@keyframes route-fade-in {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Top-of-page shimmer for the LoadingFade placeholder bar. */
@keyframes transition-shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(400%); }
}
/* Reduce motion: respect the user's OS-level preference. */
@media (prefers-reduced-motion: reduce) {
::view-transition-old(page-content),
::view-transition-new(page-content) {
animation: none !important;
}
@keyframes transition-shimmer {
0%, 100% { transform: translateX(0); }
}
}
/* Light mode placeholder (used by storefront) */
.light ::placeholder,
.light::-webkit-input-placeholder,
+4 -38
View File
@@ -1,40 +1,6 @@
"use client";
import { motion } from "framer-motion";
import { LoadingFade } from "@/components/transitions/LoadingFade";
/** Subtle navigation indicator — see LoadingFade for rationale. */
export default function Loading() {
return (
<div className="min-h-screen bg-stone-50">
<div className="mx-auto max-w-4xl px-6 py-20">
{/* Hero skeleton */}
<div className="mb-20 animate-pulse text-center">
<div className="h-3 w-24 rounded bg-blue-100 mx-auto mb-5" />
<div className="h-20 w-full max-w-xl mx-auto rounded-lg bg-blue-50 mb-5" />
<div className="h-8 w-full max-w-2xl mx-auto rounded bg-blue-50" />
</div>
{/* Content sections skeleton */}
<div className="space-y-20">
{[0, 1, 2].map((i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.1 }}
className="grid md:grid-cols-2 gap-12 items-center"
>
<div className="animate-pulse space-y-4">
<div className="h-4 w-20 rounded bg-blue-100" />
<div className="h-8 w-3/4 rounded bg-stone-200" />
<div className="h-4 w-full rounded bg-stone-100" />
<div className="h-4 w-full rounded bg-stone-100" />
<div className="h-4 w-2/3 rounded bg-stone-100" />
</div>
<div className="h-64 rounded-3xl bg-gradient-to-br from-blue-50 to-white border-2 border-blue-100" />
</motion.div>
))}
</div>
</div>
</div>
);
}
return <LoadingFade />;
}
@@ -1,62 +1,6 @@
"use client";
import { motion } from "framer-motion";
import { LoadingFade } from "@/components/transitions/LoadingFade";
/** Subtle navigation indicator — see LoadingFade for rationale. */
export default function Loading() {
return (
<div className="min-h-screen bg-stone-50">
<div className="mx-auto max-w-4xl px-6 py-20">
{/* Header skeleton */}
<div className="mb-16 animate-pulse text-center">
<div className="h-4 w-20 rounded bg-blue-100 mx-auto mb-5" />
<div className="h-16 w-64 mx-auto rounded-lg bg-stone-200 mb-5" />
<div className="h-5 w-96 max-w-full mx-auto rounded bg-stone-200" />
</div>
{/* Contact cards skeleton */}
<div className="grid gap-6 md:grid-cols-3 mb-16">
{[0, 1, 2].map((i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.1 }}
className="rounded-3xl bg-white border-2 border-stone-200 p-8 shadow-xl text-center"
>
<div className="h-14 w-14 rounded-2xl bg-blue-100 mx-auto mb-5" />
<div className="h-5 w-24 mx-auto rounded bg-stone-200 mb-3" />
<div className="h-4 w-full max-w-[160px] mx-auto rounded bg-stone-100" />
</motion.div>
))}
</div>
{/* Form skeleton */}
<div className="rounded-3xl bg-white border-2 border-stone-200 p-10 shadow-xl">
<div className="animate-pulse space-y-6">
<div className="h-6 w-32 rounded bg-stone-200" />
<div className="h-8 w-48 rounded bg-stone-200" />
<div className="grid gap-6 md:grid-cols-2 mt-8">
<div className="space-y-2">
<div className="h-4 w-20 rounded bg-stone-100" />
<div className="h-14 rounded-xl bg-stone-100" />
</div>
<div className="space-y-2">
<div className="h-4 w-20 rounded bg-stone-100" />
<div className="h-14 rounded-xl bg-stone-100" />
</div>
</div>
<div className="space-y-2">
<div className="h-4 w-16 rounded bg-stone-100" />
<div className="h-14 rounded-xl bg-stone-100" />
</div>
<div className="space-y-2">
<div className="h-4 w-20 rounded bg-stone-100" />
<div className="h-32 rounded-xl bg-stone-100" />
</div>
<div className="h-14 w-40 rounded-xl bg-blue-100 mt-8" />
</div>
</div>
</div>
</div>
);
}
return <LoadingFade />;
}
+4 -39
View File
@@ -1,41 +1,6 @@
"use client";
import { motion } from "framer-motion";
import { LoadingFade } from "@/components/transitions/LoadingFade";
/** Subtle navigation indicator — see LoadingFade for rationale. */
export default function Loading() {
return (
<div className="min-h-screen bg-stone-50">
<div className="mx-auto max-w-3xl px-6 py-20">
{/* Header skeleton */}
<div className="mb-14 text-center animate-pulse">
<div className="h-4 w-16 rounded bg-blue-100 mx-auto mb-4" />
<div className="h-16 w-48 mx-auto rounded-lg bg-stone-200 mb-5" />
<div className="h-5 w-96 max-w-full mx-auto rounded bg-stone-200" />
</div>
{/* Search skeleton */}
<div className="mb-12">
<div className="h-14 rounded-2xl bg-white border-2 border-stone-200 shadow-lg" />
</div>
{/* FAQ items skeleton */}
<div className="space-y-4">
{[0, 1, 2, 3, 4].map((i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.05 }}
className="rounded-2xl bg-white border-2 border-stone-200 overflow-hidden"
>
<div className="flex items-center justify-between px-6 py-5">
<div className="h-5 w-3/4 rounded bg-stone-200" />
<div className="h-8 w-8 rounded-full bg-blue-100" />
</div>
</motion.div>
))}
</div>
</div>
</div>
);
}
return <LoadingFade />;
}
+6 -3
View File
@@ -1,4 +1,5 @@
import type { Metadata, Viewport } from "next";
import { SmoothViewTransition } from "@/components/transitions/SmoothViewTransition";
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com";
@@ -94,7 +95,7 @@ export default function IndianRiverLayout({ children }: { children: React.ReactN
<div className="min-h-screen relative">
{/* Glass morphism gradient background */}
<div className="fixed inset-0 bg-gradient-to-br from-blue-100/40 via-white/60 to-blue-50/30" />
{/* Decorative glass orbs */}
<div className="fixed inset-0 pointer-events-none overflow-hidden">
<div className="absolute -top-40 -right-40 w-96 h-96 bg-gradient-to-br from-blue-200/30 to-transparent rounded-full blur-3xl" />
@@ -102,8 +103,10 @@ export default function IndianRiverLayout({ children }: { children: React.ReactN
<div className="absolute bottom-0 right-1/4 w-80 h-80 bg-gradient-to-br from-blue-50/40 to-transparent rounded-full blur-3xl" />
</div>
{/* Content wrapper */}
<div className="relative">{children}</div>
{/* Content wrapper — crossfades on navigation. */}
<div id="page-content" className="relative outline-none">
<SmoothViewTransition>{children}</SmoothViewTransition>
</div>
</div>
);
}
+6 -77
View File
@@ -1,78 +1,7 @@
"use client";
import { LoadingFade } from "@/components/transitions/LoadingFade";
import { motion } from "framer-motion";
export default function Loading() {
return (
<main className="min-h-screen bg-white/50 backdrop-blur-xl">
<div className="mx-auto max-w-6xl px-6 py-16">
{/* Header skeleton with blue accent */}
<div className="mb-16 animate-pulse">
<div className="h-3 w-20 rounded bg-blue-100 mb-4" />
<div className="h-16 w-80 rounded-lg bg-blue-50 mb-4" />
<div className="h-6 w-96 max-w-full rounded bg-blue-50" />
</div>
{/* Product cards skeleton */}
<div className="grid gap-6 md:grid-cols-3">
{[0, 1, 2].map((i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.1 }}
className="rounded-3xl bg-white border-2 border-stone-200 overflow-hidden shadow-lg"
>
{/* Image skeleton */}
<div className="h-48 bg-gradient-to-br from-blue-50 to-white relative overflow-hidden">
<div className="absolute inset-0 -translate-x-full animate-[shimmer_1.5s_infinite] bg-gradient-to-r from-transparent via-blue-100/60 to-transparent" />
</div>
{/* Content skeleton */}
<div className="p-6 space-y-4">
<div className="h-6 w-3/4 rounded bg-stone-200" />
<div className="h-4 w-full rounded bg-stone-100" />
<div className="h-4 w-2/3 rounded bg-stone-100" />
<div className="pt-4 flex items-center justify-between">
<div className="h-8 w-20 rounded-lg bg-blue-50" />
<div className="h-11 w-28 rounded-xl bg-blue-100" />
</div>
</div>
</motion.div>
))}
</div>
{/* Stops section skeleton */}
<div className="mt-20">
<div className="animate-pulse mb-10">
<div className="h-3 w-24 rounded bg-blue-100 mb-4" />
<div className="h-10 w-64 rounded-lg bg-blue-50 mb-3" />
<div className="h-4 w-48 rounded bg-blue-50" />
</div>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{[0, 1, 2, 3, 4, 5].map((i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.05 }}
className="rounded-3xl bg-gradient-to-br from-blue-50 to-white border-2 border-blue-100 p-6"
>
<div className="flex items-start justify-between mb-3">
<div>
<div className="h-6 w-32 rounded bg-stone-200 mb-2" />
<div className="h-4 w-full rounded bg-stone-100" />
</div>
<div className="h-6 w-16 rounded-full bg-blue-100" />
</div>
<div className="space-y-1">
<div className="h-4 w-28 rounded bg-stone-100" />
<div className="h-4 w-20 rounded bg-stone-100" />
</div>
</motion.div>
))}
</div>
</div>
</div>
</main>
);
}
/** Indian River Direct storefront loading — light blue glass backdrop
* stays mounted in the layout. */
export default function IndianRiverLoading() {
return <LoadingFade />;
}
@@ -1,70 +1,6 @@
"use client";
import { motion } from "framer-motion";
import { LoadingFade } from "@/components/transitions/LoadingFade";
/** Subtle navigation indicator — see LoadingFade for rationale. */
export default function Loading() {
return (
<div className="min-h-screen bg-stone-50/80 backdrop-blur-xl">
<div className="mx-auto max-w-5xl px-6 py-20">
{/* Back navigation skeleton */}
<div className="mb-10 animate-pulse">
<div className="h-5 w-32 rounded bg-stone-200" />
</div>
{/* Stop header skeleton */}
<div className="mb-12 animate-pulse rounded-3xl bg-white/60 border border-white/50 p-8">
<div className="h-3 w-32 rounded bg-blue-100 mb-4" />
<div className="h-16 w-80 rounded-lg bg-stone-200 mb-4" />
<div className="h-5 w-full max-w-md rounded bg-stone-200 mb-4" />
<div className="h-px w-12 bg-blue-100" />
</div>
{/* Stop info skeleton */}
<div className="mb-14 rounded-3xl bg-white/80 border border-white/50 p-8 shadow-xl">
<div className="grid gap-4 md:grid-cols-3">
{[0, 1, 2].map((i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.1 }}
className="flex items-start gap-4 py-4 px-5 rounded-2xl bg-stone-50"
>
<div className="h-10 w-10 rounded-xl bg-blue-50" />
<div className="flex-1">
<div className="h-3 w-12 rounded bg-stone-200 mb-2" />
<div className="h-5 w-24 rounded bg-stone-200" />
</div>
</motion.div>
))}
</div>
</div>
{/* Products section skeleton */}
<div className="animate-pulse">
<div className="h-4 w-20 rounded bg-blue-100 mb-4" />
<div className="h-10 w-48 rounded bg-stone-200 mb-4" />
<div className="h-4 w-64 rounded bg-stone-200 mb-10" />
<div className="grid gap-8 md:grid-cols-3">
{[0, 1, 2].map((i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.1 }}
className="rounded-3xl bg-white overflow-hidden shadow-lg"
>
<div className="h-48 bg-gradient-to-br from-blue-50 to-white" />
<div className="p-6 space-y-3">
<div className="h-6 w-3/4 rounded bg-stone-200" />
<div className="h-4 w-full rounded bg-stone-100" />
<div className="h-4 w-2/3 rounded bg-stone-100" />
</div>
</motion.div>
))}
</div>
</div>
</div>
</div>
);
}
return <LoadingFade />;
}
+5 -77
View File
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import { LoadingFade } from "@/components/transitions/LoadingFade";
export const metadata: Metadata = {
title: "Loading — Route Commerce",
@@ -10,81 +11,8 @@ export const metadata: Metadata = {
};
export default function Loading() {
return (
<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="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="atelier-title text-2xl"
>
Preparing your <span className="atelier-italic text-[#786B53]">harvest</span>
</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>
<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>
);
// No skeleton. The previous page stays visible while the next one
// streams in, and the View Transition API crossfades them. This
// thin top bar is the only signal that navigation is in flight.
return <LoadingFade />;
}
+4 -57
View File
@@ -1,59 +1,6 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Loading...",
description: "Loading...",
robots: {
index: false,
follow: false,
},
};
import { LoadingFade } from "@/components/transitions/LoadingFade";
/** Subtle navigation indicator — see LoadingFade for rationale. */
export default function Loading() {
return (
<div className="min-h-screen flex items-center justify-center px-6 py-12 bg-gradient-to-br from-stone-50 to-amber-50/30">
{/* Background orbs */}
<div
className="fixed w-96 h-96 rounded-full pointer-events-none animate-pulse"
style={{
background: 'radial-gradient(circle, rgba(34, 197, 94, 0.15) 0%, transparent 70%)',
top: '10%',
left: '10%',
filter: 'blur(60px)'
}}
aria-hidden="true"
/>
{/* Loading card */}
<div className="w-full max-w-sm relative">
<div className="bg-white/70 backdrop-blur-2xl rounded-[2rem] shadow-lg p-8">
{/* Logo skeleton */}
<div className="flex justify-center mb-10">
<div className="h-20 w-20 rounded-3xl bg-slate-200 animate-pulse" />
</div>
{/* Text skeleton */}
<div className="space-y-3">
<div className="h-8 w-32 rounded bg-slate-200 mx-auto animate-pulse" />
<div className="h-4 w-24 rounded bg-slate-100 mx-auto animate-pulse" />
</div>
{/* Form skeleton */}
<div className="mt-8 space-y-4">
<div className="space-y-2">
<div className="h-4 w-16 rounded bg-slate-100 animate-pulse" />
<div className="h-12 rounded-xl bg-slate-100 animate-pulse" />
</div>
<div className="space-y-2">
<div className="h-4 w-20 rounded bg-slate-100 animate-pulse" />
<div className="h-12 rounded-xl bg-slate-100 animate-pulse" />
</div>
<div className="h-12 rounded-xl bg-slate-200 animate-pulse" />
</div>
</div>
</div>
<span role="status" className="sr-only">Loading login page...</span>
</div>
);
}
return <LoadingFade />;
}
+4 -59
View File
@@ -1,61 +1,6 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Loading pricing...",
description: "Loading...",
robots: {
index: false,
follow: false,
},
};
import { LoadingFade } from "@/components/transitions/LoadingFade";
/** Subtle navigation indicator — see LoadingFade for rationale. */
export default function Loading() {
return (
<div className="min-h-screen bg-white">
{/* Nav skeleton */}
<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="h-9 w-9 rounded-xl bg-slate-200 animate-pulse" />
<div className="h-6 w-36 rounded bg-slate-200 animate-pulse" />
</div>
<div className="flex items-center gap-6">
<div className="h-4 w-16 rounded bg-slate-200 animate-pulse" />
<div className="h-4 w-20 rounded bg-slate-200 animate-pulse" />
<div className="h-9 w-24 rounded-xl bg-slate-200 animate-pulse" />
</div>
</div>
</header>
{/* Hero skeleton */}
<section className="bg-gradient-to-b from-slate-50 to-white px-6 py-20 text-center">
<div className="mx-auto max-w-3xl">
<div className="h-6 w-40 rounded-full bg-slate-200 mx-auto mb-4 animate-pulse" />
<div className="h-16 w-80 rounded-lg bg-slate-200 mx-auto mb-6 animate-pulse" />
<div className="h-6 w-96 rounded bg-slate-200 mx-auto animate-pulse" />
</div>
</section>
{/* Plan cards skeleton */}
<section className="mx-auto max-w-6xl px-6 py-6">
<div className="grid gap-6 lg:grid-cols-3">
{[1, 2, 3].map((i) => (
<div key={i} className="rounded-2xl border-2 border-slate-200 p-6">
<div className="h-6 w-24 rounded bg-slate-200 mb-3 animate-pulse" />
<div className="h-4 w-32 rounded bg-slate-200 mb-1 animate-pulse" />
<div className="h-10 w-24 rounded bg-slate-200 mb-4 animate-pulse" />
<div className="h-12 w-full rounded-xl bg-slate-200 animate-pulse" />
<div className="mt-6 space-y-2.5">
{[1, 2, 3, 4].map((j) => (
<div key={j} className="h-4 w-full rounded bg-slate-100 animate-pulse" />
))}
</div>
</div>
))}
</div>
</section>
<span role="status" className="sr-only">Loading pricing page...</span>
</div>
);
}
return <LoadingFade />;
}
+4 -38
View File
@@ -1,40 +1,6 @@
"use client";
import { motion } from "framer-motion";
import { LoadingFade } from "@/components/transitions/LoadingFade";
/** Subtle navigation indicator — see LoadingFade for rationale. */
export default function Loading() {
return (
<div className="min-h-screen bg-stone-50">
<div className="mx-auto max-w-4xl px-6 py-20">
{/* Hero skeleton */}
<div className="mb-20 animate-pulse text-center">
<div className="h-3 w-24 rounded bg-stone-200 mx-auto mb-5" />
<div className="h-20 w-full max-w-xl mx-auto rounded-lg bg-stone-200 mb-5" />
<div className="h-8 w-full max-w-2xl mx-auto rounded bg-stone-200" />
</div>
{/* Content sections skeleton */}
<div className="space-y-20">
{[0, 1, 2].map((i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.1 }}
className="grid md:grid-cols-2 gap-12 items-center"
>
<div className="animate-pulse space-y-4">
<div className="h-4 w-20 rounded bg-stone-200" />
<div className="h-8 w-3/4 rounded bg-stone-200" />
<div className="h-4 w-full rounded bg-stone-100" />
<div className="h-4 w-full rounded bg-stone-100" />
<div className="h-4 w-2/3 rounded bg-stone-100" />
</div>
<div className="h-64 rounded-3xl bg-stone-200" />
</motion.div>
))}
</div>
</div>
</div>
);
}
return <LoadingFade />;
}
+4 -60
View File
@@ -1,62 +1,6 @@
"use client";
import { motion } from "framer-motion";
import { LoadingFade } from "@/components/transitions/LoadingFade";
/** Subtle navigation indicator — see LoadingFade for rationale. */
export default function Loading() {
return (
<div className="min-h-screen bg-stone-50">
<div className="mx-auto max-w-4xl px-6 py-20">
{/* Header skeleton */}
<div className="mb-16 animate-pulse text-center">
<div className="h-3 w-20 rounded bg-emerald-100 mx-auto mb-5" />
<div className="h-16 w-64 mx-auto rounded-lg bg-stone-200 mb-5" />
<div className="h-5 w-96 max-w-full mx-auto rounded bg-stone-200" />
</div>
{/* Contact cards skeleton */}
<div className="grid gap-6 md:grid-cols-3 mb-16">
{[0, 1, 2].map((i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.1 }}
className="rounded-3xl bg-white p-8 shadow-sm ring-1 ring-stone-200/60 text-center"
>
<div className="h-12 w-12 rounded-2xl bg-emerald-50 mx-auto mb-5" />
<div className="h-5 w-24 mx-auto rounded bg-stone-200 mb-3" />
<div className="h-4 w-full max-w-[160px] mx-auto rounded bg-stone-100" />
</motion.div>
))}
</div>
{/* Form skeleton */}
<div className="rounded-3xl bg-white p-10 shadow-sm ring-1 ring-stone-200/60">
<div className="animate-pulse space-y-6">
<div className="h-6 w-32 rounded bg-stone-200" />
<div className="h-8 w-48 rounded bg-stone-200" />
<div className="grid gap-6 md:grid-cols-2 mt-8">
<div className="space-y-2">
<div className="h-4 w-20 rounded bg-stone-100" />
<div className="h-12 rounded-xl bg-stone-100" />
</div>
<div className="space-y-2">
<div className="h-4 w-20 rounded bg-stone-100" />
<div className="h-12 rounded-xl bg-stone-100" />
</div>
</div>
<div className="space-y-2">
<div className="h-4 w-16 rounded bg-stone-100" />
<div className="h-12 rounded-xl bg-stone-100" />
</div>
<div className="space-y-2">
<div className="h-4 w-20 rounded bg-stone-100" />
<div className="h-32 rounded-xl bg-stone-100" />
</div>
<div className="h-14 w-40 rounded-xl bg-emerald-100 mt-8" />
</div>
</div>
</div>
</div>
);
}
return <LoadingFade />;
}
+4 -39
View File
@@ -1,41 +1,6 @@
"use client";
import { motion } from "framer-motion";
import { LoadingFade } from "@/components/transitions/LoadingFade";
/** Subtle navigation indicator — see LoadingFade for rationale. */
export default function Loading() {
return (
<div className="min-h-screen bg-stone-50">
<div className="mx-auto max-w-3xl px-6 py-20">
{/* Header skeleton */}
<div className="mb-14 text-center animate-pulse">
<div className="h-3 w-16 rounded bg-emerald-100 mx-auto mb-4" />
<div className="h-16 w-48 mx-auto rounded-lg bg-stone-200 mb-5" />
<div className="h-5 w-96 max-w-full mx-auto rounded bg-stone-200" />
</div>
{/* Search skeleton */}
<div className="mb-12">
<div className="h-14 rounded-2xl bg-white shadow-sm ring-1 ring-stone-200/60" />
</div>
{/* FAQ items skeleton */}
<div className="space-y-4">
{[0, 1, 2, 3, 4].map((i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.05 }}
className="rounded-2xl bg-white shadow-sm ring-1 ring-stone-200/60 overflow-hidden"
>
<div className="flex items-center justify-between px-6 py-5">
<div className="h-5 w-3/4 rounded bg-stone-200" />
<div className="h-7 w-7 rounded-full bg-stone-100" />
</div>
</motion.div>
))}
</div>
</div>
</div>
);
}
return <LoadingFade />;
}
+7 -1
View File
@@ -1,4 +1,5 @@
import type { Metadata, Viewport } from "next";
import { SmoothViewTransition } from "@/components/transitions/SmoothViewTransition";
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com";
@@ -107,7 +108,12 @@ export default function TuxedoLayout({ children }: { children: React.ReactNode }
<div className="fixed inset-0 pointer-events-none">
<div className="absolute top-0 right-0 w-[500px] h-[500px] bg-emerald-500/10 rounded-full blur-[120px]" />
</div>
<div className="relative">{children}</div>
{/* The storefront backdrop and ambient orbs are persistent. The
page body itself crossfades between routes via the View
Transitions API. */}
<div id="page-content" className="relative outline-none">
<SmoothViewTransition>{children}</SmoothViewTransition>
</div>
</div>
);
}
+7 -76
View File
@@ -1,77 +1,8 @@
"use client";
import { LoadingFade } from "@/components/transitions/LoadingFade";
import { motion } from "framer-motion";
export default function Loading() {
return (
<main className="min-h-screen bg-stone-50">
<div className="mx-auto max-w-6xl px-6 py-16">
{/* Header skeleton */}
<div className="mb-16 animate-pulse">
<div className="h-4 w-32 rounded bg-stone-200 mb-4" />
<div className="h-16 w-80 rounded-lg bg-stone-200 mb-4" />
<div className="h-6 w-96 max-w-full rounded bg-stone-200" />
</div>
{/* Cards skeleton */}
<div className="grid gap-6 md:grid-cols-3">
{[0, 1, 2].map((i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.1 }}
className="rounded-3xl bg-white ring-1 ring-stone-200/60 overflow-hidden"
>
{/* Image skeleton */}
<div className="h-48 bg-gradient-to-br from-stone-100 to-stone-50 relative overflow-hidden">
<div className="absolute inset-0 -translate-x-full animate-[shimmer_1.5s_infinite] bg-gradient-to-r from-transparent via-stone-200/60 to-transparent" />
</div>
{/* Content skeleton */}
<div className="p-7 space-y-4">
<div className="h-6 w-3/4 rounded bg-stone-200" />
<div className="h-4 w-full rounded bg-stone-100" />
<div className="h-4 w-2/3 rounded bg-stone-100" />
<div className="pt-4 flex items-center justify-between">
<div className="h-8 w-20 rounded-lg bg-stone-200" />
<div className="h-11 w-28 rounded-xl bg-stone-200" />
</div>
</div>
</motion.div>
))}
</div>
{/* Stops section skeleton */}
<div className="mt-20">
<div className="animate-pulse mb-10">
<div className="h-3 w-24 rounded bg-stone-200 mb-4" />
<div className="h-10 w-64 rounded-lg bg-stone-200 mb-3" />
<div className="h-4 w-48 rounded bg-stone-200" />
</div>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{[0, 1, 2, 3, 4, 5].map((i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.05 }}
className="rounded-3xl bg-white p-7 ring-1 ring-stone-200/60"
>
<div className="flex items-start gap-4 mb-5">
<div className="h-10 w-10 rounded-xl bg-stone-100" />
<div className="flex-1">
<div className="h-8 w-32 rounded bg-stone-200 mb-2" />
<div className="h-4 w-full rounded bg-stone-100" />
</div>
</div>
<div className="flex gap-2 pl-[2.75rem]">
<div className="h-4 w-28 rounded bg-stone-100" />
</div>
</motion.div>
))}
</div>
</div>
</div>
</main>
);
}
/** Tuxedo storefront loading — the emerald backdrop and grain
* texture persist in the layout, so we only need to signal that
* navigation is in flight. */
export default function TuxedoLoading() {
return <LoadingFade />;
}
+4 -68
View File
@@ -1,70 +1,6 @@
"use client";
import { motion } from "framer-motion";
import { LoadingFade } from "@/components/transitions/LoadingFade";
/** Subtle navigation indicator — see LoadingFade for rationale. */
export default function Loading() {
return (
<div className="min-h-screen bg-stone-50">
<div className="mx-auto max-w-5xl px-6 py-20">
{/* Back navigation skeleton */}
<div className="mb-10 animate-pulse">
<div className="h-5 w-32 rounded bg-stone-200" />
</div>
{/* Stop header skeleton */}
<div className="mb-12 animate-pulse rounded-3xl bg-white p-8 shadow-sm ring-1 ring-stone-200/60">
<div className="h-3 w-32 rounded bg-emerald-100 mb-4" />
<div className="h-16 w-80 rounded-lg bg-stone-200 mb-4" />
<div className="h-5 w-full max-w-md rounded bg-stone-200 mb-4" />
<div className="h-px w-12 bg-emerald-600" />
</div>
{/* Stop info skeleton */}
<div className="mb-14 rounded-3xl bg-white p-8 shadow-sm ring-1 ring-stone-200/60">
<div className="grid gap-4 md:grid-cols-3">
{[0, 1, 2].map((i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.1 }}
className="flex items-start gap-4 py-4 px-5 rounded-2xl bg-stone-50"
>
<div className="h-10 w-10 rounded-xl bg-emerald-50" />
<div className="flex-1">
<div className="h-3 w-12 rounded bg-stone-200 mb-2" />
<div className="h-5 w-24 rounded bg-stone-200" />
</div>
</motion.div>
))}
</div>
</div>
{/* Products section skeleton */}
<div className="animate-pulse">
<div className="h-4 w-20 rounded bg-emerald-100 mb-4" />
<div className="h-10 w-48 rounded bg-stone-200 mb-4" />
<div className="h-4 w-64 rounded bg-stone-200 mb-10" />
<div className="grid gap-8 md:grid-cols-3">
{[0, 1, 2].map((i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.1 }}
className="rounded-3xl bg-white overflow-hidden shadow-lg"
>
<div className="h-48 bg-gradient-to-br from-stone-100 to-stone-50" />
<div className="p-6 space-y-3">
<div className="h-6 w-3/4 rounded bg-stone-200" />
<div className="h-4 w-full rounded bg-stone-100" />
<div className="h-4 w-2/3 rounded bg-stone-100" />
</div>
</motion.div>
))}
</div>
</div>
</div>
</div>
);
}
return <LoadingFade />;
}
+5 -16
View File
@@ -1,17 +1,6 @@
import { LoadingFade } from "@/components/transitions/LoadingFade";
/** Subtle navigation indicator — see LoadingFade for rationale. */
export default function Loading() {
return (
<main className="min-h-screen bg-slate-100 px-4 py-6">
<div className="mx-auto max-w-lg">
<div className="animate-pulse space-y-5">
<div className="h-16 rounded-xl bg-slate-800" />
<div className="h-8 w-48 rounded bg-slate-300" />
<div className="h-12 w-full rounded-xl bg-white" />
<div className="h-12 w-full rounded-xl bg-white" />
<div className="h-12 w-full rounded-xl bg-white" />
<div className="h-12 w-full rounded-xl bg-white" />
<div className="h-10 w-full rounded-xl bg-slate-300" />
</div>
</div>
</main>
);
}
return <LoadingFade />;
}
@@ -0,0 +1,39 @@
"use client";
import { ViewTransition } from "react";
/**
* Subtle loading placeholder used in place of skeleton `loading.tsx`
* files. Instead of a flashy "skeleton of the page you're waiting for",
* this renders a single thin animated bar that crossfades into the
* real content via the View Transitions API. The previous page stays
* visible underneath until the new page is ready, so the user never
* feels the gap.
*
* Drop this in a `loading.tsx` file:
*
* // app/admin/loading.tsx
* import { LoadingFade } from "@/components/transitions/LoadingFade";
* export default LoadingFade;
*/
export function LoadingFade() {
return (
<ViewTransition name="page-content" update="default">
<div
className="fixed top-0 left-0 right-0 z-50 h-0.5 overflow-hidden"
aria-hidden="true"
>
<div
className="h-full w-1/3 rounded-full"
style={{
background:
"linear-gradient(90deg, transparent 0%, #14532D 50%, transparent 100%)",
animation: "transition-shimmer 1.4s ease-in-out infinite",
}}
/>
</div>
</ViewTransition>
);
}
export default LoadingFade;
@@ -0,0 +1,40 @@
"use client";
import { usePathname } from "next/navigation";
import { useEffect } from "react";
/**
* Announce route changes to assistive tech. Without this, screen-reader
* users see no signal that navigation completed because the view
* transition is intentionally seamless for sighted users.
*
* The announcer also restores the keyboard focus to the top of the new
* page after navigation, so tab order picks up where the user expects
* (not stuck on the link they just clicked).
*/
export function RouteAnnouncer() {
const pathname = usePathname();
useEffect(() => {
// Reset focus to the page wrapper so keyboard nav continues from
// the top, not from the link in the sidebar.
const target = document.getElementById("page-content") ?? document.body;
if (target && "tabIndex" in target) {
(target as HTMLElement).tabIndex = -1;
(target as HTMLElement).focus({ preventScroll: true });
}
}, [pathname]);
return (
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{/* Text is intentionally empty — the live region is the
announcement channel; the actual title is set per page via
the <h1> rendered inside the layout. */}
</div>
);
}
@@ -0,0 +1,49 @@
"use client";
import { ViewTransition } from "react";
import type { ReactNode } from "react";
/**
* Smooth view-transition wrapper for any page-section content.
*
* Drop this around the main content area of a route (or around an
* individual <Suspense> chunk) to opt that subtree into a soft
* crossfade on navigation. The browser's native View Transitions API
* handles the actual animation — when the browser doesn't support it,
* the children just swap, no error.
*
* `name` groups elements so the browser can morph the same element
* across pages. Use the same name on the source and destination (e.g.
* the page header) for shared-element morphing. Use the default
* `"page-content"` for plain crossfades.
*
* The default `update="default"` plus a CSS `::view-transition-old/new`
* rule (see globals.css) gives us a 220ms crossfade with a tiny
* downward shift on the incoming page — fast enough to feel like a
* single app, slow enough to soften the cut between routes.
*
* Usage:
* <main>
* <SmoothViewTransition>{children}</SmoothViewTransition>
* </main>
*/
export function SmoothViewTransition({
children,
name = "page-content",
}: {
children: ReactNode;
name?: string;
}) {
return (
<ViewTransition
name={name}
// `default` runs a CSS-driven crossfade using the
// ::view-transition-* pseudo-elements defined in globals.css.
// Other options: "none" (instant cut), or a custom string your
// CSS can match.
update="default"
>
{children}
</ViewTransition>
);
}