@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
|
@@ -5,101 +5,169 @@ import {
|
|
|
5
5
|
type PageProps,
|
|
6
6
|
type ServerData,
|
|
7
7
|
} from "@pylonsync/react";
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
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: "
|
|
38
|
+
title: "Reprise | Distinctive secondhand finds",
|
|
18
39
|
description:
|
|
19
|
-
"
|
|
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",
|
|
24
|
-
"
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
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
|
-
<
|
|
50
|
-
<
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
<
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
</
|
|
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-
|
|
68
|
-
{listings.map((
|
|
69
|
-
<
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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-
|
|
88
|
-
<
|
|
89
|
-
{
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
</
|
|
102
|
-
|
|
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"
|
|
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="
|
|
114
|
-
<section className="
|
|
115
|
-
<
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
128
|
-
|
|
129
|
-
<
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
<
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
<
|
|
156
|
-
|
|
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
|
|
7
|
-
description: "
|
|
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-
|
|
13
|
-
<header className="
|
|
14
|
-
<h1 className="text-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
20
|
+
dashboard
|
|
19
21
|
</a>{" "}
|
|
20
|
-
|
|
22
|
+
in real time.
|
|
21
23
|
</p>
|
|
22
24
|
</header>
|
|
23
25
|
<SellForm />
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
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. `
|
|
44
|
-
//
|
|
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 "
|
|
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
|
-
<
|
|
16
|
-
href="/sell"
|
|
17
|
-
|
|
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
|
+
}
|