Skip to content

Commit 8bf38ca

Browse files
committed
Merge feature/cart-ux-redesign: mobile-first cart UX
Editor → products-first single column with collapsible sections + tap-to-preview full-screen sheet (desktop sticky preview). Dashboard cards → tappable Share + views/clicks (fixes hover-only copy). Public cart → product-count line + tighter ProductCard. Frontend-only.
2 parents 898bfc4 + 129d5e5 commit 8bf38ca

6 files changed

Lines changed: 222 additions & 146 deletions

File tree

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,15 @@ export default async function PublicCartPage({ params }: { params: { slug: strin
5555
</div>
5656
</section>
5757

58+
{/* PRODUCT COUNT (identity already shown in the hero) */}
59+
<div className="mx-auto max-w-4xl px-4 sm:px-6 pt-6">
60+
<p className="text-sm text-muted">
61+
{cart.products.length} {cart.products.length === 1 ? "product" : "products"}
62+
</p>
63+
</div>
64+
5865
{/* PRODUCTS */}
59-
<section className="mx-auto max-w-4xl px-4 sm:px-6 py-12">
66+
<section className="mx-auto max-w-4xl px-4 sm:px-6 pt-8 pb-12">
6067
{cart.products.length === 0 ? (
6168
<p className="text-center text-muted py-16">This cart is still being curated. Check back soon.</p>
6269
) : (

web/app/dashboard/carts/[id]/editor.tsx

Lines changed: 95 additions & 121 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { useState, useRef, useEffect } from "react";
44
import Image from "next/image";
55
import Link from "next/link";
66
import { useRouter } from "next/navigation";
7-
import { ArrowUp, ArrowDown, Check, ExternalLink, GripVertical, Pencil, Share2, Trash2, X } from "lucide-react";
7+
import { ArrowUp, ArrowDown, Check, ExternalLink, Eye, GripVertical, Pencil, Share2, Trash2, X } from "lucide-react";
88
import { toast } from "sonner";
99
import {
1010
DndContext,
@@ -26,12 +26,13 @@ import { CSS } from "@dnd-kit/utilities";
2626
import { Button } from "@/components/ui/button";
2727
import { ColorPicker } from "@/components/color-picker";
2828
import { PhoneFrame } from "@/components/phone-frame";
29-
import { ProductCard } from "@/components/product-card";
3029
import { PasteUrlPreview } from "@/components/paste-url-preview";
3130
import { ShareSheet } from "@/components/share-sheet";
3231
import { CartCover } from "@/components/cart-cover";
3332
import { CoverPicker } from "@/components/cover-picker";
3433
import { ImageUploadButton } from "@/components/image-upload-button";
34+
import { EditorSection } from "@/components/editor-section";
35+
import { CartPreviewSheet } from "@/components/cart-preview-sheet";
3536
import {
3637
Dialog,
3738
DialogClose,
@@ -54,6 +55,7 @@ import type { Cart, Product } from "@/lib/types";
5455
export function CartEditor({ initialCart }: { initialCart: Cart }) {
5556
const router = useRouter();
5657
const [cart, setCart] = useState<Cart>(initialCart);
58+
const [previewOpen, setPreviewOpen] = useState(false);
5759
const [deleting, setDeleting] = useState(false);
5860

5961
// Debounced save: local state is the source of truth while editing. We
@@ -179,92 +181,62 @@ export function CartEditor({ initialCart }: { initialCart: Cart }) {
179181
};
180182

181183
return (
182-
<div className="mx-auto max-w-7xl px-4 sm:px-6 py-8">
184+
<div className="mx-auto max-w-7xl px-4 sm:px-6 py-6 pb-24">
183185
{/* HEADER */}
184-
<div className="flex items-center justify-between mb-6 gap-4">
185-
<div className="min-w-0">
186-
<Link href="/dashboard" className="text-sm text-muted hover:text-ink">← Back to carts</Link>
186+
<div className="flex items-center justify-between gap-3 mb-4">
187+
<Link href="/dashboard" className="text-sm text-muted hover:text-ink shrink-0">← Carts</Link>
188+
<div className="flex items-center gap-2">
189+
<button
190+
type="button"
191+
onClick={() => setPreviewOpen(true)}
192+
className="lg:hidden inline-flex items-center gap-1.5 rounded-full border border-ink px-4 py-2 text-sm font-medium hover:bg-paper transition-colors"
193+
>
194+
<Eye size={15} /> Preview
195+
</button>
196+
<ShareSheet slug={cart.slug}>
197+
<Button variant="default"><Share2 size={16} /> Share</Button>
198+
</ShareSheet>
187199
</div>
188-
<ShareSheet slug={cart.slug}>
189-
<Button variant="default"><Share2 size={16} /> Share</Button>
190-
</ShareSheet>
191200
</div>
192201

193-
<div className="grid lg:grid-cols-[1.4fr_1fr] gap-10">
194-
{/* LEFT — EDITOR */}
195-
<div className="space-y-8">
196-
{/* Cover image */}
197-
<section>
198-
<h2 className="font-serif text-2xl mb-3">Cover image</h2>
199-
<div className="relative aspect-[16/10] rounded-xl overflow-hidden border border-rule bg-paper mb-4">
200-
<CartCover coverImageUrl={cart.coverImageUrl} accentHex={cart.accentHex} title={cart.title} />
201-
</div>
202-
<CoverPicker
203-
value={cart.coverImageUrl}
204-
accentHex={cart.accentHex}
205-
title={cart.title}
206-
onChange={(url) => patch({ coverImageUrl: url })}
207-
/>
208-
</section>
209-
210-
{/* Title — styled as a heading; subtle hover indicates editability */}
211-
<section className="space-y-4">
212-
<div className="group/title relative">
213-
<span className="sr-only">Cart title</span>
214-
<input
215-
type="text"
216-
value={cart.title}
217-
onChange={(e) => patch({ title: e.target.value })}
218-
placeholder="Untitled cart"
219-
aria-label="Cart title"
220-
className="w-full bg-transparent font-serif text-3xl sm:text-4xl tracking-tight px-0 py-2 border-0 border-b-2 border-transparent group-hover/title:border-rule focus:border-accent focus:outline-none transition-colors placeholder:text-muted/60"
221-
/>
222-
<p className="text-[11px] uppercase tracking-widest text-muted/70 opacity-0 group-hover/title:opacity-100 group-focus-within/title:opacity-100 transition-opacity mt-1">
223-
Click to edit · changes save as you type
224-
</p>
225-
</div>
226-
227-
<div>
228-
<label className="block text-sm font-medium mb-2 text-muted">Bio</label>
229-
<textarea
230-
value={cart.bio ?? ""}
231-
onChange={(e) => patch({ bio: e.target.value })}
232-
rows={3}
233-
placeholder="Tell your followers about this cart"
234-
className="w-full rounded-md border border-rule bg-cream px-3 py-2 leading-relaxed focus:outline-none focus:ring-2 focus:ring-accent"
235-
/>
236-
</div>
237-
</section>
238-
239-
{/* Accent color */}
240-
<section>
241-
<h2 className="font-serif text-2xl mb-3">Accent color</h2>
242-
<ColorPicker value={cart.accentHex} onChange={(hex) => patch({ accentHex: hex })} />
243-
</section>
202+
{/* Editable title */}
203+
<div className="group/title relative mb-6">
204+
<input
205+
type="text"
206+
value={cart.title}
207+
onChange={(e) => patch({ title: e.target.value })}
208+
placeholder="Untitled cart"
209+
aria-label="Cart title"
210+
className="w-full bg-transparent font-serif text-3xl sm:text-4xl tracking-tight px-0 py-1 border-0 border-b-2 border-transparent group-hover/title:border-rule focus:border-accent focus:outline-none transition-colors placeholder:text-muted/60"
211+
/>
212+
</div>
244213

245-
{/* Add product */}
214+
<div className="grid lg:grid-cols-[1.4fr_1fr] gap-10">
215+
{/* LEFT — editor */}
216+
<div className="space-y-6">
217+
{/* PRODUCTS — the core task, first */}
246218
<section>
247219
<h2 className="font-serif text-2xl mb-4">Add a product</h2>
248220
<PasteUrlPreview onResolved={addProduct} />
249-
</section>
250221

251-
{/* Products */}
252-
<section>
253-
<h2 className="font-serif text-2xl mb-3">Products ({cart.products.length})</h2>
222+
<div className="flex items-center justify-between mt-6 mb-3">
223+
<h2 className="font-serif text-2xl">Products ({cart.products.length})</h2>
224+
<Link
225+
href={`/c/${cart.slug}`}
226+
target="_blank"
227+
rel="noopener"
228+
className="inline-flex items-center gap-1 text-sm text-ink underline-offset-4 hover:underline"
229+
>
230+
View live <ExternalLink size={14} />
231+
</Link>
232+
</div>
254233

255234
{cart.products.length === 0 && (
256235
<p className="text-sm text-muted">No products yet. Paste a link above to add your first product.</p>
257236
)}
258237

259-
<DndContext
260-
sensors={sensors}
261-
collisionDetection={closestCenter}
262-
onDragEnd={handleDragEnd}
263-
>
264-
<SortableContext
265-
items={cart.products.map((p) => p.id)}
266-
strategy={verticalListSortingStrategy}
267-
>
238+
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
239+
<SortableContext items={cart.products.map((p) => p.id)} strategy={verticalListSortingStrategy}>
268240
<ul className="space-y-2">
269241
{cart.products.map((p, i) => (
270242
<SortableProductRow
@@ -283,20 +255,38 @@ export function CartEditor({ initialCart }: { initialCart: Cart }) {
283255
</DndContext>
284256
</section>
285257

286-
<div className="text-sm">
287-
<Link
288-
href={`/c/${cart.slug}`}
289-
target="_blank"
290-
rel="noopener"
291-
className="inline-flex items-center gap-1 text-ink underline-offset-4 hover:underline"
292-
>
293-
View live <ExternalLink size={14} />
294-
</Link>
295-
</div>
258+
{/* COVER & LOOK */}
259+
<EditorSection title="Cover & look" defaultOpen={!cart.coverImageUrl}>
260+
<div className="relative aspect-[16/10] rounded-xl overflow-hidden border border-rule bg-paper mb-4">
261+
<CartCover coverImageUrl={cart.coverImageUrl} accentHex={cart.accentHex} title={cart.title} />
262+
</div>
263+
<CoverPicker
264+
value={cart.coverImageUrl}
265+
accentHex={cart.accentHex}
266+
title={cart.title}
267+
onChange={(url) => patch({ coverImageUrl: url })}
268+
/>
269+
<div className="pt-2">
270+
<p className="text-sm font-medium mb-2">Accent color</p>
271+
<ColorPicker value={cart.accentHex} onChange={(hex) => patch({ accentHex: hex })} />
272+
</div>
273+
</EditorSection>
274+
275+
{/* CART DETAILS */}
276+
<EditorSection title="Cart details">
277+
<label className="block text-sm font-medium mb-2 text-muted">Bio</label>
278+
<textarea
279+
value={cart.bio ?? ""}
280+
onChange={(e) => patch({ bio: e.target.value })}
281+
rows={3}
282+
placeholder="Tell your followers about this cart"
283+
className="w-full rounded-md border border-rule bg-cream px-3 py-2 leading-relaxed focus:outline-none focus:ring-2 focus:ring-accent"
284+
/>
285+
</EditorSection>
296286

297-
{/* Danger zone */}
298-
<section className="border-t border-rule pt-6 mt-8">
299-
<h2 className="text-sm font-medium mb-1">Delete this cart</h2>
287+
{/* SETTINGS / DANGER */}
288+
<EditorSection title="Settings">
289+
<h3 className="text-sm font-medium mb-1">Delete this cart</h3>
300290
<p className="text-sm text-muted mb-3">
301291
Its share link will stop working and it&apos;ll disappear from your dashboard. This can&apos;t be undone.
302292
</p>
@@ -311,16 +301,14 @@ export function CartEditor({ initialCart }: { initialCart: Cart }) {
311301
</DialogTrigger>
312302
<DialogContent>
313303
<DialogHeader>
314-
<DialogTitle>Delete {cart.title}?</DialogTitle>
304+
<DialogTitle>Delete &ldquo;{cart.title}&rdquo;?</DialogTitle>
315305
<DialogDescription>
316306
The link <span className="font-mono">/c/{cart.slug}</span> will stop working and the cart leaves your dashboard. This can&apos;t be undone.
317307
</DialogDescription>
318308
</DialogHeader>
319309
<div className="flex justify-end gap-2 mt-4">
320310
<DialogClose asChild>
321-
<button type="button" className="rounded-full px-4 py-2 text-sm text-muted hover:text-ink">
322-
Cancel
323-
</button>
311+
<button type="button" className="rounded-full px-4 py-2 text-sm text-muted hover:text-ink">Cancel</button>
324312
</DialogClose>
325313
<button
326314
type="button"
@@ -333,45 +321,31 @@ export function CartEditor({ initialCart }: { initialCart: Cart }) {
333321
</div>
334322
</DialogContent>
335323
</Dialog>
336-
</section>
324+
</EditorSection>
337325
</div>
338326

339-
{/* RIGHT — PREVIEW */}
327+
{/* RIGHT — desktop sticky preview */}
340328
<div className="hidden lg:block sticky top-24 self-start">
341329
<p className="text-sm text-muted text-center mb-3">Live preview</p>
342330
<PhoneFrame>
343-
<PreviewCartPage cart={cart} />
331+
<CartPreviewSheet cart={cart} />
344332
</PhoneFrame>
345333
</div>
346334
</div>
347-
</div>
348-
);
349-
}
350335

351-
function PreviewCartPage({ cart }: { cart: Cart }) {
352-
return (
353-
<div style={{ ["--accent" as string]: cart.accentHex } as React.CSSProperties}>
354-
<div className="relative aspect-[5/4]">
355-
<CartCover coverImageUrl={cart.coverImageUrl} accentHex={cart.accentHex} title={cart.title} />
356-
<div className="absolute inset-0 bg-ink/30" />
357-
<div className="absolute inset-0 bg-gradient-to-t from-ink via-ink/60 to-transparent" />
358-
<div className="absolute bottom-3 left-3 right-3 text-cream [text-shadow:0_1px_10px_rgba(0,0,0,0.85)]">
359-
<p className="text-xs font-medium">@{cart.ownerHandle}</p>
360-
<p className="font-serif text-xl leading-tight">{cart.title}</p>
361-
</div>
362-
</div>
363-
<div className="p-3">
364-
{cart.products.length === 0 ? (
365-
<p className="text-xs text-muted text-center py-6">No products yet.</p>
366-
) : (
367-
<div className="grid grid-cols-2 gap-2">
368-
{cart.products.slice(0, 4).map((p) => <ProductCard key={p.id} product={p} />)}
369-
</div>
370-
)}
371-
{cart.products.length > 4 && (
372-
<p className="text-center text-xs text-muted mt-3">+ {cart.products.length - 4} more on the live page</p>
373-
)}
374-
</div>
336+
{/* MOBILE full-screen preview */}
337+
<Dialog open={previewOpen} onOpenChange={setPreviewOpen}>
338+
<DialogContent className="max-w-full w-screen h-[100dvh] p-0 gap-0 rounded-none overflow-y-auto [&>button:last-child]:hidden">
339+
<DialogHeader className="sticky top-0 z-10 flex-row items-center justify-between bg-cream/95 backdrop-blur border-b border-rule px-4 py-3 space-y-0">
340+
<DialogTitle className="font-serif text-lg">Preview</DialogTitle>
341+
<DialogClose asChild>
342+
<button type="button" aria-label="Close preview" className="text-muted hover:text-ink"><X size={20} /></button>
343+
</DialogClose>
344+
</DialogHeader>
345+
<DialogDescription className="sr-only">A preview of how your cart looks to followers.</DialogDescription>
346+
<CartPreviewSheet cart={cart} fullPage />
347+
</DialogContent>
348+
</Dialog>
375349
</div>
376350
);
377351
}

web/components/cart-card.tsx

Lines changed: 27 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { useState } from "react";
44
import Link from "next/link";
5-
import { Check, Link2 } from "lucide-react";
5+
import { Check, Eye, MousePointerClick, Share2 } from "lucide-react";
66
import { toast } from "sonner";
77
import type { Cart } from "@/lib/types";
88
import { CartCover } from "@/components/cart-cover";
@@ -49,25 +49,33 @@ export function CartCard({ cart, href, showCopy = true }: CartCardProps) {
4949
sizes="(max-width: 768px) 100vw, 33vw"
5050
imageClassName="object-cover transition-transform duration-300 group-hover:scale-[1.02]"
5151
/>
52-
{showCopy && (
53-
<button
54-
type="button"
55-
onClick={handleCopy}
56-
aria-label={`Copy link to ${cart.title}`}
57-
className="absolute top-2 right-2 flex items-center gap-1.5 rounded-full bg-ink/85 text-cream px-3 py-1.5 text-xs font-medium backdrop-blur opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity hover:bg-ink"
58-
>
59-
{copied ? <Check size={12} /> : <Link2 size={12} />}
60-
{copied ? "Copied" : "Copy link"}
61-
</button>
62-
)}
6352
</div>
64-
<div className="p-5">
65-
<h3 className="font-serif text-xl mb-1 line-clamp-2">{cart.title}</h3>
66-
<p className="text-sm text-muted">
67-
{cart.products.length} {cart.products.length === 1 ? "product" : "products"}
68-
{" · "}{cart.viewsLast7d.toLocaleString()} views (7d)
69-
{" · "}{cart.clicksLast7d.toLocaleString()} clicks (7d)
70-
</p>
53+
<div className="p-4">
54+
<h3 className="font-serif text-lg mb-2 line-clamp-2">{cart.title}</h3>
55+
<div className="flex items-center justify-between gap-2">
56+
<div className="flex items-center gap-3 text-xs text-muted">
57+
<span className="inline-flex items-center gap-1">
58+
<Eye size={13} /> {cart.viewsLast7d.toLocaleString()}
59+
</span>
60+
<span className="inline-flex items-center gap-1">
61+
<MousePointerClick size={13} /> {cart.clicksLast7d.toLocaleString()}
62+
</span>
63+
<span className="text-muted/70">
64+
· {cart.products.length} {cart.products.length === 1 ? "item" : "items"}
65+
</span>
66+
</div>
67+
{showCopy && (
68+
<button
69+
type="button"
70+
onClick={handleCopy}
71+
aria-label={`Copy link to ${cart.title}`}
72+
className="shrink-0 inline-flex items-center gap-1.5 rounded-full border border-rule px-3 py-1.5 text-xs font-medium hover:border-ink hover:bg-paper transition-colors"
73+
>
74+
{copied ? <Check size={13} /> : <Share2 size={13} />}
75+
{copied ? "Copied" : "Share"}
76+
</button>
77+
)}
78+
</div>
7179
</div>
7280
</Link>
7381
);

0 commit comments

Comments
 (0)