@pylonsync/create-pylon 0.3.357 → 0.3.358

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/package.json +1 -1
  2. package/templates/marketplace/README.md +29 -14
  3. package/templates/marketplace/app/error.tsx +30 -16
  4. package/templates/marketplace/app/globals.css +140 -24
  5. package/templates/marketplace/app/layout.tsx +60 -31
  6. package/templates/marketplace/app/listing/[id]/page.tsx +160 -60
  7. package/templates/marketplace/app/me/page.tsx +3 -3
  8. package/templates/marketplace/app/not-found.tsx +19 -10
  9. package/templates/marketplace/app/page.tsx +287 -97
  10. package/templates/marketplace/app/sell/page.tsx +11 -9
  11. package/templates/marketplace/app.ts +5 -4
  12. package/templates/marketplace/client/AuthNav.tsx +4 -8
  13. package/templates/marketplace/client/LiveListingStatus.tsx +60 -0
  14. package/templates/marketplace/client/LiveTicker.tsx +22 -10
  15. package/templates/marketplace/client/LoginCard.tsx +108 -62
  16. package/templates/marketplace/client/MarketProvider.tsx +16 -2
  17. package/templates/marketplace/client/MyMarket.tsx +126 -56
  18. package/templates/marketplace/client/OfferPanel.tsx +222 -112
  19. package/templates/marketplace/client/ScrollToListingsLink.tsx +29 -0
  20. package/templates/marketplace/client/SeedOnEmpty.tsx +7 -6
  21. package/templates/marketplace/client/SellForm.tsx +198 -40
  22. package/templates/marketplace/client/ThemeToggle.tsx +62 -0
  23. package/templates/marketplace/client/WatchButton.tsx +19 -8
  24. package/templates/marketplace/client/market.ts +10 -4
  25. package/templates/marketplace/components/ui/alert.tsx +66 -0
  26. package/templates/marketplace/components/ui/badge.tsx +5 -1
  27. package/templates/marketplace/components/ui/button.tsx +9 -7
  28. package/templates/marketplace/components/ui/card.tsx +2 -2
  29. package/templates/marketplace/components/ui/empty.tsx +104 -0
  30. package/templates/marketplace/components/ui/field.tsx +246 -0
  31. package/templates/marketplace/components/ui/input.tsx +2 -2
  32. package/templates/marketplace/components/ui/label.tsx +4 -3
  33. package/templates/marketplace/components/ui/native-select.tsx +62 -0
  34. package/templates/marketplace/components/ui/separator.tsx +26 -0
  35. package/templates/marketplace/components/ui/skeleton.tsx +13 -0
  36. package/templates/marketplace/components/ui/spinner.tsx +16 -0
  37. package/templates/marketplace/components/ui/textarea.tsx +2 -2
  38. package/templates/marketplace/{ui → components/ui}/tokens.css +1 -1
  39. package/templates/marketplace/components.json +20 -0
  40. package/templates/marketplace/functions/seedMarket.ts +14 -12
  41. package/templates/marketplace/lib/catalog.ts +32 -0
  42. package/templates/marketplace/package.json +10 -9
  43. package/templates/marketplace/public/images/marketplace-hero.webp +0 -0
  44. package/templates/marketplace/tests/example.test.ts +76 -8
  45. package/templates/marketplace/tsconfig.json +13 -2
  46. package/templates/marketplace/components/ui/select.tsx +0 -39
  47. package/templates/marketplace/ui/badge.tsx +0 -30
  48. package/templates/marketplace/ui/button.tsx +0 -49
  49. package/templates/marketplace/ui/card.tsx +0 -48
  50. package/templates/marketplace/ui/input.tsx +0 -17
  51. package/templates/marketplace/ui/label.tsx +0 -18
  52. package/templates/marketplace/ui/textarea.tsx +0 -17
  53. package/templates/marketplace/ui/utils.ts +0 -6
@@ -3,6 +3,7 @@
3
3
  import React from "react";
4
4
  import { Link, db } from "@pylonsync/react";
5
5
  import { Tag, BadgeCheck } from "lucide-react";
6
+ import { Card } from "@/components/ui/card";
6
7
  import { MarketProvider } from "./MarketProvider";
7
8
  import { money, timeAgo, type Listing, type Offer } from "./market";
8
9
 
@@ -18,13 +19,13 @@ type Activity = {
18
19
  href: string;
19
20
  amount?: number;
20
21
  at: string;
22
+ seed?: Listing;
21
23
  };
22
24
 
23
25
  function Ticker() {
24
26
  const { data: listingData } = db.useQuery<Listing>("Listing", {
25
- where: { status: "active" },
26
27
  orderBy: { createdAt: "desc" },
27
- limit: 8,
28
+ limit: 16,
28
29
  });
29
30
  const { data: saleData } = db.useQuery<Offer>("Offer", {
30
31
  where: { status: "accepted" },
@@ -32,13 +33,16 @@ function Ticker() {
32
33
  limit: 8,
33
34
  });
34
35
 
36
+ const listings = listingData ?? [];
37
+ const listingById = new Map(listings.map((listing) => [listing.id, listing]));
35
38
  const events: Activity[] = [
36
- ...(listingData ?? []).map((l) => ({
39
+ ...listings.filter((l) => l.status === "active").map((l) => ({
37
40
  key: `l-${l.id}`,
38
41
  kind: "listed" as const,
39
42
  title: l.title,
40
43
  href: `/listing/${l.slug || l.id}`,
41
44
  at: l.createdAt,
45
+ seed: l,
42
46
  })),
43
47
  ...(saleData ?? []).map((o) => ({
44
48
  key: `s-${o.id}`,
@@ -47,6 +51,7 @@ function Ticker() {
47
51
  href: `/listing/${o.listingId}`,
48
52
  amount: o.amount,
49
53
  at: o.createdAt,
54
+ seed: listingById.get(o.listingId),
50
55
  })),
51
56
  ]
52
57
  .sort((a, b) => Date.parse(b.at) - Date.parse(a.at))
@@ -55,11 +60,11 @@ function Ticker() {
55
60
  if (events.length === 0) return null;
56
61
 
57
62
  return (
58
- <div className="flex items-center gap-3 overflow-hidden rounded-lg border bg-card px-3 py-2 text-sm">
59
- <span className="flex shrink-0 items-center gap-1.5 font-medium text-emerald-600">
63
+ <Card className="flex min-h-11 items-center gap-3 overflow-hidden px-4 py-2 text-sm">
64
+ <span className="flex shrink-0 items-center gap-1.5 font-medium text-success-foreground">
60
65
  <span className="relative flex size-2">
61
- <span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-75" />
62
- <span className="relative inline-flex size-2 rounded-full bg-emerald-500" />
66
+ <span className="absolute inline-flex size-full animate-ping rounded-full bg-success opacity-75" />
67
+ <span className="relative inline-flex size-2 rounded-full bg-success" />
63
68
  </span>
64
69
  Live
65
70
  </span>
@@ -72,14 +77,21 @@ function Ticker() {
72
77
  <Link
73
78
  key={`${e.key}-${i}`}
74
79
  href={e.href}
80
+ seed={e.seed}
75
81
  className="flex shrink-0 items-center gap-1.5 whitespace-nowrap hover:underline"
76
82
  aria-hidden={i >= events.length}
77
83
  tabIndex={i >= events.length ? -1 : undefined}
78
84
  >
79
85
  {e.kind === "sold" ? (
80
- <BadgeCheck className="size-3.5 text-emerald-600" />
86
+ <BadgeCheck
87
+ className="size-3.5 text-success-foreground"
88
+ aria-hidden="true"
89
+ />
81
90
  ) : (
82
- <Tag className="size-3.5 text-muted-foreground" />
91
+ <Tag
92
+ className="size-3.5 text-muted-foreground"
93
+ aria-hidden="true"
94
+ />
83
95
  )}
84
96
  <span className="font-medium">{e.title}</span>
85
97
  <span className="text-muted-foreground">
@@ -91,7 +103,7 @@ function Ticker() {
91
103
  ))}
92
104
  </div>
93
105
  </div>
94
- </div>
106
+ </Card>
95
107
  );
96
108
  }
97
109
 
@@ -1,9 +1,22 @@
1
1
  "use client";
2
2
 
3
3
  import React, { useState } from "react";
4
- import { Button } from "../ui/button";
5
- import { Input } from "../ui/input";
6
- import { Label } from "../ui/label";
4
+ import { Alert, AlertDescription } from "@/components/ui/alert";
5
+ import { Button } from "@/components/ui/button";
6
+ import {
7
+ Card,
8
+ CardContent,
9
+ CardDescription,
10
+ CardFooter,
11
+ CardHeader,
12
+ } from "@/components/ui/card";
13
+ import {
14
+ Field,
15
+ FieldGroup,
16
+ FieldLabel,
17
+ } from "@/components/ui/field";
18
+ import { Input } from "@/components/ui/input";
19
+ import { Spinner } from "@/components/ui/spinner";
7
20
  import { useAuth } from "./MarketProvider";
8
21
  import { DEMO } from "./market";
9
22
 
@@ -12,10 +25,12 @@ import { DEMO } from "./market";
12
25
  // with the seeded demo account so it's one click to a working session.
13
26
  export function LoginCard({
14
27
  title = "Sign in to continue",
15
- blurb = "Real email/password auth no verification email. The demo account is prefilled; just hit Log in.",
28
+ blurb = "Use the ready demo account or create your own. No verification email is required.",
29
+ headingLevel = 2,
16
30
  }: {
17
31
  title?: string;
18
32
  blurb?: string;
33
+ headingLevel?: 1 | 2;
19
34
  }) {
20
35
  const { signIn, signUp } = useAuth();
21
36
  const [mode, setMode] = useState<"login" | "register">("login");
@@ -25,6 +40,7 @@ export function LoginCard({
25
40
  const [name, setName] = useState("");
26
41
  const [busy, setBusy] = useState(false);
27
42
  const [err, setErr] = useState<string | null>(null);
43
+ const Heading = headingLevel === 1 ? "h1" : "h2";
28
44
 
29
45
  async function submit(e: React.FormEvent) {
30
46
  e.preventDefault();
@@ -41,62 +57,90 @@ export function LoginCard({
41
57
  }
42
58
 
43
59
  return (
44
- <div className="mx-auto max-w-sm space-y-4 rounded-xl border bg-card p-6">
45
- <div>
46
- <h2 className="text-lg font-semibold tracking-tight">{title}</h2>
47
- <p className="mt-1 text-sm text-muted-foreground">{blurb}</p>
48
- </div>
49
- <form onSubmit={submit} className="space-y-3">
50
- {mode === "register" ? (
51
- <div className="space-y-1.5">
52
- <Label htmlFor="lc-name">Name</Label>
53
- <Input
54
- id="lc-name"
55
- value={name}
56
- onChange={(e) => setName(e.target.value)}
57
- placeholder="Pat Pylon"
58
- />
59
- </div>
60
- ) : null}
61
- <div className="space-y-1.5">
62
- <Label htmlFor="lc-email">Email</Label>
63
- <Input
64
- id="lc-email"
65
- type="email"
66
- required
67
- value={email}
68
- onChange={(e) => setEmail(e.target.value)}
69
- placeholder="you@example.com"
70
- />
71
- </div>
72
- <div className="space-y-1.5">
73
- <Label htmlFor="lc-password">Password</Label>
74
- <Input
75
- id="lc-password"
76
- type="password"
77
- required
78
- minLength={8}
79
- value={password}
80
- onChange={(e) => setPassword(e.target.value)}
81
- placeholder={mode === "register" ? "8+ characters" : ""}
82
- />
83
- </div>
84
- {err ? <p className="text-sm text-destructive">{err}</p> : null}
85
- <Button type="submit" disabled={busy} className="w-full">
86
- {busy
87
- ? ""
88
- : mode === "login"
89
- ? "Log in"
90
- : "Create account"}
91
- </Button>
92
- </form>
93
- <p className="text-center text-xs text-muted-foreground">
60
+ <Card className="mx-auto max-w-sm">
61
+ <CardHeader>
62
+ <Heading className="text-xl font-semibold tracking-[-0.02em]">
63
+ {title}
64
+ </Heading>
65
+ <CardDescription className="text-pretty leading-5">
66
+ {blurb}
67
+ </CardDescription>
68
+ </CardHeader>
69
+ <CardContent>
70
+ <form onSubmit={submit}>
71
+ <FieldGroup className="gap-4">
72
+ {mode === "register" ? (
73
+ <Field>
74
+ <FieldLabel htmlFor="lc-name">Name</FieldLabel>
75
+ <Input
76
+ id="lc-name"
77
+ name="name"
78
+ autoComplete="name"
79
+ value={name}
80
+ onChange={(e) => setName(e.target.value)}
81
+ placeholder="Pat Pylon"
82
+ />
83
+ </Field>
84
+ ) : null}
85
+ <Field>
86
+ <FieldLabel htmlFor="lc-email">Email</FieldLabel>
87
+ <Input
88
+ id="lc-email"
89
+ name="email"
90
+ type="email"
91
+ required
92
+ autoComplete="email"
93
+ spellCheck={false}
94
+ value={email}
95
+ onChange={(e) => setEmail(e.target.value)}
96
+ placeholder="you@example.com"
97
+ />
98
+ </Field>
99
+ <Field>
100
+ <FieldLabel htmlFor="lc-password">Password</FieldLabel>
101
+ <Input
102
+ id="lc-password"
103
+ name="password"
104
+ type="password"
105
+ required
106
+ minLength={8}
107
+ autoComplete={
108
+ mode === "login" ? "current-password" : "new-password"
109
+ }
110
+ value={password}
111
+ onChange={(e) => setPassword(e.target.value)}
112
+ placeholder={mode === "register" ? "8+ characters" : ""}
113
+ />
114
+ </Field>
115
+ <div aria-live="polite">
116
+ {err ? (
117
+ <Alert variant="destructive">
118
+ <AlertDescription>{err}</AlertDescription>
119
+ </Alert>
120
+ ) : null}
121
+ </div>
122
+ <Button type="submit" disabled={busy} className="w-full">
123
+ {busy ? <Spinner data-icon="inline-start" /> : null}
124
+ {busy
125
+ ? mode === "login"
126
+ ? "Logging in…"
127
+ : "Creating account…"
128
+ : mode === "login"
129
+ ? "Log in"
130
+ : "Create account"}
131
+ </Button>
132
+ </FieldGroup>
133
+ </form>
134
+ </CardContent>
135
+ <CardFooter className="justify-center text-xs text-muted-foreground">
94
136
  {mode === "login" ? (
95
137
  <>
96
138
  No account?{" "}
97
- <button
139
+ <Button
98
140
  type="button"
99
- className="font-medium text-foreground hover:underline"
141
+ variant="link"
142
+ size="sm"
143
+ className="px-1"
100
144
  onClick={() => {
101
145
  setMode("register");
102
146
  setEmail("");
@@ -105,14 +149,16 @@ export function LoginCard({
105
149
  }}
106
150
  >
107
151
  Sign up
108
- </button>
152
+ </Button>
109
153
  </>
110
154
  ) : (
111
155
  <>
112
156
  Have an account?{" "}
113
- <button
157
+ <Button
114
158
  type="button"
115
- className="font-medium text-foreground hover:underline"
159
+ variant="link"
160
+ size="sm"
161
+ className="px-1"
116
162
  onClick={() => {
117
163
  setMode("login");
118
164
  setEmail(DEMO.email);
@@ -121,10 +167,10 @@ export function LoginCard({
121
167
  }}
122
168
  >
123
169
  Log in
124
- </button>
170
+ </Button>
125
171
  </>
126
172
  )}
127
- </p>
128
- </div>
173
+ </CardFooter>
174
+ </Card>
129
175
  );
130
176
  }
@@ -19,6 +19,7 @@ import {
19
19
  type Identity,
20
20
  } from "./market";
21
21
  import { LoginCard } from "./LoginCard";
22
+ import { Spinner } from "@/components/ui/spinner";
22
23
 
23
24
  // The sync engine (live queries, WebSocket) is browser-only, so every
24
25
  // interactive island mounts behind this provider. It boots the client, keeps
@@ -100,7 +101,10 @@ export function MarketProvider({
100
101
  return (
101
102
  <>
102
103
  {fallback ?? (
103
- <span className="text-xs text-muted-foreground">connecting…</span>
104
+ <span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
105
+ <Spinner />
106
+ Connecting…
107
+ </span>
104
108
  )}
105
109
  </>
106
110
  );
@@ -137,12 +141,22 @@ export function AuthGate({
137
141
  children,
138
142
  title,
139
143
  blurb,
144
+ headingLevel,
140
145
  }: {
141
146
  children: React.ReactNode;
142
147
  title?: string;
143
148
  blurb?: string;
149
+ headingLevel?: 1 | 2;
144
150
  }) {
145
151
  const { identity } = useAuth();
146
- if (!identity) return <LoginCard title={title} blurb={blurb} />;
152
+ if (!identity) {
153
+ return (
154
+ <LoginCard
155
+ title={title}
156
+ blurb={blurb}
157
+ headingLevel={headingLevel}
158
+ />
159
+ );
160
+ }
147
161
  return <>{children}</>;
148
162
  }
@@ -2,8 +2,17 @@
2
2
 
3
3
  import React from "react";
4
4
  import { Link, db } from "@pylonsync/react";
5
- import { Badge } from "../ui/badge";
6
- import { Button } from "../ui/button";
5
+ import { Badge } from "@/components/ui/badge";
6
+ import { Button } from "@/components/ui/button";
7
+ import { Card } from "@/components/ui/card";
8
+ import {
9
+ Empty,
10
+ EmptyContent,
11
+ EmptyDescription,
12
+ EmptyHeader,
13
+ EmptyTitle,
14
+ } from "@/components/ui/empty";
15
+ import { Spinner } from "@/components/ui/spinner";
7
16
  import { AuthGate, MarketProvider, useIdentity } from "./MarketProvider";
8
17
  import { Heart } from "lucide-react";
9
18
  import { money, timeAgo, type Listing, type Offer, type Watch } from "./market";
@@ -16,46 +25,53 @@ const statusVariant: Record<string, BadgeVariant> = {
16
25
  declined: "outline",
17
26
  };
18
27
 
28
+ function uniqueById<T extends { id: string }>(rows: T[]): T[] {
29
+ return Array.from(new Map(rows.map((row) => [row.id, row])).values());
30
+ }
31
+
19
32
  function Dashboard() {
20
33
  // Rendered inside <AuthGate>, so identity is non-null here.
21
34
  const identity = useIdentity();
22
35
  const userId = identity?.userId ?? "";
23
36
  const name = identity?.name ?? "you";
24
37
 
25
- // Three live queries, all scoped to me. Everything updates in place: a new
26
- // offer on my listing, a seller answering my bid no refresh.
38
+ // Three live queries. Listings + offers are public-read, then narrowed to
39
+ // the signed-in user in memory; Watch remains policy-scoped to its owner.
40
+ // Everything updates in place: a new offer on my listing, a seller
41
+ // answering my bid — no refresh.
27
42
  const { data: listings } = db.useQuery<Listing>("Listing", {
28
- where: { sellerId: userId },
29
43
  orderBy: { createdAt: "desc" },
30
44
  });
31
- const { data: received } = db.useQuery<Offer>("Offer", {
32
- where: { sellerId: userId },
45
+ const { data: offers } = db.useQuery<Offer>("Offer", {
33
46
  orderBy: { createdAt: "desc" },
34
47
  });
35
48
  const { data: watching } = db.useQuery<Watch>("Watch", {
36
49
  where: { userId },
37
50
  orderBy: { createdAt: "desc" },
38
51
  });
39
- const { data: sent } = db.useQuery<Offer>("Offer", {
40
- where: { buyerId: userId },
41
- orderBy: { createdAt: "desc" },
42
- });
43
52
 
44
- const myListings = listings ?? [];
45
- const myOffers = sent ?? [];
46
- const watchlist = watching ?? [];
47
- const inbound = received ?? [];
53
+ const allListings = uniqueById(listings ?? []);
54
+ const listingById = new Map(
55
+ allListings.map((listing) => [listing.id, listing]),
56
+ );
57
+ const myListings = allListings.filter(
58
+ (listing) => listing.sellerId === userId,
59
+ );
60
+ const allOffers = uniqueById(offers ?? []);
61
+ const myOffers = allOffers.filter((offer) => offer.buyerId === userId);
62
+ const watchlist = uniqueById(watching ?? []);
63
+ const inbound = allOffers.filter((offer) => offer.sellerId === userId);
48
64
  const pendingFor = (listingId: string) =>
49
65
  inbound.filter((o) => o.listingId === listingId && o.status === "pending")
50
66
  .length;
51
67
 
52
68
  return (
53
- <div className="space-y-10">
54
- <header className="flex items-center justify-between">
69
+ <div className="flex flex-col gap-10 py-2 sm:py-6">
70
+ <header className="flex flex-col items-start justify-between gap-5 sm:flex-row sm:items-end">
55
71
  <div>
56
- <h1 className="text-2xl font-semibold tracking-tight">My Market</h1>
57
- <p className="text-sm text-muted-foreground">
58
- You're trading as <span className="font-medium">{name}</span>.
72
+ <h1 className="text-3xl font-semibold tracking-[-0.03em]">Dashboard</h1>
73
+ <p className="mt-1 text-sm text-muted-foreground">
74
+ Signed in as <span className="font-medium text-foreground">{name}</span>.
59
75
  </p>
60
76
  </div>
61
77
  <Button asChild>
@@ -63,21 +79,42 @@ function Dashboard() {
63
79
  </Button>
64
80
  </header>
65
81
 
66
- <section className="space-y-3">
82
+ <section className="flex flex-col gap-3">
67
83
  <h2 className="font-semibold">Your listings ({myListings.length})</h2>
68
84
  {myListings.length === 0 ? (
69
- <p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
70
- Nothing listed yet.{" "}
71
- <Link href="/sell" className="underline">
72
- Post your first item
73
- </Link>
74
- .
75
- </p>
85
+ <Empty className="border-0 bg-card py-8 shadow-[var(--shadow-border)]">
86
+ <EmptyHeader>
87
+ <EmptyTitle className="text-base">Nothing listed yet</EmptyTitle>
88
+ <EmptyDescription>
89
+ Post your first item and it will appear here.
90
+ </EmptyDescription>
91
+ </EmptyHeader>
92
+ <EmptyContent>
93
+ <Button asChild variant="outline">
94
+ <Link href="/sell">Post your first item</Link>
95
+ </Button>
96
+ </EmptyContent>
97
+ </Empty>
76
98
  ) : (
77
- <ul className="divide-y rounded-lg border">
99
+ <Card>
100
+ <ul className="divide-y overflow-hidden">
78
101
  {myListings.map((l) => (
79
102
  <li key={l.id} className="flex items-center justify-between gap-3 p-3">
80
- <Link href={`/listing/${l.slug || l.id}`} className="min-w-0 hover:underline">
103
+ <Link
104
+ href={`/listing/${l.slug || l.id}`}
105
+ seed={l}
106
+ className="flex min-w-0 items-center gap-3 hover:underline"
107
+ >
108
+ {l.imageUrl ? (
109
+ <img
110
+ src={l.imageUrl}
111
+ alt=""
112
+ width="48"
113
+ height="48"
114
+ loading="lazy"
115
+ className="size-12 shrink-0 rounded-lg object-cover outline outline-1 -outline-offset-1 outline-border"
116
+ />
117
+ ) : null}
81
118
  <span className="truncate font-medium">{l.title}</span>
82
119
  </Link>
83
120
  <div className="flex shrink-0 items-center gap-2 text-sm">
@@ -95,61 +132,85 @@ function Dashboard() {
95
132
  </li>
96
133
  ))}
97
134
  </ul>
135
+ </Card>
98
136
  )}
99
137
  </section>
100
138
 
101
- <section className="space-y-3">
139
+ <section className="flex flex-col gap-3">
102
140
  <h2 className="font-semibold">Offers you've made ({myOffers.length})</h2>
103
141
  {myOffers.length === 0 ? (
104
- <p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
105
- No offers out.{" "}
106
- <Link href="/" className="underline">
107
- Browse the market
108
- </Link>
109
- .
110
- </p>
142
+ <Empty className="border-0 bg-card py-8 shadow-[var(--shadow-border)]">
143
+ <EmptyHeader>
144
+ <EmptyTitle className="text-base">No offers out</EmptyTitle>
145
+ <EmptyDescription>
146
+ When you make an offer, its status will stay synced here.
147
+ </EmptyDescription>
148
+ </EmptyHeader>
149
+ <EmptyContent>
150
+ <Button asChild variant="outline">
151
+ <Link href="/">Browse the market</Link>
152
+ </Button>
153
+ </EmptyContent>
154
+ </Empty>
111
155
  ) : (
112
- <ul className="divide-y rounded-lg border">
156
+ <Card>
157
+ <ul className="divide-y overflow-hidden">
113
158
  {myOffers.map((o) => (
114
- <li key={o.id} className="flex items-center justify-between gap-3 p-3">
159
+ <li
160
+ key={o.id}
161
+ className="flex flex-col items-start gap-2 p-3 sm:flex-row sm:items-center sm:justify-between"
162
+ >
115
163
  <Link
116
164
  href={`/listing/${o.listingId}`}
117
- className="min-w-0 hover:underline"
165
+ seed={listingById.get(o.listingId)}
166
+ className="min-w-0 max-w-full hover:underline"
118
167
  >
119
- <span className="truncate font-medium">{o.listingTitle}</span>
120
- <span className="ml-2 text-sm text-muted-foreground">
168
+ <span className="block truncate font-medium">{o.listingTitle}</span>
169
+ <span className="block text-sm text-muted-foreground sm:mt-0 sm:inline sm:pl-2">
121
170
  {timeAgo(o.createdAt)}
122
171
  </span>
123
172
  </Link>
124
- <div className="flex shrink-0 items-center gap-2 text-sm">
173
+ <div className="flex shrink-0 self-end items-center gap-2 text-sm sm:self-auto">
125
174
  <span className="font-semibold tabular-nums">{money(o.amount)}</span>
126
- <Badge variant={statusVariant[o.status] ?? "outline"}>
175
+ <Badge
176
+ variant={statusVariant[o.status] ?? "outline"}
177
+ className="capitalize"
178
+ >
127
179
  {o.status}
128
180
  </Badge>
129
181
  </div>
130
182
  </li>
131
183
  ))}
132
184
  </ul>
185
+ </Card>
133
186
  )}
134
187
  </section>
135
188
 
136
- <section className="space-y-3">
189
+ <section className="flex flex-col gap-3">
137
190
  <h2 className="flex items-center gap-2 font-semibold">
138
- <Heart className="size-4 text-rose-500" />
191
+ <Heart
192
+ aria-hidden="true"
193
+ className="size-4 fill-foreground text-foreground"
194
+ />
139
195
  Watching ({watchlist.length})
140
196
  </h2>
141
197
  {watchlist.length === 0 ? (
142
- <p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
143
- Nothing saved yet. Tap the{" "}
144
- <Heart className="inline size-3.5 align-text-bottom" /> on any
145
- listing to watch it — your watchlist is private and syncs live.
146
- </p>
198
+ <Empty className="border-0 bg-card py-8 shadow-[var(--shadow-border)]">
199
+ <EmptyHeader>
200
+ <EmptyTitle className="text-base">Nothing saved yet</EmptyTitle>
201
+ <EmptyDescription>
202
+ Use the heart on any listing to save it privately here.
203
+ </EmptyDescription>
204
+ </EmptyHeader>
205
+ </Empty>
147
206
  ) : (
148
- <ul className="divide-y rounded-lg border">
207
+ <Card>
208
+ <ul className="divide-y overflow-hidden">
149
209
  {watchlist.map((w) => (
150
210
  <li key={w.id} className="flex items-center justify-between gap-3 p-3">
151
211
  <Link
152
212
  href={`/listing/${w.listingId}`}
213
+ seed={listingById.get(w.listingId)}
153
214
  className="min-w-0 truncate font-medium hover:underline"
154
215
  >
155
216
  {w.listingTitle}
@@ -160,6 +221,7 @@ function Dashboard() {
160
221
  </li>
161
222
  ))}
162
223
  </ul>
224
+ </Card>
163
225
  )}
164
226
  </section>
165
227
  </div>
@@ -168,10 +230,18 @@ function Dashboard() {
168
230
 
169
231
  export function MyMarket() {
170
232
  return (
171
- <MarketProvider fallback={<p className="text-sm text-muted-foreground">Loading your market…</p>}>
233
+ <MarketProvider
234
+ fallback={
235
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
236
+ <Spinner />
237
+ Loading your market…
238
+ </div>
239
+ }
240
+ >
172
241
  <AuthGate
173
- title="Sign in to see your market"
174
- blurb="Your listings, offers received, and bids you've sent — all live. The demo account is prefilled; just hit Log in."
242
+ title="Sign in to open your dashboard"
243
+ blurb="Your listings, saved finds, and offers stay synced here. The demo account is ready; just select Log in."
244
+ headingLevel={1}
175
245
  >
176
246
  <Dashboard />
177
247
  </AuthGate>