@pylonsync/create-pylon 0.3.356 → 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
@@ -5,101 +5,169 @@ import {
5
5
  type PageProps,
6
6
  type ServerData,
7
7
  } from "@pylonsync/react";
8
- import { Card } from "../ui/card";
9
- import { Badge } from "../ui/badge";
8
+ import { Badge } from "@/components/ui/badge";
9
+ import { Button } from "@/components/ui/button";
10
+ import { Card } from "@/components/ui/card";
11
+ import {
12
+ Empty,
13
+ EmptyContent,
14
+ EmptyDescription,
15
+ EmptyHeader,
16
+ EmptyTitle,
17
+ } from "@/components/ui/empty";
18
+ import {
19
+ Field,
20
+ FieldGroup,
21
+ FieldLabel,
22
+ } from "@/components/ui/field";
23
+ import { Input } from "@/components/ui/input";
24
+ import {
25
+ NativeSelect,
26
+ NativeSelectOption,
27
+ } from "@/components/ui/native-select";
28
+ import { Skeleton } from "@/components/ui/skeleton";
10
29
  import { LiveTicker } from "../client/LiveTicker";
11
30
  import { SeedOnEmpty } from "../client/SeedOnEmpty";
12
31
  import { CategoryIcon } from "./_components/CategoryIcon";
13
32
  import { WatchButton } from "../client/WatchButton";
33
+ import { ScrollToListingsLink } from "../client/ScrollToListingsLink";
14
34
  import { gradient, money, conditionLabel, type Listing } from "../client/market";
35
+ import { browseListings } from "../lib/catalog";
15
36
 
16
37
  export const metadata: Metadata = {
17
- title: "Pylon Market buy & sell locally, live",
38
+ title: "Reprise | Distinctive secondhand finds",
18
39
  description:
19
- "A live local marketplace. Server-rendered listings for SEO, realtime offers over the sync engine one Pylon binary, one port.",
40
+ "Browse distinctive secondhand furniture, technology, fashion, and more. Save favorites, make offers, and follow every update live.",
20
41
  };
21
42
 
22
43
  const CATEGORIES = [
23
- "all", "furniture", "electronics", "cameras", "bikes", "audio", "kitchen",
24
- "instruments", "outdoor", "apparel",
44
+ "all",
45
+ "furniture",
46
+ "electronics",
47
+ "cameras",
48
+ "bikes",
49
+ "audio",
50
+ "kitchen",
51
+ "instruments",
52
+ "outdoor",
53
+ "apparel",
25
54
  ];
26
55
 
27
- // The grid suspends on the server-side read and streams in with real rows in
28
- // the HTML (good for SEO + LCP). `serverData.query` runs through the same
29
- // policy gate as a query function's ctx.db.
56
+ function browseHref(category: string, query: string, sort: string): string {
57
+ const params = new URLSearchParams();
58
+ if (category !== "all") params.set("category", category);
59
+ if (query) params.set("q", query);
60
+ if (sort !== "latest") params.set("sort", sort);
61
+ const suffix = params.toString();
62
+ return suffix ? `/?${suffix}#listings` : "/#listings";
63
+ }
64
+
65
+ function ListingImage({ listing }: { listing: Listing }) {
66
+ if (listing.imageUrl) {
67
+ return (
68
+ <img
69
+ src={listing.imageUrl}
70
+ alt={listing.title}
71
+ width="600"
72
+ height="750"
73
+ loading="lazy"
74
+ decoding="async"
75
+ className="h-full w-full object-cover outline outline-1 -outline-offset-1 outline-border"
76
+ />
77
+ );
78
+ }
79
+
80
+ return (
81
+ <div
82
+ className="flex h-full w-full items-center justify-center text-white/90"
83
+ style={{ background: gradient(listing.seed || listing.id) }}
84
+ >
85
+ <CategoryIcon category={listing.category} className="size-14" />
86
+ </div>
87
+ );
88
+ }
89
+
30
90
  function Grid({
31
91
  serverData,
32
92
  category,
93
+ query,
94
+ sort,
33
95
  }: {
34
96
  serverData: ServerData;
35
97
  category: string;
98
+ query: string;
99
+ sort: string;
36
100
  }) {
37
101
  const active = use(serverData.query<Listing>("Listing", { status: "active" }));
38
- const sorted = [...active].sort((a, b) =>
39
- b.createdAt.localeCompare(a.createdAt),
40
- );
41
- const listings =
42
- category === "all"
43
- ? sorted
44
- : sorted.filter((l) => l.category === category);
102
+ const listings = browseListings(active, { category, query, sort });
45
103
 
46
104
  if (listings.length === 0) {
47
105
  return (
48
106
  <>
49
- <div className="rounded-xl border border-dashed p-12 text-center">
50
- <p className="text-sm text-muted-foreground">
51
- {active.length === 0
52
- ? "Setting up a few sample listings…"
53
- : "Nothing in this category yet."}
54
- </p>
55
- {category !== "all" ? (
56
- <Link href="/" className="mt-2 inline-block text-sm underline">
57
- See everything
58
- </Link>
107
+ <Empty className="border-0 bg-card py-16 shadow-[var(--shadow-border)]">
108
+ <EmptyHeader>
109
+ <EmptyTitle>
110
+ {active.length === 0
111
+ ? "Adding sample finds…"
112
+ : "No matching finds yet"}
113
+ </EmptyTitle>
114
+ <EmptyDescription>
115
+ {active.length === 0
116
+ ? "The catalog will appear in a moment."
117
+ : "Try another category or a broader search."}
118
+ </EmptyDescription>
119
+ </EmptyHeader>
120
+ {active.length > 0 ? (
121
+ <EmptyContent>
122
+ <Button asChild>
123
+ <a href="/#listings">Clear filters</a>
124
+ </Button>
125
+ </EmptyContent>
59
126
  ) : null}
60
- </div>
127
+ </Empty>
61
128
  <SeedOnEmpty count={active.length} />
62
129
  </>
63
130
  );
64
131
  }
65
132
 
66
133
  return (
67
- <div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
68
- {listings.map((l) => (
69
- <Link key={l.id} href={`/listing/${l.slug || l.id}`} className="group">
70
- <Card className="flex flex-col overflow-hidden p-0 transition group-hover:-translate-y-0.5 group-hover:shadow-md">
71
- <div
72
- className="relative flex aspect-square items-center justify-center text-white/90"
73
- style={{ background: gradient(l.seed || l.id) }}
74
- >
75
- <CategoryIcon category={l.category} className="size-14" />
76
- <WatchButton
77
- listingId={l.id}
78
- listingTitle={l.title}
79
- className="absolute right-2 top-2 size-8"
80
- />
81
- {l.status === "sold" ? (
82
- <span className="absolute inset-0 grid place-items-center bg-black/55 text-sm font-bold uppercase tracking-wide">
83
- Sold
84
- </span>
85
- ) : null}
134
+ <div className="grid grid-cols-2 gap-x-3 gap-y-7 sm:gap-x-5 sm:gap-y-9 lg:grid-cols-4">
135
+ {listings.map((listing) => (
136
+ <Card
137
+ key={listing.id}
138
+ className="group relative min-w-0 overflow-hidden transition-[box-shadow,transform] duration-200 ease-out hover:-translate-y-1 hover:shadow-[var(--shadow-border-hover)]"
139
+ >
140
+ <Link
141
+ href={`/listing/${listing.slug || listing.id}`}
142
+ seed={listing}
143
+ className="block"
144
+ >
145
+ <div className="aspect-[4/5] overflow-hidden bg-muted">
146
+ <div className="h-full w-full transition-transform duration-500 ease-out group-hover:scale-[1.025]">
147
+ <ListingImage listing={listing} />
148
+ </div>
86
149
  </div>
87
- <div className="flex flex-1 flex-col gap-1 p-3">
88
- <span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
89
- {l.category}
90
- </span>
91
- <span className="line-clamp-2 min-h-[34px] text-sm font-medium leading-snug">
92
- {l.title}
93
- </span>
94
- <div className="mt-1 flex items-center justify-between">
95
- <span className="font-semibold tabular-nums">{money(l.price)}</span>
96
- <Badge variant="outline" className="text-[10px]">
97
- {conditionLabel(l.condition)}
150
+ <div className="flex flex-col gap-2.5 p-3.5 sm:p-4">
151
+ <div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
152
+ <span className="capitalize">{listing.category}</span>
153
+ <Badge variant="outline" className="shrink-0 text-[10px]">
154
+ {conditionLabel(listing.condition)}
98
155
  </Badge>
99
156
  </div>
157
+ <h2 className="line-clamp-2 min-h-10 text-sm font-medium leading-5 text-balance sm:text-[15px]">
158
+ {listing.title}
159
+ </h2>
160
+ <p className="text-base font-semibold tabular-nums">
161
+ {money(listing.price)}
162
+ </p>
100
163
  </div>
101
- </Card>
102
- </Link>
164
+ </Link>
165
+ <WatchButton
166
+ listingId={listing.id}
167
+ listingTitle={listing.title}
168
+ className="absolute right-3 top-3"
169
+ />
170
+ </Card>
103
171
  ))}
104
172
  </div>
105
173
  );
@@ -107,53 +175,175 @@ function Grid({
107
175
 
108
176
  export default function BrowsePage({ searchParams, serverData }: PageProps) {
109
177
  const category =
110
- typeof searchParams.category === "string" ? searchParams.category : "all";
178
+ typeof searchParams.category === "string" &&
179
+ CATEGORIES.includes(searchParams.category)
180
+ ? searchParams.category
181
+ : "all";
182
+ const query = typeof searchParams.q === "string" ? searchParams.q.trim() : "";
183
+ const sort = typeof searchParams.sort === "string" ? searchParams.sort : "latest";
111
184
 
112
185
  return (
113
- <div className="space-y-6">
114
- <section className="space-y-1">
115
- <h1 className="text-3xl font-semibold tracking-tight">
116
- A local marketplace with live listings and offers
117
- </h1>
118
- <p className="text-muted-foreground">
119
- Listings are server-rendered for search engines; offers are realtime.
120
- Open two tabs, list an item in one, and watch it appear in the other.
121
- </p>
186
+ <div className="flex flex-col gap-12 pb-10">
187
+ <section className="relative min-h-[430px] overflow-hidden rounded-[28px] bg-[#d8d4ce] shadow-[var(--shadow-border)] sm:min-h-[470px]">
188
+ <img
189
+ src="/images/marketplace-hero.webp"
190
+ alt="A curated collection of secondhand furniture, clothing, audio, and camera gear"
191
+ width="1600"
192
+ height="1024"
193
+ fetchPriority="high"
194
+ decoding="async"
195
+ className="absolute inset-0 h-full w-full object-cover object-[72%_center] outline outline-1 -outline-offset-1 outline-border sm:object-center"
196
+ />
197
+ <div className="absolute inset-0 bg-[#ebe8e3]/80 sm:w-[76%] sm:bg-[linear-gradient(90deg,rgba(235,232,227,.98)_0%,rgba(235,232,227,.9)_40%,rgba(235,232,227,.16)_78%,rgba(235,232,227,0)_100%)]" />
198
+ <div className="relative flex min-h-[430px] max-w-[620px] flex-col justify-center px-6 py-12 text-[#171717] sm:min-h-[470px] sm:px-10 lg:px-12">
199
+ <h1 className="max-w-[11ch] text-balance text-4xl font-semibold leading-[1.04] tracking-[-0.045em] sm:text-5xl lg:text-[3.5rem]">
200
+ Better things, ready for what comes next.
201
+ </h1>
202
+ <p className="mt-5 max-w-[38ch] text-pretty text-base leading-7 text-[#4f4c48] sm:text-lg">
203
+ Shop distinctive furniture, technology, fashion, and more. Make offers and follow every update live.
204
+ </p>
205
+ <div className="mt-7 flex flex-wrap gap-3">
206
+ <ScrollToListingsLink
207
+ className="inline-flex min-h-11 items-center rounded-lg bg-[#171717] px-5 text-sm font-medium text-[#fafafa] transition-[background-color,scale] duration-150 hover:bg-[#2b2b2b] active:scale-[0.96]"
208
+ >
209
+ Browse finds
210
+ </ScrollToListingsLink>
211
+ <Button
212
+ asChild
213
+ variant="outline"
214
+ size="lg"
215
+ className="bg-white/75 text-[#171717] backdrop-blur hover:bg-white hover:text-[#171717]"
216
+ >
217
+ <Link href="/sell">Sell</Link>
218
+ </Button>
219
+ </div>
220
+ </div>
122
221
  </section>
123
222
 
124
- {/* Realtime client island */}
223
+ <Card className="grid gap-px overflow-hidden bg-border sm:grid-cols-3">
224
+ {[
225
+ ["Realtime catalog", "New listings and sold status update without a refresh."],
226
+ ["Flexible offers", "Buy at the list price or send the seller an offer."],
227
+ ["Private saves", "Keep a personal watchlist that stays synced to your account."],
228
+ ].map(([title, body]) => (
229
+ <div key={title} className="bg-card px-5 py-4">
230
+ <h2 className="text-sm font-medium">{title}</h2>
231
+ <p className="mt-1 text-pretty text-sm leading-5 text-muted-foreground">
232
+ {body}
233
+ </p>
234
+ </div>
235
+ ))}
236
+ </Card>
237
+
125
238
  <LiveTicker />
126
239
 
127
- <nav className="flex flex-wrap gap-2">
128
- {CATEGORIES.map((c) => (
129
- <Link
130
- key={c}
131
- href={c === "all" ? "/" : `/?category=${c}`}
132
- className={`rounded-full border px-3 py-1 text-sm transition hover:bg-muted ${
133
- category === c
134
- ? "border-foreground bg-foreground text-background hover:bg-foreground"
135
- : "text-muted-foreground"
136
- }`}
137
- >
138
- {c}
139
- </Link>
140
- ))}
141
- </nav>
142
-
143
- <Suspense
144
- fallback={
145
- <div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
146
- {Array.from({ length: 8 }).map((_, i) => (
147
- <div
148
- key={i}
149
- className="aspect-[3/4] animate-pulse rounded-xl bg-muted"
240
+ <section id="listings" className="flex scroll-mt-24 flex-col gap-6">
241
+ <div>
242
+ <h2 className="text-balance text-3xl font-semibold tracking-[-0.03em]">
243
+ Fresh finds
244
+ </h2>
245
+ <p className="mt-1 text-sm text-muted-foreground">
246
+ Thoughtful pieces from independent sellers.
247
+ </p>
248
+ </div>
249
+
250
+ <form
251
+ method="get"
252
+ action="/"
253
+ role="search"
254
+ >
255
+ {category !== "all" ? (
256
+ <Input type="hidden" name="category" value={category} />
257
+ ) : null}
258
+ <Card className="p-3">
259
+ <FieldGroup className="gap-3 sm:grid sm:grid-cols-[1fr_180px_auto]">
260
+ <Field>
261
+ <FieldLabel className="sr-only" htmlFor="catalog-search">
262
+ Search listings
263
+ </FieldLabel>
264
+ <Input
265
+ id="catalog-search"
266
+ name="q"
267
+ type="search"
268
+ autoComplete="off"
269
+ defaultValue={query}
270
+ placeholder="Search furniture, cameras, sellers…"
271
+ className="border-0 bg-muted shadow-none"
150
272
  />
273
+ </Field>
274
+ <Field>
275
+ <FieldLabel className="sr-only" htmlFor="catalog-sort">
276
+ Sort listings
277
+ </FieldLabel>
278
+ <NativeSelect
279
+ id="catalog-sort"
280
+ name="sort"
281
+ defaultValue={sort}
282
+ className="min-h-11 border-0 bg-muted shadow-none"
283
+ >
284
+ <NativeSelectOption value="latest">Newest first</NativeSelectOption>
285
+ <NativeSelectOption value="price-low">
286
+ Price: low to high
287
+ </NativeSelectOption>
288
+ <NativeSelectOption value="price-high">
289
+ Price: high to low
290
+ </NativeSelectOption>
291
+ </NativeSelect>
292
+ </Field>
293
+ <Button type="submit" size="lg">
294
+ Search
295
+ </Button>
296
+ </FieldGroup>
297
+ </Card>
298
+ </form>
299
+
300
+ <nav
301
+ aria-label="Listing categories"
302
+ className="hide-scrollbar -mx-5 overflow-x-auto px-5 pb-1"
303
+ >
304
+ <div className="flex w-max gap-2">
305
+ {CATEGORIES.map((item) => (
306
+ <Button
307
+ key={item}
308
+ asChild
309
+ variant={category === item ? "default" : "outline"}
310
+ className="rounded-full capitalize"
311
+ >
312
+ <a
313
+ href={browseHref(item, query, sort)}
314
+ aria-current={category === item ? "page" : undefined}
315
+ >
316
+ {item}
317
+ </a>
318
+ </Button>
151
319
  ))}
152
320
  </div>
153
- }
154
- >
155
- <Grid serverData={serverData} category={category} />
156
- </Suspense>
321
+ </nav>
322
+
323
+ <Suspense
324
+ fallback={
325
+ <div className="grid grid-cols-2 gap-x-3 gap-y-7 sm:gap-x-5 lg:grid-cols-4">
326
+ {Array.from({ length: 8 }).map((_, index) => (
327
+ <Card key={index} className="overflow-hidden">
328
+ <Skeleton className="aspect-[4/5] rounded-none" />
329
+ <div className="flex flex-col gap-3 p-4">
330
+ <Skeleton className="h-3 w-2/5" />
331
+ <Skeleton className="h-9" />
332
+ <Skeleton className="h-4 w-1/3" />
333
+ </div>
334
+ </Card>
335
+ ))}
336
+ </div>
337
+ }
338
+ >
339
+ <Grid
340
+ serverData={serverData}
341
+ category={category}
342
+ query={query}
343
+ sort={sort}
344
+ />
345
+ </Suspense>
346
+ </section>
157
347
  </div>
158
348
  );
159
349
  }
@@ -3,21 +3,23 @@ import { type Metadata } from "@pylonsync/react";
3
3
  import { SellForm } from "../../client/SellForm";
4
4
 
5
5
  export const metadata: Metadata = {
6
- title: "Sell an item · Pylon Market",
7
- description: "List something for sale in seconds buyers' offers arrive live.",
6
+ title: "Sell an item | Reprise",
7
+ description: "Create a polished listing and receive buyer offers in real time.",
8
8
  };
9
9
 
10
10
  export default function SellPage() {
11
11
  return (
12
- <div className="mx-auto max-w-xl space-y-6">
13
- <header className="space-y-1">
14
- <h1 className="text-2xl font-semibold tracking-tight">List an item</h1>
15
- <p className="text-sm text-muted-foreground">
16
- It goes live instantly. Offers land in your{" "}
12
+ <div className="mx-auto flex max-w-2xl flex-col gap-8 py-2 sm:py-8">
13
+ <header className="flex flex-col gap-2">
14
+ <h1 className="text-balance text-3xl font-semibold tracking-[-0.03em]">
15
+ List something worth finding
16
+ </h1>
17
+ <p className="max-w-xl text-pretty text-sm leading-6 text-muted-foreground">
18
+ Your listing goes live instantly. Offers arrive in your{" "}
17
19
  <a href="/me" className="underline">
18
- My Market
20
+ dashboard
19
21
  </a>{" "}
20
- inbox in realtime.
22
+ in real time.
21
23
  </p>
22
24
  </header>
23
25
  <SellForm />
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Pylon Market — a live local marketplace.
2
+ * Reprise — a live resale marketplace.
3
3
  *
4
4
  * Anyone (a guest session) can list an item for sale; anyone else can make
5
5
  * an offer. Sellers watch offers arrive in realtime and accept/decline them.
@@ -40,9 +40,9 @@ const User = entity(
40
40
  },
41
41
  );
42
42
 
43
- // A thing for sale. `seed` drives a deterministic gradient "photo" so the
44
- // demo needs no image hosting. `status` flips active sold when an offer
45
- // is accepted.
43
+ // A thing for sale. `imageUrl` is optional so listings created before the
44
+ // photo field was added still render with a deterministic fallback.
45
+ // `status` flips active → sold when an offer is accepted.
46
46
  //
47
47
  // `sellerId: field.owner()` is what lets SellForm create a listing with a
48
48
  // plain, optimistic `db.insert` (it shows in the live ticker the instant
@@ -64,6 +64,7 @@ const Listing = entity(
64
64
  category: field.string(),
65
65
  condition: field.string(), // new | like-new | good | fair
66
66
  status: field.string().default("active"), // active | sold
67
+ imageUrl: field.string().optional(),
67
68
  seed: field.string(),
68
69
  createdAt: field.datetime().defaultNow(),
69
70
  },
@@ -2,7 +2,7 @@
2
2
 
3
3
  import React from "react";
4
4
  import { Link } from "@pylonsync/react";
5
- import { Button } from "../ui/button";
5
+ import { Button } from "@/components/ui/button";
6
6
  import { MarketProvider, useAuth } from "./MarketProvider";
7
7
 
8
8
  // Compact sign-in state for the header. Signed out → a link to /sell (which
@@ -12,12 +12,9 @@ function Nav() {
12
12
  const { identity, signOut } = useAuth();
13
13
  if (!identity) {
14
14
  return (
15
- <Link
16
- href="/sell"
17
- className="rounded-md border px-3 py-1.5 text-sm font-medium transition hover:bg-muted"
18
- >
19
- Sign in
20
- </Link>
15
+ <Button asChild variant="outline" size="sm">
16
+ <Link href="/sell">Sign in</Link>
17
+ </Button>
21
18
  );
22
19
  }
23
20
  return (
@@ -28,7 +25,6 @@ function Nav() {
28
25
  <Button
29
26
  variant="ghost"
30
27
  size="sm"
31
- className="text-muted-foreground"
32
28
  onClick={() => void signOut()}
33
29
  >
34
30
  Sign out
@@ -0,0 +1,60 @@
1
+ "use client";
2
+
3
+ import React, { useEffect, useState } from "react";
4
+ import { db } from "@pylonsync/react";
5
+ import { bootClient, type Listing } from "./market";
6
+
7
+ export function LiveListingStatus({
8
+ listingId,
9
+ initialStatus,
10
+ mode = "text",
11
+ }: {
12
+ listingId: string;
13
+ initialStatus: Listing["status"];
14
+ mode?: "text" | "overlay";
15
+ }) {
16
+ const [mounted, setMounted] = useState(false);
17
+ useEffect(() => {
18
+ bootClient();
19
+ setMounted(true);
20
+ }, []);
21
+
22
+ if (!mounted) {
23
+ return initialStatus === "sold" ? (
24
+ mode === "overlay" ? <SoldOverlay /> : <>Sold</>
25
+ ) : mode === "text" ? (
26
+ <>Open to offers</>
27
+ ) : null;
28
+ }
29
+
30
+ return (
31
+ <LiveStatus
32
+ listingId={listingId}
33
+ initialStatus={initialStatus}
34
+ mode={mode}
35
+ />
36
+ );
37
+ }
38
+
39
+ function LiveStatus({
40
+ listingId,
41
+ initialStatus,
42
+ mode,
43
+ }: {
44
+ listingId: string;
45
+ initialStatus: Listing["status"];
46
+ mode: "text" | "overlay";
47
+ }) {
48
+ const { data } = db.useQueryOne<Listing>("Listing", listingId);
49
+ const sold = (data?.status ?? initialStatus) === "sold";
50
+ if (mode === "overlay") return sold ? <SoldOverlay /> : null;
51
+ return <>{sold ? "Sold" : "Open to offers"}</>;
52
+ }
53
+
54
+ function SoldOverlay() {
55
+ return (
56
+ <span className="absolute inset-0 grid place-items-center bg-black/55 text-2xl font-semibold text-white">
57
+ Sold
58
+ </span>
59
+ );
60
+ }