Skip to content

Commit e4fd6a8

Browse files
committed
Merge feature/rail-and-acquisition: desktop rail + cart acquisition banner
2 parents dc5a1cc + 018658f commit e4fd6a8

6 files changed

Lines changed: 274 additions & 6 deletions

File tree

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# Desktop nav rail + cart-page acquisition banner — Design
2+
3+
Date: 2026-05-25
4+
Status: Approved for implementation
5+
6+
Two independent frontend-only features, disjoint files → parallelizable.
7+
8+
---
9+
10+
## Feature 1 — Fixed desktop nav rail (rail BELOW the top bar; desktop only)
11+
12+
Goal: on desktop (`lg+`), logged-in users get a permanently-visible left nav rail so navigating between primary pages is **one click** (not open-drawer-then-click). **Mobile/tablet (`< lg`) is unchanged** (top bar + drawer, phone bottom tab bar). The existing top bar is kept (chosen "rail below top bar" approach — do NOT remove or restructure the top bar or the page layouts).
13+
14+
### Components
15+
- **`web/components/desktop-rail.tsx`** (`"use client"`), props `{ user: User }`:
16+
- `hidden lg:flex flex-col fixed left-0 top-[57px] bottom-0 w-56 z-30 border-r border-rule bg-cream overflow-y-auto py-4 px-3`. (`top-[57px]` ≈ the top bar's height so the rail sits just under it; fine-tune to match the rendered NavBar height.)
17+
- Vertical nav items (icon + label, `min-h-[44px]`, rounded, active = `bg-paper text-accent font-medium` via `usePathname`, inactive = `text-muted hover:bg-paper hover:text-ink`): **Dashboard** `/dashboard` (Home), **Discover** `/discover` (Compass), **Your feed** `/dashboard/following` (Rss), **Your profile** `/u/{handle}` (User; omit if no handle), **New cart** `/add` (Plus). Then, only when `user.isAdmin`: a divider + **Admin** `/dashboard/admin` (ShieldCheck).
18+
- Reuse the drawer's link set/idiom (`app-sidebar.tsx`) for consistency; account/sign-out stay in the top-bar avatar drawer (the rail is nav-only).
19+
- **`web/components/app-frame.tsx`** (`"use client"`), props `{ user: User | null; children }`:
20+
- `const showShell = !!user && isAppRoute(pathname)` where `isAppRoute(p) = p === "/discover" || p === "/add" || p.startsWith("/dashboard") || p.startsWith("/u/")`.
21+
- Renders: `<div className={showShell ? "lg:pl-56" : undefined}>{children}</div>`, then `{showShell && user && <DesktopRail user={user} />}`, then `<MobileTabBar user={user} />`.
22+
- (The `lg:pl-56` shifts page content right of the rail on desktop only; below `lg` no padding. The top bar lives inside `children` and reflows within the padded width — acceptable; do not try to special-case it.)
23+
24+
### Wiring
25+
- `web/app/layout.tsx`: where it currently renders `<MobileTabBar user={...} />` after `{children}`, instead wrap: `<AppFrame user={user}>{children}</AppFrame>` (AppFrame now renders MobileTabBar internally — remove the standalone MobileTabBar render/import from the root layout).
26+
- **No other files change** for Feature 1 (top bar, `(public)`/`dashboard` layouts, drawer all stay).
27+
28+
### Mobile/tablet
29+
`< lg`: rail is `hidden` (not rendered visually), no `lg:pl-56` effect → current mobile UX (top bar + drawer + phone bottom bar) is fully preserved.
30+
31+
### Files (1)
32+
Create: `web/components/desktop-rail.tsx`, `web/components/app-frame.tsx`. Modify: `web/app/layout.tsx`.
33+
34+
---
35+
36+
## Feature 2 — "Create your own shoplit" banner on public cart pages
37+
38+
Goal: convert viewers of a shared cart (`/c/{slug}`, the `(share)` group) into shoplit signups with a **bold, on-brand** call-to-action.
39+
40+
### Component
41+
- **`web/components/create-your-own-banner.tsx`** (server or simple client; no heavy state): a prominent, on-brand CTA card — `rounded-2xl bg-ink text-cream` (or an accent gradient) section with:
42+
- the shoplit logo/wordmark,
43+
- a punchy headline, e.g. **"Love this? Build your own shoplit."**,
44+
- one line of subtext (e.g., "Turn the products you love into one beautiful, shareable link — free."),
45+
- a primary button **"Create your shoplit — free"**`/login`, and a quieter secondary link **"Discover creators"**`/discover`.
46+
- Tasteful, generous padding; works at 360px (stacks), `≥44px` tap targets.
47+
48+
### Placement
49+
- In `web/app/(share)/c/[slug]/page.tsx`: render `<CreateYourOwnBanner />` **after the products section, before the footer**. Leave the hero, products, `StickyShareBar`, and footer intact (don't disturb the immersive layout or the sticky share bar's spacing — keep enough bottom padding so the banner isn't hidden behind the sticky bar).
50+
51+
### Files (2)
52+
Create: `web/components/create-your-own-banner.tsx`. Modify: `web/app/(share)/c/[slug]/page.tsx`.
53+
54+
---
55+
56+
## Verification (both)
57+
`pnpm exec tsc --noEmit`, `pnpm lint`, `pnpm test`, `pnpm build` (each agent builds its own tree). Integrator: deploy `shoplit-web` (frontend-only); desktop visual check that the rail shows on `/dashboard`,`/discover`,`/u/*` (lg+) with content cleared of it and one-click nav; mobile unchanged; cart page shows the banner.
58+
59+
## Out of scope
60+
Collapsible/resizable rail; removing the top bar on desktop; exit-intent popups; A/B testing the banner copy.

web/app/(share)/c/[slug]/page.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { ProductCard } from "@/components/product-card";
77
import { RevealOnScroll } from "@/components/reveal-on-scroll";
88
import { StickyShareBar } from "@/components/sticky-share-bar";
99
import { CartCover } from "@/components/cart-cover";
10+
import { CreateYourOwnBanner } from "@/components/create-your-own-banner";
1011
import { linkify } from "@/lib/linkify";
1112

1213
export const dynamic = "force-dynamic";
@@ -78,6 +79,9 @@ export default async function PublicCartPage({ params }: { params: { slug: strin
7879
)}
7980
</section>
8081

82+
{/* ACQUISITION CTA — convert viewers into shoplit signups. */}
83+
<CreateYourOwnBanner />
84+
8185
{/* FOOTER (extra bottom padding so it isn't covered by the sticky share bar) */}
8286
<footer className="border-t border-rule mt-16 pb-24">
8387
<div className="mx-auto max-w-4xl px-4 sm:px-6 py-10 text-sm text-muted text-center space-y-3">

web/app/layout.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { cookies } from "next/headers";
44
import "./globals.css";
55
import { Toaster } from "@/components/ui/sonner";
66
import { SwRegister } from "@/components/sw-register";
7-
import { MobileTabBar } from "@/components/mobile-tab-bar";
7+
import { AppFrame } from "@/components/app-frame";
88
import { getCurrentUser } from "@/lib/api-client";
99

1010
const fraunces = Fraunces({
@@ -46,16 +46,16 @@ export const viewport: Viewport = {
4646
};
4747

4848
export default async function RootLayout({ children }: { children: React.ReactNode }) {
49-
// Resolve the viewer once, globally, so the mobile tab bar can render across
50-
// the primary surfaces (Carts/Discover/Feed/Add). Fail open: a logged-out
51-
// visitor (or any lookup error) yields null so public pages never error.
49+
// Resolve the viewer once, globally, so AppFrame's persistent nav surfaces
50+
// (desktop rail + mobile tab bar) can render across the primary surfaces
51+
// (Carts/Discover/Feed/Add). Fail open: a logged-out visitor (or any lookup
52+
// error) yields null so public pages never error.
5253
const user = await getCurrentUser({ cookie: cookies().toString() }).catch(() => null);
5354

5455
return (
5556
<html lang="en" className={`${fraunces.variable} ${inter.variable} ${notoDeva.variable} ${jetbrains.variable}`}>
5657
<body>
57-
{children}
58-
<MobileTabBar user={user} />
58+
<AppFrame user={user}>{children}</AppFrame>
5959
<Toaster position="bottom-center" richColors />
6060
<SwRegister />
6161
</body>

web/components/app-frame.tsx

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"use client";
2+
3+
import { usePathname } from "next/navigation";
4+
import { DesktopRail } from "@/components/desktop-rail";
5+
import { MobileTabBar } from "@/components/mobile-tab-bar";
6+
import type { User } from "@/lib/types";
7+
8+
// Global app chrome wrapper, rendered once from the root layout. It owns the
9+
// two persistent nav surfaces — the desktop rail (lg+) and the phone bottom tab
10+
// bar (< sm) — so they live across the primary signed-in surfaces instead of
11+
// per-route. `children` (incl. the top NavBar) render inside the rail-padded
12+
// column.
13+
//
14+
// `showShell` mirrors MobileTabBar's isPrimaryRoute idiom: only signed-in
15+
// viewers on a primary app route get the rail + content padding. On /c/[slug],
16+
// /login, the marketing home, etc. there's no rail and no padding. The
17+
// lg:pl-56 only applies at lg+, so below lg there is no padding effect and the
18+
// rail is `hidden` — mobile/tablet UX is fully preserved.
19+
function isAppRoute(pathname: string): boolean {
20+
return (
21+
pathname === "/discover" ||
22+
pathname === "/add" ||
23+
pathname.startsWith("/dashboard") ||
24+
pathname.startsWith("/u/")
25+
);
26+
}
27+
28+
export function AppFrame({
29+
user,
30+
children,
31+
}: {
32+
user: User | null;
33+
children: React.ReactNode;
34+
}) {
35+
const pathname = usePathname();
36+
const showShell = !!user && isAppRoute(pathname);
37+
38+
return (
39+
<>
40+
<div className={showShell ? "lg:pl-56" : undefined}>{children}</div>
41+
{showShell && user && <DesktopRail user={user} />}
42+
<MobileTabBar user={user} />
43+
</>
44+
);
45+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import Link from "next/link";
2+
import { ArrowRight } from "lucide-react";
3+
import { LogoMark } from "@/components/logo";
4+
5+
// Acquisition CTA shown at the bottom of every public cart page (`/c/{slug}`),
6+
// after the products and before the footer. Converts viewers of a shared cart
7+
// into shoplit signups. On-brand: dark ink card with cream text and an accent
8+
// glow that picks up the cart's per-page --accent (set inline on the page
9+
// wrapper). Stacks cleanly at 360px; all tap targets are >=44px.
10+
export function CreateYourOwnBanner() {
11+
return (
12+
<section className="mx-auto max-w-4xl px-4 sm:px-6">
13+
<div className="relative overflow-hidden rounded-2xl bg-ink text-cream px-6 py-10 sm:px-12 sm:py-14">
14+
{/* Soft accent glow so the card feels tied to this creator's cart. */}
15+
<div
16+
aria-hidden
17+
className="pointer-events-none absolute -right-16 -top-20 h-56 w-56 rounded-full opacity-30 blur-3xl"
18+
style={{ background: "var(--accent)" }}
19+
/>
20+
<div className="relative flex flex-col items-start gap-5 text-left">
21+
{/* Wordmark: standalone mark (accent fill) + serif name in cream. */}
22+
<div className="flex items-center gap-2">
23+
<LogoMark className="h-9 w-9 shrink-0" />
24+
<span className="font-serif text-2xl leading-none tracking-tight text-cream">
25+
shoplit
26+
</span>
27+
</div>
28+
29+
<h2 className="font-serif text-3xl sm:text-4xl leading-[1.05] text-cream max-w-xl">
30+
Love this? Build your own shoplit.
31+
</h2>
32+
33+
<p className="text-sm sm:text-base text-cream/75 max-w-md leading-relaxed">
34+
Turn the products you love into one beautiful, shareable link — free.
35+
</p>
36+
37+
<div className="mt-2 flex flex-col items-stretch gap-3 sm:flex-row sm:items-center w-full sm:w-auto">
38+
<Link
39+
href="/login"
40+
className="inline-flex min-h-[44px] items-center justify-center gap-2 rounded-full bg-cream px-6 text-sm font-medium text-ink transition-opacity hover:opacity-90"
41+
>
42+
Create your shoplit — free
43+
<ArrowRight size={16} aria-hidden />
44+
</Link>
45+
<Link
46+
href="/discover"
47+
className="inline-flex min-h-[44px] items-center justify-center rounded-full px-6 text-sm font-medium text-cream/80 transition-colors hover:text-cream"
48+
>
49+
Discover creators
50+
</Link>
51+
</div>
52+
</div>
53+
</div>
54+
</section>
55+
);
56+
}

web/components/desktop-rail.tsx

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
"use client";
2+
3+
import Link from "next/link";
4+
import { usePathname } from "next/navigation";
5+
import {
6+
Compass,
7+
Home,
8+
Plus,
9+
Rss,
10+
ShieldCheck,
11+
User as UserIcon,
12+
} from "lucide-react";
13+
import type { User } from "@/lib/types";
14+
15+
// Fixed desktop nav rail. Rendered (via AppFrame) only for signed-in viewers on
16+
// the primary app routes, and only at lg+ — it's `hidden` below lg so the
17+
// mobile/tablet UX (top bar + drawer + phone bottom bar) is untouched. It sits
18+
// just below the sticky top NavBar (top-[57px] ≈ NavBar height) and gives
19+
// one-click navigation between the primary surfaces. Nav-only by design:
20+
// account / sign-out stay in the top-bar avatar drawer (AppSidebar).
21+
export function DesktopRail({ user }: { user: User }) {
22+
const pathname = usePathname();
23+
const profileHref = user.handle ? `/u/${user.handle}` : null;
24+
25+
return (
26+
<nav
27+
aria-label="Primary navigation"
28+
className="hidden lg:flex flex-col fixed left-0 top-[57px] bottom-0 w-56 z-30 border-r border-rule bg-cream overflow-y-auto py-4 px-3"
29+
>
30+
<RailLink
31+
href="/dashboard"
32+
label="Dashboard"
33+
icon={<Home size={18} />}
34+
active={pathname === "/dashboard"}
35+
/>
36+
<RailLink
37+
href="/discover"
38+
label="Discover"
39+
icon={<Compass size={18} />}
40+
active={pathname === "/discover"}
41+
/>
42+
<RailLink
43+
href="/dashboard/following"
44+
label="Your feed"
45+
icon={<Rss size={18} />}
46+
active={pathname === "/dashboard/following"}
47+
/>
48+
{profileHref && (
49+
<RailLink
50+
href={profileHref}
51+
label="Your profile"
52+
icon={<UserIcon size={18} />}
53+
active={pathname === profileHref}
54+
/>
55+
)}
56+
<RailLink
57+
href="/add"
58+
label="New cart"
59+
icon={<Plus size={18} />}
60+
active={pathname === "/add"}
61+
/>
62+
63+
{user.isAdmin && (
64+
<>
65+
<div className="my-2 h-px bg-rule" aria-hidden="true" />
66+
<RailLink
67+
href="/dashboard/admin"
68+
label="Admin"
69+
icon={<ShieldCheck size={18} />}
70+
active={pathname.startsWith("/dashboard/admin")}
71+
/>
72+
</>
73+
)}
74+
</nav>
75+
);
76+
}
77+
78+
function RailLink({
79+
href,
80+
label,
81+
icon,
82+
active,
83+
}: {
84+
href: string;
85+
label: string;
86+
icon: React.ReactNode;
87+
active: boolean;
88+
}) {
89+
return (
90+
<Link
91+
href={href}
92+
aria-current={active ? "page" : undefined}
93+
className={`flex items-center gap-3 min-h-[44px] px-3 rounded-md text-sm transition-colors ${
94+
active
95+
? "bg-paper text-accent font-medium"
96+
: "text-muted hover:bg-paper hover:text-ink"
97+
}`}
98+
>
99+
{icon}
100+
{label}
101+
</Link>
102+
);
103+
}

0 commit comments

Comments
 (0)