@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
@@ -1,11 +1,31 @@
1
1
  "use client";
2
2
 
3
3
  import React, { useState } from "react";
4
- import { db } from "@pylonsync/react";
5
- import { Button } from "../ui/button";
6
- import { Input } from "../ui/input";
7
- import { Textarea } from "../ui/textarea";
8
- import { Badge } from "../ui/badge";
4
+ import { callFn, db } from "@pylonsync/react";
5
+ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
6
+ import { Badge } from "@/components/ui/badge";
7
+ import { Button } from "@/components/ui/button";
8
+ import {
9
+ Card,
10
+ CardContent,
11
+ CardHeader,
12
+ CardTitle,
13
+ } from "@/components/ui/card";
14
+ import {
15
+ Empty,
16
+ EmptyDescription,
17
+ EmptyHeader,
18
+ EmptyTitle,
19
+ } from "@/components/ui/empty";
20
+ import {
21
+ Field,
22
+ FieldGroup,
23
+ FieldLabel,
24
+ FieldSeparator,
25
+ } from "@/components/ui/field";
26
+ import { Input } from "@/components/ui/input";
27
+ import { Spinner } from "@/components/ui/spinner";
28
+ import { Textarea } from "@/components/ui/textarea";
9
29
  import { AuthGate, MarketProvider, useIdentity } from "./MarketProvider";
10
30
  import { money, timeAgo, type Offer } from "./market";
11
31
 
@@ -16,6 +36,7 @@ interface Props {
16
36
  title: string;
17
37
  price: number;
18
38
  status: "active" | "sold";
39
+ initialOffers?: Offer[];
19
40
  }
20
41
 
21
42
  type BadgeVariant = "default" | "secondary" | "destructive" | "outline" | "success" | "warning";
@@ -30,16 +51,23 @@ function Panel(props: Props) {
30
51
  const { listingId, sellerId, price } = props;
31
52
  const identity = useIdentity();
32
53
  const isSeller = !!identity && identity.userId === sellerId;
33
- const isSold = props.status === "sold";
34
54
 
35
55
  // The live query — every offer on this listing, newest first. Reads are
36
56
  // public, so this runs for signed-out visitors too; it just lights up the
37
57
  // moment a buyer in another tab makes an offer.
38
58
  const { data } = db.useQuery<Offer>("Offer", {
39
- where: { listingId },
40
59
  orderBy: { createdAt: "desc" },
41
60
  });
42
- const offers = data ?? [];
61
+ const offers = Array.from(
62
+ new Map(
63
+ [...(props.initialOffers ?? []), ...(data ?? [])].map((offer) => [
64
+ offer.id,
65
+ offer,
66
+ ]),
67
+ ).values(),
68
+ ).filter((offer) => offer.listingId === listingId);
69
+ const isSold =
70
+ props.status === "sold" || offers.some((offer) => offer.status === "accepted");
43
71
  const myOffer = identity
44
72
  ? offers.find((o) => o.buyerId === identity.userId)
45
73
  : undefined;
@@ -51,7 +79,7 @@ function Panel(props: Props) {
51
79
  return (
52
80
  <AuthGate
53
81
  title="Sign in to make an offer"
54
- blurb="Offers are tied to a real account so the seller knows who's bidding. The demo account is prefilled just hit Log in."
82
+ blurb="Offers are tied to a real account so the seller knows who is bidding. The demo account is ready; just select Log in."
55
83
  >
56
84
  <BuyerView
57
85
  {...props}
@@ -65,94 +93,112 @@ function Panel(props: Props) {
65
93
 
66
94
  function SellerView({ offers }: { offers: Offer[] }) {
67
95
  const [busy, setBusy] = useState<string | null>(null);
96
+ const [confirming, setConfirming] = useState<string | null>(null);
68
97
  const [err, setErr] = useState<string | null>(null);
69
98
  const pending = offers.filter((o) => o.status === "pending");
70
99
 
71
- // Optimistic accept/decline: flip the offer's status in the local store
72
- // immediately so the seller's list updates the instant they click. The
73
- // server (respondToOffer) reconciles the rest — marking the listing sold and
74
- // declining the sibling offers — when its broadcast lands.
75
- const respondMutation = db.useMutation<{ offerId: string; accept: boolean }>(
76
- "respondToOffer",
77
- {
78
- optimistic: (args) => {
79
- const o = offers.find((x) => x.id === args.offerId);
80
- return o
81
- ? [
82
- {
83
- entity: "Offer",
84
- data: { ...o, status: args.accept ? "accepted" : "declined" },
85
- },
86
- ]
87
- : [];
88
- },
89
- },
90
- );
91
-
92
100
  async function respond(offerId: string, accept: boolean) {
93
101
  setBusy(offerId);
94
102
  setErr(null);
95
103
  try {
96
- await respondMutation.mutate({ offerId, accept });
104
+ // Use the direct function transport for seller decisions. It remains
105
+ // reliable across dev-server reconnects, while the live query applies
106
+ // the atomic offer + listing updates as soon as the server broadcasts.
107
+ await callFn("respondToOffer", { offerId, accept });
97
108
  } catch (e) {
98
109
  setErr((e as Error).message ?? "Could not respond to offer.");
99
110
  } finally {
100
111
  setBusy(null);
112
+ setConfirming(null);
101
113
  }
102
114
  }
103
115
 
104
116
  return (
105
- <div className="space-y-3">
117
+ <div className="flex flex-col gap-3">
106
118
  <div className="flex items-center justify-between">
107
119
  <h2 className="font-semibold">Offers on your listing</h2>
108
120
  <Badge variant="outline">{pending.length} pending</Badge>
109
121
  </div>
110
- {err ? <p className="text-sm text-destructive">{err}</p> : null}
122
+ <div aria-live="polite">
123
+ {err ? (
124
+ <Alert variant="destructive">
125
+ <AlertDescription>{err}</AlertDescription>
126
+ </Alert>
127
+ ) : null}
128
+ </div>
111
129
  {offers.length === 0 ? (
112
- <p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
113
- No offers yet. They'll show up here the moment a buyer makes one —
114
- live, no refresh.
115
- </p>
130
+ <Empty className="border-0 bg-muted py-6">
131
+ <EmptyHeader>
132
+ <EmptyTitle className="text-base">No offers yet</EmptyTitle>
133
+ <EmptyDescription>
134
+ They will appear here as soon as a buyer sends one.
135
+ </EmptyDescription>
136
+ </EmptyHeader>
137
+ </Empty>
116
138
  ) : (
117
- <ul className="space-y-2">
139
+ <ul className="flex flex-col gap-2">
118
140
  {offers.map((o) => (
119
- <li
120
- key={o.id}
121
- className="flex items-center justify-between gap-3 rounded-lg border bg-card p-3"
122
- >
141
+ <li key={o.id}>
142
+ <Card className="flex items-center justify-between gap-3 rounded-lg p-3">
123
143
  <div className="min-w-0">
124
144
  <div className="flex items-center gap-2">
125
145
  <span className="text-lg font-semibold tabular-nums">
126
146
  {money(o.amount)}
127
147
  </span>
128
- <Badge variant={statusVariant[o.status] ?? "outline"}>
148
+ <Badge
149
+ variant={statusVariant[o.status] ?? "outline"}
150
+ className="capitalize"
151
+ >
129
152
  {o.status}
130
153
  </Badge>
131
154
  </div>
132
155
  <p className="truncate text-sm text-muted-foreground">
133
156
  {o.buyerName} · {timeAgo(o.createdAt)}
134
- {o.message ? ` · "${o.message}"` : ""}
157
+ {o.message ? ` · “${o.message}”` : ""}
135
158
  </p>
136
159
  </div>
137
160
  {o.status === "pending" ? (
138
161
  <div className="flex shrink-0 gap-2">
139
- <Button
140
- size="sm"
141
- disabled={busy === o.id}
142
- onClick={() => respond(o.id, true)}
143
- >
144
- Accept
145
- </Button>
146
- <Button
147
- size="sm"
148
- variant="outline"
149
- disabled={busy === o.id}
150
- onClick={() => respond(o.id, false)}
151
- >
152
- Decline
153
- </Button>
162
+ {confirming === o.id ? (
163
+ <>
164
+ <Button
165
+ size="sm"
166
+ disabled={busy === o.id}
167
+ onClick={() => respond(o.id, true)}
168
+ >
169
+ Confirm
170
+ </Button>
171
+ <Button
172
+ size="sm"
173
+ variant="ghost"
174
+ disabled={busy === o.id}
175
+ onClick={() => setConfirming(null)}
176
+ >
177
+ Cancel
178
+ </Button>
179
+ </>
180
+ ) : (
181
+ <Button
182
+ size="sm"
183
+ disabled={busy === o.id}
184
+ onClick={() => setConfirming(o.id)}
185
+ >
186
+ Accept
187
+ </Button>
188
+ )}
189
+ {confirming !== o.id ? (
190
+ <Button
191
+ size="sm"
192
+ variant="outline"
193
+ disabled={busy === o.id}
194
+ onClick={() => respond(o.id, false)}
195
+ >
196
+ Decline
197
+ </Button>
198
+ ) : null}
154
199
  </div>
155
200
  ) : null}
201
+ </Card>
156
202
  </li>
157
203
  ))}
158
204
  </ul>
@@ -176,6 +222,7 @@ function BuyerView({
176
222
  const name = identity?.name ?? "you";
177
223
  const [amount, setAmount] = useState(String(suggestedPrice));
178
224
  const [message, setMessage] = useState("");
225
+ const [confirmingBuy, setConfirmingBuy] = useState(false);
179
226
  const [err, setErr] = useState<string | null>(null);
180
227
 
181
228
  // Local-first optimism, baked in: db.useMutation paints the Offer into the
@@ -242,30 +289,42 @@ function BuyerView({
242
289
  // the offer is made.
243
290
  if (myOffer) {
244
291
  return (
245
- <div className="space-y-2 rounded-lg border bg-card p-4">
246
- <h2 className="font-semibold">Your offer</h2>
247
- <div className="flex items-center gap-2">
248
- <span className="text-2xl font-semibold tabular-nums">{money(myOffer.amount)}</span>
249
- <Badge variant={statusVariant[myOffer.status] ?? "outline"}>
250
- {myOffer.status}
251
- </Badge>
252
- </div>
253
- <p className="text-sm text-muted-foreground">
292
+ <Card>
293
+ <CardHeader className="pb-3">
294
+ <CardTitle>Your offer</CardTitle>
295
+ </CardHeader>
296
+ <CardContent className="flex flex-col gap-2">
297
+ <div className="flex items-center gap-2">
298
+ <span className="text-2xl font-semibold tabular-nums">
299
+ {money(myOffer.amount)}
300
+ </span>
301
+ <Badge
302
+ variant={statusVariant[myOffer.status] ?? "outline"}
303
+ className="capitalize"
304
+ >
305
+ {myOffer.status}
306
+ </Badge>
307
+ </div>
308
+ <p className="text-sm text-muted-foreground">
254
309
  {myOffer.status === "pending"
255
- ? `Sent to ${sellerName} you'll see their answer here live.`
310
+ ? `Sent to ${sellerName}. Their answer will appear here live.`
256
311
  : myOffer.status === "accepted"
257
- ? "🎉 Accepted! Arrange pickup with the seller."
312
+ ? "Accepted. Confirm payment and delivery with the seller."
258
313
  : "This offer was declined."}
259
- </p>
260
- </div>
314
+ </p>
315
+ </CardContent>
316
+ </Card>
261
317
  );
262
318
  }
263
319
 
264
320
  if (isSold) {
265
321
  return (
266
- <div className="rounded-lg border bg-card p-4 text-sm text-muted-foreground">
267
- This item has sold.
268
- </div>
322
+ <Alert>
323
+ <AlertTitle>This item has sold</AlertTitle>
324
+ <AlertDescription>
325
+ Browse other finds to discover something similar.
326
+ </AlertDescription>
327
+ </Alert>
269
328
  );
270
329
  }
271
330
 
@@ -287,53 +346,102 @@ function BuyerView({
287
346
  }
288
347
 
289
348
  return (
290
- <div className="space-y-4 rounded-lg border bg-card p-4">
291
- <div className="space-y-2">
292
- <Button
293
- type="button"
294
- onClick={buy}
295
- disabled={buyNow.loading}
296
- className="w-full"
297
- >
298
- {buyNow.loading ? "Buying…" : `Buy now — ${money(suggestedPrice)}`}
299
- </Button>
300
- <p className="text-center text-xs text-muted-foreground">
301
- Instant purchase at the asking price.
302
- </p>
349
+ <Card>
350
+ <CardContent className="flex flex-col gap-4 p-5">
351
+ <div className="flex flex-col gap-2">
352
+ {confirmingBuy ? (
353
+ <Alert>
354
+ <AlertTitle>
355
+ Buy this item for {money(suggestedPrice)}?
356
+ </AlertTitle>
357
+ <AlertDescription>
358
+ This accepts the asking price and marks the listing sold.
359
+ </AlertDescription>
360
+ <div className="mt-3 flex gap-2">
361
+ <Button
362
+ type="button"
363
+ size="sm"
364
+ onClick={buy}
365
+ disabled={buyNow.loading}
366
+ className="flex-1"
367
+ >
368
+ {buyNow.loading ? <Spinner data-icon="inline-start" /> : null}
369
+ {buyNow.loading ? "Buying…" : "Confirm purchase"}
370
+ </Button>
371
+ <Button
372
+ type="button"
373
+ size="sm"
374
+ variant="outline"
375
+ onClick={() => setConfirmingBuy(false)}
376
+ disabled={buyNow.loading}
377
+ >
378
+ Cancel
379
+ </Button>
380
+ </div>
381
+ </Alert>
382
+ ) : (
383
+ <>
384
+ <Button
385
+ type="button"
386
+ onClick={() => setConfirmingBuy(true)}
387
+ className="w-full"
388
+ >
389
+ Buy now for {money(suggestedPrice)}
390
+ </Button>
391
+ <p className="text-center text-xs text-muted-foreground">
392
+ Instant purchase at the asking price.
393
+ </p>
394
+ </>
395
+ )}
303
396
  </div>
304
397
 
305
- <div className="flex items-center gap-3 text-xs uppercase tracking-wide text-muted-foreground">
306
- <span className="h-px flex-1 bg-border" />
307
- or make an offer
308
- <span className="h-px flex-1 bg-border" />
309
- </div>
398
+ <FieldSeparator>or make an offer</FieldSeparator>
310
399
 
311
- <form onSubmit={submit} className="space-y-3">
312
- <div className="flex items-center gap-2">
313
- <span className="text-muted-foreground">$</span>
314
- <Input
315
- type="number"
316
- min="1"
317
- step="1"
318
- value={amount}
319
- onChange={(e) => setAmount(e.target.value)}
320
- className="w-32"
321
- aria-label="Offer amount"
400
+ <form onSubmit={submit}>
401
+ <FieldGroup className="gap-3">
402
+ <Field>
403
+ <FieldLabel htmlFor="offer-amount">Your offer ($)</FieldLabel>
404
+ <Input
405
+ id="offer-amount"
406
+ name="offerAmount"
407
+ type="number"
408
+ inputMode="decimal"
409
+ autoComplete="off"
410
+ min="1"
411
+ step="1"
412
+ value={amount}
413
+ onChange={(e) => setAmount(e.target.value)}
414
+ className="max-w-32"
415
+ />
416
+ </Field>
417
+ <Field>
418
+ <FieldLabel htmlFor="offer-note">
419
+ Note <span className="font-normal text-muted-foreground">(optional)</span>
420
+ </FieldLabel>
421
+ <Textarea
422
+ id="offer-note"
423
+ name="offerNote"
424
+ autoComplete="off"
425
+ placeholder="Share any useful details…"
426
+ value={message}
427
+ onChange={(e) => setMessage(e.target.value)}
428
+ rows={2}
322
429
  />
430
+ </Field>
431
+ <div aria-live="polite">
432
+ {err ? (
433
+ <Alert variant="destructive">
434
+ <AlertDescription>{err}</AlertDescription>
435
+ </Alert>
436
+ ) : null}
323
437
  </div>
324
- <Textarea
325
- placeholder="Add a note (optional)…"
326
- value={message}
327
- onChange={(e) => setMessage(e.target.value)}
328
- rows={2}
329
- />
330
- {err ? <p className="text-sm text-destructive">{err}</p> : null}
331
438
  <Button
332
439
  type="submit"
333
440
  variant="outline"
334
441
  disabled={makeOffer.loading}
335
442
  className="w-full"
336
443
  >
444
+ {makeOffer.loading ? <Spinner data-icon="inline-start" /> : null}
337
445
  {makeOffer.loading
338
446
  ? "Sending…"
339
447
  : `Offer ${money(Number.parseFloat(amount) || 0)}`}
@@ -341,8 +449,10 @@ function BuyerView({
341
449
  <p className="text-center text-xs text-muted-foreground">
342
450
  You're bidding as <span className="font-medium">{name}</span>
343
451
  </p>
452
+ </FieldGroup>
344
453
  </form>
345
- </div>
454
+ </CardContent>
455
+ </Card>
346
456
  );
347
457
  }
348
458
 
@@ -0,0 +1,29 @@
1
+ "use client";
2
+
3
+ import React from "react";
4
+ import { Button } from "@/components/ui/button";
5
+
6
+ export function ScrollToListingsLink({
7
+ children,
8
+ className,
9
+ }: {
10
+ children: React.ReactNode;
11
+ className?: string;
12
+ }) {
13
+ return (
14
+ <Button asChild className={className}>
15
+ <a
16
+ href="#listings"
17
+ onClick={(event) => {
18
+ const listings = document.getElementById("listings");
19
+ if (!listings) return;
20
+ event.preventDefault();
21
+ history.replaceState(null, "", "#listings");
22
+ listings.scrollIntoView({ behavior: "smooth", block: "start" });
23
+ }}
24
+ >
25
+ {children}
26
+ </a>
27
+ </Button>
28
+ );
29
+ }
@@ -5,20 +5,21 @@ import { ensureDemoSeed, ensureReadSession } from "./market";
5
5
 
6
6
  // First-run convenience: if the marketplace is empty, ensure the demo account
7
7
  // + seed a dozen listings under it, then reload once so the server-rendered
8
- // grid picks them up. Guarded by a session flag so it never loops. Real apps
9
- // wouldn't ship this; it just makes `pylon dev` show something on first visit.
8
+ // grid picks them up. A short retry window prevents reload loops while still
9
+ // recovering when the dev database is reset but browser storage survives.
10
10
  export function SeedOnEmpty({ count }: { count: number }) {
11
11
  const fired = useRef(false);
12
12
  useEffect(() => {
13
13
  if (count > 0 || fired.current) return;
14
- if (sessionStorage.getItem("market:seeded") === "1") return;
14
+ const lastAttempt = Number(sessionStorage.getItem("market:seed-attempted-at"));
15
+ if (Number.isFinite(lastAttempt) && Date.now() - lastAttempt < 10_000) return;
15
16
  fired.current = true;
16
- sessionStorage.setItem("market:seeded", "1");
17
+ sessionStorage.setItem("market:seed-attempted-at", String(Date.now()));
17
18
  void (async () => {
18
19
  await ensureReadSession();
19
- await ensureDemoSeed();
20
+ await ensureDemoSeed({ force: true });
20
21
  // The seed inserts listings owned by the demo user; reload so the SSR
21
- // grid renders them. The session flag prevents a reload loop.
22
+ // grid renders them.
22
23
  window.location.reload();
23
24
  })();
24
25
  }, [count]);