@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
|
@@ -2,12 +2,32 @@
|
|
|
2
2
|
|
|
3
3
|
import React, { useState } from "react";
|
|
4
4
|
import { db, useRouter } from "@pylonsync/react";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
5
|
+
import { ImagePlus } from "lucide-react";
|
|
6
|
+
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
7
|
+
import { Button } from "@/components/ui/button";
|
|
8
|
+
import {
|
|
9
|
+
Card,
|
|
10
|
+
CardContent,
|
|
11
|
+
CardDescription,
|
|
12
|
+
CardHeader,
|
|
13
|
+
CardTitle,
|
|
14
|
+
} from "@/components/ui/card";
|
|
15
|
+
import {
|
|
16
|
+
Field,
|
|
17
|
+
FieldDescription,
|
|
18
|
+
FieldGroup,
|
|
19
|
+
FieldLabel,
|
|
20
|
+
FieldSeparator,
|
|
21
|
+
} from "@/components/ui/field";
|
|
22
|
+
import { Input } from "@/components/ui/input";
|
|
23
|
+
import {
|
|
24
|
+
NativeSelect,
|
|
25
|
+
NativeSelectOption,
|
|
26
|
+
} from "@/components/ui/native-select";
|
|
27
|
+
import { Spinner } from "@/components/ui/spinner";
|
|
28
|
+
import { Textarea } from "@/components/ui/textarea";
|
|
9
29
|
import { AuthGate, MarketProvider, useIdentity } from "./MarketProvider";
|
|
10
|
-
import { makeSlug } from "./market";
|
|
30
|
+
import { conditionLabel, makeSlug } from "./market";
|
|
11
31
|
|
|
12
32
|
const CATEGORIES = [
|
|
13
33
|
"furniture", "electronics", "cameras", "bikes", "audio", "kitchen",
|
|
@@ -15,8 +35,34 @@ const CATEGORIES = [
|
|
|
15
35
|
];
|
|
16
36
|
const CONDITIONS = ["new", "like-new", "good", "fair"];
|
|
17
37
|
|
|
18
|
-
|
|
19
|
-
|
|
38
|
+
async function uploadListingPhoto(file: File) {
|
|
39
|
+
const initResponse = await fetch("/api/files/init", {
|
|
40
|
+
method: "POST",
|
|
41
|
+
headers: { "Content-Type": "application/json" },
|
|
42
|
+
body: JSON.stringify({
|
|
43
|
+
filename: file.name,
|
|
44
|
+
mimeType: file.type,
|
|
45
|
+
size: file.size,
|
|
46
|
+
}),
|
|
47
|
+
});
|
|
48
|
+
if (!initResponse.ok) throw new Error("Could not prepare that upload.");
|
|
49
|
+
const init = await initResponse.json() as { assetId: string; uploadUrl: string };
|
|
50
|
+
|
|
51
|
+
const uploadResponse = await fetch(init.uploadUrl, {
|
|
52
|
+
method: "PUT",
|
|
53
|
+
headers: { "Content-Type": file.type },
|
|
54
|
+
body: file,
|
|
55
|
+
});
|
|
56
|
+
if (!uploadResponse.ok) throw new Error("Could not upload that photo.");
|
|
57
|
+
|
|
58
|
+
const confirmResponse = await fetch("/api/files/confirm", {
|
|
59
|
+
method: "POST",
|
|
60
|
+
headers: { "Content-Type": "application/json" },
|
|
61
|
+
body: JSON.stringify({ assetId: init.assetId }),
|
|
62
|
+
});
|
|
63
|
+
if (!confirmResponse.ok) throw new Error("Could not finish that upload.");
|
|
64
|
+
return confirmResponse.json() as Promise<{ id: string; url: string; size: number }>;
|
|
65
|
+
}
|
|
20
66
|
|
|
21
67
|
function Form() {
|
|
22
68
|
// Rendered inside <AuthGate>, so identity is guaranteed non-null here.
|
|
@@ -26,16 +72,50 @@ function Form() {
|
|
|
26
72
|
const router = useRouter();
|
|
27
73
|
const [title, setTitle] = useState("");
|
|
28
74
|
const [description, setDescription] = useState("");
|
|
75
|
+
const [imageUrl, setImageUrl] = useState("");
|
|
29
76
|
const [price, setPrice] = useState("");
|
|
30
77
|
const [category, setCategory] = useState(CATEGORIES[0]);
|
|
31
78
|
const [condition, setCondition] = useState("good");
|
|
79
|
+
const [photoBusy, setPhotoBusy] = useState(false);
|
|
32
80
|
const [busy, setBusy] = useState(false);
|
|
33
81
|
const [err, setErr] = useState<string | null>(null);
|
|
34
82
|
|
|
83
|
+
async function selectPhoto(e: React.ChangeEvent<HTMLInputElement>) {
|
|
84
|
+
const file = e.target.files?.[0];
|
|
85
|
+
e.target.value = "";
|
|
86
|
+
if (!file) return;
|
|
87
|
+
if (!file.type.startsWith("image/")) {
|
|
88
|
+
setErr("Choose an image file.");
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (file.size > 8 * 1024 * 1024) {
|
|
92
|
+
setErr("Choose an image smaller than 8 MB.");
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
setPhotoBusy(true);
|
|
96
|
+
setErr(null);
|
|
97
|
+
try {
|
|
98
|
+
const uploaded = await uploadListingPhoto(file);
|
|
99
|
+
setImageUrl(uploaded.url);
|
|
100
|
+
} catch (e) {
|
|
101
|
+
setErr((e as Error).message ?? "Could not upload that photo.");
|
|
102
|
+
} finally {
|
|
103
|
+
setPhotoBusy(false);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
35
107
|
async function submit(e: React.FormEvent) {
|
|
36
108
|
e.preventDefault();
|
|
37
109
|
const value = Number.parseFloat(price);
|
|
38
110
|
if (!title.trim()) return setErr("Give your item a title.");
|
|
111
|
+
if (!imageUrl.startsWith("/")) {
|
|
112
|
+
try {
|
|
113
|
+
const photo = new URL(imageUrl);
|
|
114
|
+
if (!["http:", "https:"].includes(photo.protocol)) throw new Error();
|
|
115
|
+
} catch {
|
|
116
|
+
return setErr("Upload a photo or add a valid photo URL.");
|
|
117
|
+
}
|
|
118
|
+
}
|
|
39
119
|
if (!Number.isFinite(value) || value < 0) return setErr("Set a price.");
|
|
40
120
|
setBusy(true);
|
|
41
121
|
setErr(null);
|
|
@@ -59,6 +139,7 @@ function Form() {
|
|
|
59
139
|
category,
|
|
60
140
|
condition,
|
|
61
141
|
status: "active",
|
|
142
|
+
imageUrl,
|
|
62
143
|
seed,
|
|
63
144
|
createdAt: new Date().toISOString(),
|
|
64
145
|
});
|
|
@@ -70,78 +151,145 @@ function Form() {
|
|
|
70
151
|
}
|
|
71
152
|
|
|
72
153
|
return (
|
|
73
|
-
<form onSubmit={submit}
|
|
74
|
-
<
|
|
75
|
-
|
|
154
|
+
<form onSubmit={submit}>
|
|
155
|
+
<FieldGroup className="gap-5">
|
|
156
|
+
<Field>
|
|
157
|
+
<FieldLabel htmlFor="title">Title</FieldLabel>
|
|
76
158
|
<Input
|
|
77
159
|
id="title"
|
|
160
|
+
name="title"
|
|
161
|
+
autoComplete="off"
|
|
78
162
|
value={title}
|
|
79
163
|
onChange={(e) => setTitle(e.target.value)}
|
|
80
164
|
placeholder="e.g. Herman Miller Aeron, size B"
|
|
81
165
|
/>
|
|
82
|
-
</
|
|
83
|
-
<
|
|
84
|
-
<
|
|
166
|
+
</Field>
|
|
167
|
+
<Field>
|
|
168
|
+
<FieldLabel htmlFor="description">Description</FieldLabel>
|
|
85
169
|
<Textarea
|
|
86
170
|
id="description"
|
|
171
|
+
name="description"
|
|
172
|
+
autoComplete="off"
|
|
87
173
|
value={description}
|
|
88
174
|
onChange={(e) => setDescription(e.target.value)}
|
|
89
175
|
placeholder="Condition details, dimensions, why you're selling…"
|
|
90
176
|
rows={4}
|
|
91
177
|
/>
|
|
92
|
-
</
|
|
178
|
+
</Field>
|
|
179
|
+
<Field>
|
|
180
|
+
<FieldLabel htmlFor="listing-photo">Photo</FieldLabel>
|
|
181
|
+
<label
|
|
182
|
+
htmlFor="listing-photo"
|
|
183
|
+
className="flex min-h-28 cursor-pointer flex-col items-center justify-center gap-2 rounded-xl border border-dashed border-border bg-muted/40 px-5 py-6 text-center transition-colors hover:bg-muted/70 focus-within:ring-2 focus-within:ring-ring"
|
|
184
|
+
>
|
|
185
|
+
<ImagePlus aria-hidden="true" className="size-5 text-muted-foreground" />
|
|
186
|
+
<span className="text-sm font-medium">
|
|
187
|
+
{photoBusy ? "Uploading photo…" : imageUrl ? "Replace photo" : "Upload a photo"}
|
|
188
|
+
</span>
|
|
189
|
+
<span className="text-xs text-muted-foreground">JPG, PNG, or WebP up to 8 MB</span>
|
|
190
|
+
<Input
|
|
191
|
+
id="listing-photo"
|
|
192
|
+
name="photo"
|
|
193
|
+
type="file"
|
|
194
|
+
accept="image/jpeg,image/png,image/webp"
|
|
195
|
+
onChange={selectPhoto}
|
|
196
|
+
disabled={photoBusy}
|
|
197
|
+
className="sr-only"
|
|
198
|
+
/>
|
|
199
|
+
</label>
|
|
200
|
+
<FieldSeparator>or use a link</FieldSeparator>
|
|
201
|
+
<FieldLabel htmlFor="imageUrl" className="sr-only">
|
|
202
|
+
Photo URL
|
|
203
|
+
</FieldLabel>
|
|
204
|
+
<Input
|
|
205
|
+
id="imageUrl"
|
|
206
|
+
name="imageUrl"
|
|
207
|
+
type="url"
|
|
208
|
+
autoComplete="off"
|
|
209
|
+
spellCheck={false}
|
|
210
|
+
value={imageUrl}
|
|
211
|
+
onChange={(e) => setImageUrl(e.target.value)}
|
|
212
|
+
placeholder="https://example.com/item.jpg"
|
|
213
|
+
aria-describedby="image-help"
|
|
214
|
+
/>
|
|
215
|
+
<FieldDescription id="image-help">
|
|
216
|
+
Clear, well-lit photos get more interest.
|
|
217
|
+
</FieldDescription>
|
|
218
|
+
{imageUrl ? (
|
|
219
|
+
<div className="mt-3 aspect-[16/10] overflow-hidden rounded-xl bg-muted shadow-[var(--shadow-border)]">
|
|
220
|
+
<img
|
|
221
|
+
src={imageUrl}
|
|
222
|
+
alt="Listing photo preview"
|
|
223
|
+
width="1200"
|
|
224
|
+
height="750"
|
|
225
|
+
className="h-full w-full object-cover outline outline-1 -outline-offset-1 outline-border"
|
|
226
|
+
/>
|
|
227
|
+
</div>
|
|
228
|
+
) : null}
|
|
229
|
+
</Field>
|
|
93
230
|
<div className="grid grid-cols-2 gap-4">
|
|
94
|
-
<
|
|
95
|
-
<
|
|
231
|
+
<Field>
|
|
232
|
+
<FieldLabel htmlFor="price">Price ($)</FieldLabel>
|
|
96
233
|
<Input
|
|
97
234
|
id="price"
|
|
235
|
+
name="price"
|
|
98
236
|
type="number"
|
|
237
|
+
inputMode="decimal"
|
|
238
|
+
autoComplete="off"
|
|
99
239
|
min="0"
|
|
100
240
|
step="1"
|
|
101
241
|
value={price}
|
|
102
242
|
onChange={(e) => setPrice(e.target.value)}
|
|
103
243
|
placeholder="0"
|
|
104
244
|
/>
|
|
105
|
-
</
|
|
106
|
-
<
|
|
107
|
-
<
|
|
108
|
-
<
|
|
245
|
+
</Field>
|
|
246
|
+
<Field>
|
|
247
|
+
<FieldLabel htmlFor="condition">Condition</FieldLabel>
|
|
248
|
+
<NativeSelect
|
|
109
249
|
id="condition"
|
|
250
|
+
name="condition"
|
|
110
251
|
value={condition}
|
|
111
252
|
onChange={(e) => setCondition(e.target.value)}
|
|
112
|
-
className={selectClass}
|
|
113
253
|
>
|
|
114
254
|
{CONDITIONS.map((c) => (
|
|
115
|
-
<
|
|
116
|
-
{c}
|
|
117
|
-
</
|
|
255
|
+
<NativeSelectOption key={c} value={c}>
|
|
256
|
+
{conditionLabel(c)}
|
|
257
|
+
</NativeSelectOption>
|
|
118
258
|
))}
|
|
119
|
-
</
|
|
120
|
-
</
|
|
259
|
+
</NativeSelect>
|
|
260
|
+
</Field>
|
|
121
261
|
</div>
|
|
122
|
-
<
|
|
123
|
-
<
|
|
124
|
-
<
|
|
262
|
+
<Field>
|
|
263
|
+
<FieldLabel htmlFor="category">Category</FieldLabel>
|
|
264
|
+
<NativeSelect
|
|
125
265
|
id="category"
|
|
266
|
+
name="category"
|
|
126
267
|
value={category}
|
|
127
268
|
onChange={(e) => setCategory(e.target.value)}
|
|
128
|
-
className={selectClass}
|
|
129
269
|
>
|
|
130
270
|
{CATEGORIES.map((c) => (
|
|
131
|
-
<
|
|
132
|
-
{c}
|
|
133
|
-
</
|
|
271
|
+
<NativeSelectOption key={c} value={c}>
|
|
272
|
+
{c[0]?.toUpperCase()}{c.slice(1)}
|
|
273
|
+
</NativeSelectOption>
|
|
134
274
|
))}
|
|
135
|
-
</
|
|
275
|
+
</NativeSelect>
|
|
276
|
+
</Field>
|
|
277
|
+
<div aria-live="polite">
|
|
278
|
+
{err ? (
|
|
279
|
+
<Alert variant="destructive">
|
|
280
|
+
<AlertDescription>{err}</AlertDescription>
|
|
281
|
+
</Alert>
|
|
282
|
+
) : null}
|
|
136
283
|
</div>
|
|
137
|
-
{
|
|
138
|
-
|
|
284
|
+
<Button type="submit" disabled={busy || photoBusy} className="w-full">
|
|
285
|
+
{busy ? <Spinner data-icon="inline-start" /> : null}
|
|
139
286
|
{busy ? "Posting…" : "Post listing"}
|
|
140
287
|
</Button>
|
|
141
288
|
<p className="text-center text-xs text-muted-foreground">
|
|
142
|
-
Posting as <span className="font-medium">{name}</span
|
|
143
|
-
offers land in <a href="/me" className="underline">
|
|
289
|
+
Posting as <span className="font-medium">{name}</span>. Buyers'
|
|
290
|
+
offers land in <a href="/me" className="underline">Dashboard</a>.
|
|
144
291
|
</p>
|
|
292
|
+
</FieldGroup>
|
|
145
293
|
</form>
|
|
146
294
|
);
|
|
147
295
|
}
|
|
@@ -151,9 +299,19 @@ export function SellForm() {
|
|
|
151
299
|
<MarketProvider>
|
|
152
300
|
<AuthGate
|
|
153
301
|
title="Sign in to list an item"
|
|
154
|
-
blurb="Selling needs an account so your listings
|
|
302
|
+
blurb="Selling needs an account so your listings stay tied to you. The demo account is ready; just select Log in."
|
|
155
303
|
>
|
|
156
|
-
<
|
|
304
|
+
<Card>
|
|
305
|
+
<CardHeader>
|
|
306
|
+
<CardTitle>Item details</CardTitle>
|
|
307
|
+
<CardDescription>
|
|
308
|
+
Add a clear photo and enough detail for buyers to decide quickly.
|
|
309
|
+
</CardDescription>
|
|
310
|
+
</CardHeader>
|
|
311
|
+
<CardContent>
|
|
312
|
+
<Form />
|
|
313
|
+
</CardContent>
|
|
314
|
+
</Card>
|
|
157
315
|
</AuthGate>
|
|
158
316
|
</MarketProvider>
|
|
159
317
|
);
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { Moon, Sun } from "lucide-react";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
|
|
7
|
+
const storageKey = "reprise:theme";
|
|
8
|
+
|
|
9
|
+
type Theme = "light" | "dark";
|
|
10
|
+
|
|
11
|
+
function preferredTheme(): Theme {
|
|
12
|
+
const stored = window.localStorage.getItem(storageKey);
|
|
13
|
+
if (stored === "light" || stored === "dark") return stored;
|
|
14
|
+
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
|
15
|
+
? "dark"
|
|
16
|
+
: "light";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function applyTheme(theme: Theme) {
|
|
20
|
+
document.documentElement.dataset.theme = theme;
|
|
21
|
+
document
|
|
22
|
+
.querySelectorAll<HTMLMetaElement>('meta[name="theme-color"]')
|
|
23
|
+
.forEach((meta) => {
|
|
24
|
+
meta.content = theme === "dark" ? "#181817" : "#f8f6f2";
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function ThemeToggle() {
|
|
29
|
+
const [theme, setTheme] = React.useState<Theme | null>(null);
|
|
30
|
+
|
|
31
|
+
React.useEffect(() => {
|
|
32
|
+
const nextTheme = preferredTheme();
|
|
33
|
+
applyTheme(nextTheme);
|
|
34
|
+
setTheme(nextTheme);
|
|
35
|
+
}, []);
|
|
36
|
+
|
|
37
|
+
function toggleTheme() {
|
|
38
|
+
const nextTheme = theme === "dark" ? "light" : "dark";
|
|
39
|
+
applyTheme(nextTheme);
|
|
40
|
+
window.localStorage.setItem(storageKey, nextTheme);
|
|
41
|
+
setTheme(nextTheme);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const isDark = theme === "dark";
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<Button
|
|
48
|
+
type="button"
|
|
49
|
+
variant="ghost"
|
|
50
|
+
size="icon"
|
|
51
|
+
aria-label={isDark ? "Use light theme" : "Use dark theme"}
|
|
52
|
+
title={isDark ? "Use light theme" : "Use dark theme"}
|
|
53
|
+
onClick={toggleTheme}
|
|
54
|
+
>
|
|
55
|
+
{isDark ? (
|
|
56
|
+
<Sun aria-hidden="true" />
|
|
57
|
+
) : (
|
|
58
|
+
<Moon aria-hidden="true" />
|
|
59
|
+
)}
|
|
60
|
+
</Button>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
import React, { useEffect, useState } from "react";
|
|
4
4
|
import { db } from "@pylonsync/react";
|
|
5
5
|
import { Heart } from "lucide-react";
|
|
6
|
-
import {
|
|
6
|
+
import { Button } from "@/components/ui/button";
|
|
7
|
+
import { cn } from "@/lib/utils";
|
|
7
8
|
import { bootClient, readIdentity, type Watch } from "./market";
|
|
8
9
|
|
|
9
10
|
// Heart toggle that saves a listing to your private watchlist. Self-contained
|
|
@@ -47,8 +48,13 @@ function Inner({
|
|
|
47
48
|
|
|
48
49
|
// Policy scopes reads to the caller, so this returns only MY watch (if any)
|
|
49
50
|
// for this listing.
|
|
50
|
-
const { data } = db.useQuery<Watch>("Watch", {
|
|
51
|
-
const mine = identity
|
|
51
|
+
const { data } = db.useQuery<Watch>("Watch", {});
|
|
52
|
+
const mine = identity
|
|
53
|
+
? data?.find(
|
|
54
|
+
(watch) =>
|
|
55
|
+
watch.userId === identity.userId && watch.listingId === listingId,
|
|
56
|
+
)
|
|
57
|
+
: undefined;
|
|
52
58
|
const watched = !!mine;
|
|
53
59
|
|
|
54
60
|
// No heart for signed-out visitors.
|
|
@@ -66,23 +72,28 @@ function Inner({
|
|
|
66
72
|
}
|
|
67
73
|
|
|
68
74
|
return (
|
|
69
|
-
<
|
|
75
|
+
<Button
|
|
70
76
|
type="button"
|
|
77
|
+
variant="outline"
|
|
78
|
+
size="icon"
|
|
71
79
|
onClick={toggle}
|
|
72
80
|
aria-pressed={watched}
|
|
73
81
|
aria-label={watched ? "Remove from watchlist" : "Save to watchlist"}
|
|
74
82
|
title={watched ? "Saved" : "Save to watchlist"}
|
|
75
83
|
className={cn(
|
|
76
|
-
"
|
|
84
|
+
"rounded-full bg-background/85 backdrop-blur",
|
|
77
85
|
className,
|
|
78
86
|
)}
|
|
79
87
|
>
|
|
80
88
|
<Heart
|
|
89
|
+
aria-hidden="true"
|
|
81
90
|
className={cn(
|
|
82
|
-
"
|
|
83
|
-
watched
|
|
91
|
+
"transition-[color,fill,scale] duration-200",
|
|
92
|
+
watched
|
|
93
|
+
? "scale-100 fill-foreground text-foreground"
|
|
94
|
+
: "scale-95 fill-transparent text-foreground/70",
|
|
84
95
|
)}
|
|
85
96
|
/>
|
|
86
|
-
</
|
|
97
|
+
</Button>
|
|
87
98
|
);
|
|
88
99
|
}
|
|
@@ -38,6 +38,7 @@ export interface Listing {
|
|
|
38
38
|
category: string;
|
|
39
39
|
condition: string;
|
|
40
40
|
status: "active" | "sold";
|
|
41
|
+
imageUrl?: string;
|
|
41
42
|
seed: string;
|
|
42
43
|
createdAt: string;
|
|
43
44
|
}
|
|
@@ -81,7 +82,7 @@ function hash(s: string): number {
|
|
|
81
82
|
return h >>> 0;
|
|
82
83
|
}
|
|
83
84
|
|
|
84
|
-
/** Deterministic
|
|
85
|
+
/** Deterministic visual fallback for listings whose remote image is unavailable. */
|
|
85
86
|
export function gradient(seed: string): string {
|
|
86
87
|
const h = hash(seed);
|
|
87
88
|
const a = h % 360;
|
|
@@ -263,7 +264,10 @@ export function readIdentity(): Identity | null {
|
|
|
263
264
|
|
|
264
265
|
/** Cache the freshest displayName (from the live User query) for instant UI. */
|
|
265
266
|
export function cacheDisplayName(name: string): void {
|
|
266
|
-
if (name
|
|
267
|
+
if (name && localStorage.getItem(DISPLAY_NAME) !== name) {
|
|
268
|
+
localStorage.setItem(DISPLAY_NAME, name);
|
|
269
|
+
window.dispatchEvent(new Event("pylon-auth-changed"));
|
|
270
|
+
}
|
|
267
271
|
}
|
|
268
272
|
|
|
269
273
|
/** Register an account (or log in if it already exists). Returns its token,
|
|
@@ -313,10 +317,12 @@ async function ensureAccount(
|
|
|
313
317
|
// and a premature reload could abort it mid-flight.
|
|
314
318
|
let seedPromise: Promise<void> | null = null;
|
|
315
319
|
|
|
316
|
-
export function ensureDemoSeed(): Promise<void> {
|
|
320
|
+
export function ensureDemoSeed(options: { force?: boolean } = {}): Promise<void> {
|
|
317
321
|
// Already seeded in a previous visit (accounts + catalog persist
|
|
318
322
|
// server-side) — nothing to do.
|
|
319
|
-
if (localStorage.getItem("market:demo-seeded") === "1")
|
|
323
|
+
if (!options.force && localStorage.getItem("market:demo-seeded") === "1") {
|
|
324
|
+
return Promise.resolve();
|
|
325
|
+
}
|
|
320
326
|
if (seedPromise) return seedPromise;
|
|
321
327
|
|
|
322
328
|
seedPromise = (async () => {
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { cva, type VariantProps } from "class-variance-authority"
|
|
3
|
+
|
|
4
|
+
import { cn } from "@/lib/utils"
|
|
5
|
+
|
|
6
|
+
const alertVariants = cva(
|
|
7
|
+
"relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
|
8
|
+
{
|
|
9
|
+
variants: {
|
|
10
|
+
variant: {
|
|
11
|
+
default: "bg-card text-card-foreground",
|
|
12
|
+
destructive:
|
|
13
|
+
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
defaultVariants: {
|
|
17
|
+
variant: "default",
|
|
18
|
+
},
|
|
19
|
+
}
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
function Alert({
|
|
23
|
+
className,
|
|
24
|
+
variant,
|
|
25
|
+
...props
|
|
26
|
+
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
|
27
|
+
return (
|
|
28
|
+
<div
|
|
29
|
+
data-slot="alert"
|
|
30
|
+
role="alert"
|
|
31
|
+
className={cn(alertVariants({ variant }), className)}
|
|
32
|
+
{...props}
|
|
33
|
+
/>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
|
38
|
+
return (
|
|
39
|
+
<div
|
|
40
|
+
data-slot="alert-title"
|
|
41
|
+
className={cn(
|
|
42
|
+
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
|
43
|
+
className
|
|
44
|
+
)}
|
|
45
|
+
{...props}
|
|
46
|
+
/>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function AlertDescription({
|
|
51
|
+
className,
|
|
52
|
+
...props
|
|
53
|
+
}: React.ComponentProps<"div">) {
|
|
54
|
+
return (
|
|
55
|
+
<div
|
|
56
|
+
data-slot="alert-description"
|
|
57
|
+
className={cn(
|
|
58
|
+
"col-start-2 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed",
|
|
59
|
+
className
|
|
60
|
+
)}
|
|
61
|
+
{...props}
|
|
62
|
+
/>
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export { Alert, AlertTitle, AlertDescription }
|
|
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority";
|
|
|
4
4
|
import { cn } from "@/lib/utils";
|
|
5
5
|
|
|
6
6
|
const badgeVariants = cva(
|
|
7
|
-
"inline-flex items-center rounded-
|
|
7
|
+
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring",
|
|
8
8
|
{
|
|
9
9
|
variants: {
|
|
10
10
|
variant: {
|
|
@@ -12,6 +12,10 @@ const badgeVariants = cva(
|
|
|
12
12
|
secondary: "border-transparent bg-secondary text-secondary-foreground",
|
|
13
13
|
destructive: "border-transparent bg-destructive text-white",
|
|
14
14
|
outline: "text-foreground",
|
|
15
|
+
success:
|
|
16
|
+
"border-transparent bg-success/15 text-success-foreground",
|
|
17
|
+
warning:
|
|
18
|
+
"border-transparent bg-warning/15 text-warning-foreground",
|
|
15
19
|
},
|
|
16
20
|
},
|
|
17
21
|
defaultVariants: {
|
|
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority";
|
|
|
5
5
|
import { cn } from "@/lib/utils";
|
|
6
6
|
|
|
7
7
|
const buttonVariants = cva(
|
|
8
|
-
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-
|
|
8
|
+
"inline-flex min-h-10 items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-[background-color,color,box-shadow,scale] duration-150 active:not-disabled:scale-[0.96] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0",
|
|
9
9
|
{
|
|
10
10
|
variants: {
|
|
11
11
|
variant: {
|
|
@@ -13,17 +13,18 @@ const buttonVariants = cva(
|
|
|
13
13
|
destructive:
|
|
14
14
|
"bg-destructive text-white hover:bg-destructive/90",
|
|
15
15
|
outline:
|
|
16
|
-
"
|
|
16
|
+
"bg-background shadow-[var(--shadow-border)] hover:bg-accent hover:text-accent-foreground hover:shadow-[var(--shadow-border-hover)]",
|
|
17
17
|
secondary:
|
|
18
18
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
|
19
19
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
|
20
20
|
link: "text-primary underline-offset-4 hover:underline",
|
|
21
21
|
},
|
|
22
22
|
size: {
|
|
23
|
-
default: "
|
|
24
|
-
sm: "h-
|
|
25
|
-
lg: "h-
|
|
26
|
-
icon: "
|
|
23
|
+
default: "px-4 py-2",
|
|
24
|
+
sm: "min-h-10 px-3 text-xs",
|
|
25
|
+
lg: "min-h-11 px-6",
|
|
26
|
+
icon: "size-10",
|
|
27
|
+
xs: "min-h-9 px-2 text-xs",
|
|
27
28
|
},
|
|
28
29
|
},
|
|
29
30
|
defaultVariants: {
|
|
@@ -44,7 +45,8 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
|
44
45
|
const Comp = asChild ? Slot : "button";
|
|
45
46
|
return (
|
|
46
47
|
<Comp
|
|
47
|
-
|
|
48
|
+
data-slot="button"
|
|
49
|
+
className={cn(buttonVariants({ variant, size }), className)}
|
|
48
50
|
ref={ref}
|
|
49
51
|
{...props}
|
|
50
52
|
/>
|
|
@@ -7,7 +7,7 @@ function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
|
|
7
7
|
<div
|
|
8
8
|
data-slot="card"
|
|
9
9
|
className={cn(
|
|
10
|
-
"rounded-
|
|
10
|
+
"rounded-2xl bg-card text-card-foreground shadow-[var(--shadow-border)]",
|
|
11
11
|
className,
|
|
12
12
|
)}
|
|
13
13
|
{...props}
|
|
@@ -22,7 +22,7 @@ function CardHeader({
|
|
|
22
22
|
return (
|
|
23
23
|
<div
|
|
24
24
|
data-slot="card-header"
|
|
25
|
-
className={cn("flex flex-col
|
|
25
|
+
className={cn("flex flex-col gap-1.5 p-6", className)}
|
|
26
26
|
{...props}
|
|
27
27
|
/>
|
|
28
28
|
);
|