@pylonsync/create-pylon 0.3.357 → 0.3.359
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.
- package/package.json +1 -1
- package/templates/marketplace/README.md +29 -14
- package/templates/marketplace/app/error.tsx +30 -16
- package/templates/marketplace/app/globals.css +140 -24
- package/templates/marketplace/app/layout.tsx +60 -31
- package/templates/marketplace/app/listing/[id]/page.tsx +160 -60
- package/templates/marketplace/app/me/page.tsx +3 -3
- package/templates/marketplace/app/not-found.tsx +19 -10
- package/templates/marketplace/app/page.tsx +287 -97
- package/templates/marketplace/app/sell/page.tsx +11 -9
- package/templates/marketplace/app.ts +5 -4
- package/templates/marketplace/client/AuthNav.tsx +4 -8
- package/templates/marketplace/client/LiveListingStatus.tsx +60 -0
- package/templates/marketplace/client/LiveTicker.tsx +22 -10
- package/templates/marketplace/client/LoginCard.tsx +108 -62
- package/templates/marketplace/client/MarketProvider.tsx +16 -2
- package/templates/marketplace/client/MyMarket.tsx +126 -56
- package/templates/marketplace/client/OfferPanel.tsx +222 -112
- package/templates/marketplace/client/ScrollToListingsLink.tsx +29 -0
- package/templates/marketplace/client/SeedOnEmpty.tsx +7 -6
- package/templates/marketplace/client/SellForm.tsx +198 -40
- package/templates/marketplace/client/ThemeToggle.tsx +62 -0
- package/templates/marketplace/client/WatchButton.tsx +19 -8
- package/templates/marketplace/client/market.ts +10 -4
- package/templates/marketplace/components/ui/alert.tsx +66 -0
- package/templates/marketplace/components/ui/badge.tsx +5 -1
- package/templates/marketplace/components/ui/button.tsx +9 -7
- package/templates/marketplace/components/ui/card.tsx +2 -2
- package/templates/marketplace/components/ui/empty.tsx +104 -0
- package/templates/marketplace/components/ui/field.tsx +246 -0
- package/templates/marketplace/components/ui/input.tsx +2 -2
- package/templates/marketplace/components/ui/label.tsx +4 -3
- package/templates/marketplace/components/ui/native-select.tsx +62 -0
- package/templates/marketplace/components/ui/separator.tsx +26 -0
- package/templates/marketplace/components/ui/skeleton.tsx +13 -0
- package/templates/marketplace/components/ui/spinner.tsx +16 -0
- package/templates/marketplace/components/ui/textarea.tsx +2 -2
- package/templates/marketplace/{ui → components/ui}/tokens.css +1 -1
- package/templates/marketplace/components.json +20 -0
- package/templates/marketplace/functions/seedMarket.ts +14 -12
- package/templates/marketplace/lib/catalog.ts +32 -0
- package/templates/marketplace/package.json +10 -9
- package/templates/marketplace/public/images/marketplace-hero.webp +0 -0
- package/templates/marketplace/tests/example.test.ts +76 -8
- package/templates/marketplace/tsconfig.json +13 -2
- package/templates/marketplace/components/ui/select.tsx +0 -39
- package/templates/marketplace/ui/badge.tsx +0 -30
- package/templates/marketplace/ui/button.tsx +0 -49
- package/templates/marketplace/ui/card.tsx +0 -48
- package/templates/marketplace/ui/input.tsx +0 -17
- package/templates/marketplace/ui/label.tsx +0 -18
- package/templates/marketplace/ui/textarea.tsx +0 -17
- 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 {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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="
|
|
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
|
-
|
|
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
|
-
<
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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="
|
|
139
|
+
<ul className="flex flex-col gap-2">
|
|
118
140
|
{offers.map((o) => (
|
|
119
|
-
<li
|
|
120
|
-
|
|
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
|
|
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 ? ` ·
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
<
|
|
246
|
-
<
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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}
|
|
310
|
+
? `Sent to ${sellerName}. Their answer will appear here live.`
|
|
256
311
|
: myOffer.status === "accepted"
|
|
257
|
-
? "
|
|
312
|
+
? "Accepted. Confirm payment and delivery with the seller."
|
|
258
313
|
: "This offer was declined."}
|
|
259
|
-
|
|
260
|
-
|
|
314
|
+
</p>
|
|
315
|
+
</CardContent>
|
|
316
|
+
</Card>
|
|
261
317
|
);
|
|
262
318
|
}
|
|
263
319
|
|
|
264
320
|
if (isSold) {
|
|
265
321
|
return (
|
|
266
|
-
<
|
|
267
|
-
This item has sold
|
|
268
|
-
|
|
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
|
-
<
|
|
291
|
-
<
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
<
|
|
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}
|
|
312
|
-
<
|
|
313
|
-
<
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
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.
|
|
9
|
-
//
|
|
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
|
-
|
|
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:
|
|
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.
|
|
22
|
+
// grid renders them.
|
|
22
23
|
window.location.reload();
|
|
23
24
|
})();
|
|
24
25
|
}, [count]);
|