@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
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import React from "react";
|
|
4
4
|
import { Link, db } from "@pylonsync/react";
|
|
5
5
|
import { Tag, BadgeCheck } from "lucide-react";
|
|
6
|
+
import { Card } from "@/components/ui/card";
|
|
6
7
|
import { MarketProvider } from "./MarketProvider";
|
|
7
8
|
import { money, timeAgo, type Listing, type Offer } from "./market";
|
|
8
9
|
|
|
@@ -18,13 +19,13 @@ type Activity = {
|
|
|
18
19
|
href: string;
|
|
19
20
|
amount?: number;
|
|
20
21
|
at: string;
|
|
22
|
+
seed?: Listing;
|
|
21
23
|
};
|
|
22
24
|
|
|
23
25
|
function Ticker() {
|
|
24
26
|
const { data: listingData } = db.useQuery<Listing>("Listing", {
|
|
25
|
-
where: { status: "active" },
|
|
26
27
|
orderBy: { createdAt: "desc" },
|
|
27
|
-
limit:
|
|
28
|
+
limit: 16,
|
|
28
29
|
});
|
|
29
30
|
const { data: saleData } = db.useQuery<Offer>("Offer", {
|
|
30
31
|
where: { status: "accepted" },
|
|
@@ -32,13 +33,16 @@ function Ticker() {
|
|
|
32
33
|
limit: 8,
|
|
33
34
|
});
|
|
34
35
|
|
|
36
|
+
const listings = listingData ?? [];
|
|
37
|
+
const listingById = new Map(listings.map((listing) => [listing.id, listing]));
|
|
35
38
|
const events: Activity[] = [
|
|
36
|
-
...(
|
|
39
|
+
...listings.filter((l) => l.status === "active").map((l) => ({
|
|
37
40
|
key: `l-${l.id}`,
|
|
38
41
|
kind: "listed" as const,
|
|
39
42
|
title: l.title,
|
|
40
43
|
href: `/listing/${l.slug || l.id}`,
|
|
41
44
|
at: l.createdAt,
|
|
45
|
+
seed: l,
|
|
42
46
|
})),
|
|
43
47
|
...(saleData ?? []).map((o) => ({
|
|
44
48
|
key: `s-${o.id}`,
|
|
@@ -47,6 +51,7 @@ function Ticker() {
|
|
|
47
51
|
href: `/listing/${o.listingId}`,
|
|
48
52
|
amount: o.amount,
|
|
49
53
|
at: o.createdAt,
|
|
54
|
+
seed: listingById.get(o.listingId),
|
|
50
55
|
})),
|
|
51
56
|
]
|
|
52
57
|
.sort((a, b) => Date.parse(b.at) - Date.parse(a.at))
|
|
@@ -55,11 +60,11 @@ function Ticker() {
|
|
|
55
60
|
if (events.length === 0) return null;
|
|
56
61
|
|
|
57
62
|
return (
|
|
58
|
-
<
|
|
59
|
-
<span className="flex shrink-0 items-center gap-1.5 font-medium text-
|
|
63
|
+
<Card className="flex min-h-11 items-center gap-3 overflow-hidden px-4 py-2 text-sm">
|
|
64
|
+
<span className="flex shrink-0 items-center gap-1.5 font-medium text-success-foreground">
|
|
60
65
|
<span className="relative flex size-2">
|
|
61
|
-
<span className="absolute inline-flex size-full animate-ping rounded-full bg-
|
|
62
|
-
<span className="relative inline-flex size-2 rounded-full bg-
|
|
66
|
+
<span className="absolute inline-flex size-full animate-ping rounded-full bg-success opacity-75" />
|
|
67
|
+
<span className="relative inline-flex size-2 rounded-full bg-success" />
|
|
63
68
|
</span>
|
|
64
69
|
Live
|
|
65
70
|
</span>
|
|
@@ -72,14 +77,21 @@ function Ticker() {
|
|
|
72
77
|
<Link
|
|
73
78
|
key={`${e.key}-${i}`}
|
|
74
79
|
href={e.href}
|
|
80
|
+
seed={e.seed}
|
|
75
81
|
className="flex shrink-0 items-center gap-1.5 whitespace-nowrap hover:underline"
|
|
76
82
|
aria-hidden={i >= events.length}
|
|
77
83
|
tabIndex={i >= events.length ? -1 : undefined}
|
|
78
84
|
>
|
|
79
85
|
{e.kind === "sold" ? (
|
|
80
|
-
<BadgeCheck
|
|
86
|
+
<BadgeCheck
|
|
87
|
+
className="size-3.5 text-success-foreground"
|
|
88
|
+
aria-hidden="true"
|
|
89
|
+
/>
|
|
81
90
|
) : (
|
|
82
|
-
<Tag
|
|
91
|
+
<Tag
|
|
92
|
+
className="size-3.5 text-muted-foreground"
|
|
93
|
+
aria-hidden="true"
|
|
94
|
+
/>
|
|
83
95
|
)}
|
|
84
96
|
<span className="font-medium">{e.title}</span>
|
|
85
97
|
<span className="text-muted-foreground">
|
|
@@ -91,7 +103,7 @@ function Ticker() {
|
|
|
91
103
|
))}
|
|
92
104
|
</div>
|
|
93
105
|
</div>
|
|
94
|
-
</
|
|
106
|
+
</Card>
|
|
95
107
|
);
|
|
96
108
|
}
|
|
97
109
|
|
|
@@ -1,9 +1,22 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import React, { useState } from "react";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
4
|
+
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import {
|
|
7
|
+
Card,
|
|
8
|
+
CardContent,
|
|
9
|
+
CardDescription,
|
|
10
|
+
CardFooter,
|
|
11
|
+
CardHeader,
|
|
12
|
+
} from "@/components/ui/card";
|
|
13
|
+
import {
|
|
14
|
+
Field,
|
|
15
|
+
FieldGroup,
|
|
16
|
+
FieldLabel,
|
|
17
|
+
} from "@/components/ui/field";
|
|
18
|
+
import { Input } from "@/components/ui/input";
|
|
19
|
+
import { Spinner } from "@/components/ui/spinner";
|
|
7
20
|
import { useAuth } from "./MarketProvider";
|
|
8
21
|
import { DEMO } from "./market";
|
|
9
22
|
|
|
@@ -12,10 +25,12 @@ import { DEMO } from "./market";
|
|
|
12
25
|
// with the seeded demo account so it's one click to a working session.
|
|
13
26
|
export function LoginCard({
|
|
14
27
|
title = "Sign in to continue",
|
|
15
|
-
blurb = "
|
|
28
|
+
blurb = "Use the ready demo account or create your own. No verification email is required.",
|
|
29
|
+
headingLevel = 2,
|
|
16
30
|
}: {
|
|
17
31
|
title?: string;
|
|
18
32
|
blurb?: string;
|
|
33
|
+
headingLevel?: 1 | 2;
|
|
19
34
|
}) {
|
|
20
35
|
const { signIn, signUp } = useAuth();
|
|
21
36
|
const [mode, setMode] = useState<"login" | "register">("login");
|
|
@@ -25,6 +40,7 @@ export function LoginCard({
|
|
|
25
40
|
const [name, setName] = useState("");
|
|
26
41
|
const [busy, setBusy] = useState(false);
|
|
27
42
|
const [err, setErr] = useState<string | null>(null);
|
|
43
|
+
const Heading = headingLevel === 1 ? "h1" : "h2";
|
|
28
44
|
|
|
29
45
|
async function submit(e: React.FormEvent) {
|
|
30
46
|
e.preventDefault();
|
|
@@ -41,62 +57,90 @@ export function LoginCard({
|
|
|
41
57
|
}
|
|
42
58
|
|
|
43
59
|
return (
|
|
44
|
-
<
|
|
45
|
-
<
|
|
46
|
-
<
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
60
|
+
<Card className="mx-auto max-w-sm">
|
|
61
|
+
<CardHeader>
|
|
62
|
+
<Heading className="text-xl font-semibold tracking-[-0.02em]">
|
|
63
|
+
{title}
|
|
64
|
+
</Heading>
|
|
65
|
+
<CardDescription className="text-pretty leading-5">
|
|
66
|
+
{blurb}
|
|
67
|
+
</CardDescription>
|
|
68
|
+
</CardHeader>
|
|
69
|
+
<CardContent>
|
|
70
|
+
<form onSubmit={submit}>
|
|
71
|
+
<FieldGroup className="gap-4">
|
|
72
|
+
{mode === "register" ? (
|
|
73
|
+
<Field>
|
|
74
|
+
<FieldLabel htmlFor="lc-name">Name</FieldLabel>
|
|
75
|
+
<Input
|
|
76
|
+
id="lc-name"
|
|
77
|
+
name="name"
|
|
78
|
+
autoComplete="name"
|
|
79
|
+
value={name}
|
|
80
|
+
onChange={(e) => setName(e.target.value)}
|
|
81
|
+
placeholder="Pat Pylon"
|
|
82
|
+
/>
|
|
83
|
+
</Field>
|
|
84
|
+
) : null}
|
|
85
|
+
<Field>
|
|
86
|
+
<FieldLabel htmlFor="lc-email">Email</FieldLabel>
|
|
87
|
+
<Input
|
|
88
|
+
id="lc-email"
|
|
89
|
+
name="email"
|
|
90
|
+
type="email"
|
|
91
|
+
required
|
|
92
|
+
autoComplete="email"
|
|
93
|
+
spellCheck={false}
|
|
94
|
+
value={email}
|
|
95
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
96
|
+
placeholder="you@example.com"
|
|
97
|
+
/>
|
|
98
|
+
</Field>
|
|
99
|
+
<Field>
|
|
100
|
+
<FieldLabel htmlFor="lc-password">Password</FieldLabel>
|
|
101
|
+
<Input
|
|
102
|
+
id="lc-password"
|
|
103
|
+
name="password"
|
|
104
|
+
type="password"
|
|
105
|
+
required
|
|
106
|
+
minLength={8}
|
|
107
|
+
autoComplete={
|
|
108
|
+
mode === "login" ? "current-password" : "new-password"
|
|
109
|
+
}
|
|
110
|
+
value={password}
|
|
111
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
112
|
+
placeholder={mode === "register" ? "8+ characters" : ""}
|
|
113
|
+
/>
|
|
114
|
+
</Field>
|
|
115
|
+
<div aria-live="polite">
|
|
116
|
+
{err ? (
|
|
117
|
+
<Alert variant="destructive">
|
|
118
|
+
<AlertDescription>{err}</AlertDescription>
|
|
119
|
+
</Alert>
|
|
120
|
+
) : null}
|
|
121
|
+
</div>
|
|
122
|
+
<Button type="submit" disabled={busy} className="w-full">
|
|
123
|
+
{busy ? <Spinner data-icon="inline-start" /> : null}
|
|
124
|
+
{busy
|
|
125
|
+
? mode === "login"
|
|
126
|
+
? "Logging in…"
|
|
127
|
+
: "Creating account…"
|
|
128
|
+
: mode === "login"
|
|
129
|
+
? "Log in"
|
|
130
|
+
: "Create account"}
|
|
131
|
+
</Button>
|
|
132
|
+
</FieldGroup>
|
|
133
|
+
</form>
|
|
134
|
+
</CardContent>
|
|
135
|
+
<CardFooter className="justify-center text-xs text-muted-foreground">
|
|
94
136
|
{mode === "login" ? (
|
|
95
137
|
<>
|
|
96
138
|
No account?{" "}
|
|
97
|
-
<
|
|
139
|
+
<Button
|
|
98
140
|
type="button"
|
|
99
|
-
|
|
141
|
+
variant="link"
|
|
142
|
+
size="sm"
|
|
143
|
+
className="px-1"
|
|
100
144
|
onClick={() => {
|
|
101
145
|
setMode("register");
|
|
102
146
|
setEmail("");
|
|
@@ -105,14 +149,16 @@ export function LoginCard({
|
|
|
105
149
|
}}
|
|
106
150
|
>
|
|
107
151
|
Sign up
|
|
108
|
-
</
|
|
152
|
+
</Button>
|
|
109
153
|
</>
|
|
110
154
|
) : (
|
|
111
155
|
<>
|
|
112
156
|
Have an account?{" "}
|
|
113
|
-
<
|
|
157
|
+
<Button
|
|
114
158
|
type="button"
|
|
115
|
-
|
|
159
|
+
variant="link"
|
|
160
|
+
size="sm"
|
|
161
|
+
className="px-1"
|
|
116
162
|
onClick={() => {
|
|
117
163
|
setMode("login");
|
|
118
164
|
setEmail(DEMO.email);
|
|
@@ -121,10 +167,10 @@ export function LoginCard({
|
|
|
121
167
|
}}
|
|
122
168
|
>
|
|
123
169
|
Log in
|
|
124
|
-
</
|
|
170
|
+
</Button>
|
|
125
171
|
</>
|
|
126
172
|
)}
|
|
127
|
-
</
|
|
128
|
-
</
|
|
173
|
+
</CardFooter>
|
|
174
|
+
</Card>
|
|
129
175
|
);
|
|
130
176
|
}
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
type Identity,
|
|
20
20
|
} from "./market";
|
|
21
21
|
import { LoginCard } from "./LoginCard";
|
|
22
|
+
import { Spinner } from "@/components/ui/spinner";
|
|
22
23
|
|
|
23
24
|
// The sync engine (live queries, WebSocket) is browser-only, so every
|
|
24
25
|
// interactive island mounts behind this provider. It boots the client, keeps
|
|
@@ -100,7 +101,10 @@ export function MarketProvider({
|
|
|
100
101
|
return (
|
|
101
102
|
<>
|
|
102
103
|
{fallback ?? (
|
|
103
|
-
<span className="text-xs text-muted-foreground">
|
|
104
|
+
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
105
|
+
<Spinner />
|
|
106
|
+
Connecting…
|
|
107
|
+
</span>
|
|
104
108
|
)}
|
|
105
109
|
</>
|
|
106
110
|
);
|
|
@@ -137,12 +141,22 @@ export function AuthGate({
|
|
|
137
141
|
children,
|
|
138
142
|
title,
|
|
139
143
|
blurb,
|
|
144
|
+
headingLevel,
|
|
140
145
|
}: {
|
|
141
146
|
children: React.ReactNode;
|
|
142
147
|
title?: string;
|
|
143
148
|
blurb?: string;
|
|
149
|
+
headingLevel?: 1 | 2;
|
|
144
150
|
}) {
|
|
145
151
|
const { identity } = useAuth();
|
|
146
|
-
if (!identity)
|
|
152
|
+
if (!identity) {
|
|
153
|
+
return (
|
|
154
|
+
<LoginCard
|
|
155
|
+
title={title}
|
|
156
|
+
blurb={blurb}
|
|
157
|
+
headingLevel={headingLevel}
|
|
158
|
+
/>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
147
161
|
return <>{children}</>;
|
|
148
162
|
}
|
|
@@ -2,8 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
import React from "react";
|
|
4
4
|
import { Link, db } from "@pylonsync/react";
|
|
5
|
-
import { Badge } from "
|
|
6
|
-
import { Button } from "
|
|
5
|
+
import { Badge } from "@/components/ui/badge";
|
|
6
|
+
import { Button } from "@/components/ui/button";
|
|
7
|
+
import { Card } from "@/components/ui/card";
|
|
8
|
+
import {
|
|
9
|
+
Empty,
|
|
10
|
+
EmptyContent,
|
|
11
|
+
EmptyDescription,
|
|
12
|
+
EmptyHeader,
|
|
13
|
+
EmptyTitle,
|
|
14
|
+
} from "@/components/ui/empty";
|
|
15
|
+
import { Spinner } from "@/components/ui/spinner";
|
|
7
16
|
import { AuthGate, MarketProvider, useIdentity } from "./MarketProvider";
|
|
8
17
|
import { Heart } from "lucide-react";
|
|
9
18
|
import { money, timeAgo, type Listing, type Offer, type Watch } from "./market";
|
|
@@ -16,46 +25,53 @@ const statusVariant: Record<string, BadgeVariant> = {
|
|
|
16
25
|
declined: "outline",
|
|
17
26
|
};
|
|
18
27
|
|
|
28
|
+
function uniqueById<T extends { id: string }>(rows: T[]): T[] {
|
|
29
|
+
return Array.from(new Map(rows.map((row) => [row.id, row])).values());
|
|
30
|
+
}
|
|
31
|
+
|
|
19
32
|
function Dashboard() {
|
|
20
33
|
// Rendered inside <AuthGate>, so identity is non-null here.
|
|
21
34
|
const identity = useIdentity();
|
|
22
35
|
const userId = identity?.userId ?? "";
|
|
23
36
|
const name = identity?.name ?? "you";
|
|
24
37
|
|
|
25
|
-
// Three live queries
|
|
26
|
-
//
|
|
38
|
+
// Three live queries. Listings + offers are public-read, then narrowed to
|
|
39
|
+
// the signed-in user in memory; Watch remains policy-scoped to its owner.
|
|
40
|
+
// Everything updates in place: a new offer on my listing, a seller
|
|
41
|
+
// answering my bid — no refresh.
|
|
27
42
|
const { data: listings } = db.useQuery<Listing>("Listing", {
|
|
28
|
-
where: { sellerId: userId },
|
|
29
43
|
orderBy: { createdAt: "desc" },
|
|
30
44
|
});
|
|
31
|
-
const { data:
|
|
32
|
-
where: { sellerId: userId },
|
|
45
|
+
const { data: offers } = db.useQuery<Offer>("Offer", {
|
|
33
46
|
orderBy: { createdAt: "desc" },
|
|
34
47
|
});
|
|
35
48
|
const { data: watching } = db.useQuery<Watch>("Watch", {
|
|
36
49
|
where: { userId },
|
|
37
50
|
orderBy: { createdAt: "desc" },
|
|
38
51
|
});
|
|
39
|
-
const { data: sent } = db.useQuery<Offer>("Offer", {
|
|
40
|
-
where: { buyerId: userId },
|
|
41
|
-
orderBy: { createdAt: "desc" },
|
|
42
|
-
});
|
|
43
52
|
|
|
44
|
-
const
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
53
|
+
const allListings = uniqueById(listings ?? []);
|
|
54
|
+
const listingById = new Map(
|
|
55
|
+
allListings.map((listing) => [listing.id, listing]),
|
|
56
|
+
);
|
|
57
|
+
const myListings = allListings.filter(
|
|
58
|
+
(listing) => listing.sellerId === userId,
|
|
59
|
+
);
|
|
60
|
+
const allOffers = uniqueById(offers ?? []);
|
|
61
|
+
const myOffers = allOffers.filter((offer) => offer.buyerId === userId);
|
|
62
|
+
const watchlist = uniqueById(watching ?? []);
|
|
63
|
+
const inbound = allOffers.filter((offer) => offer.sellerId === userId);
|
|
48
64
|
const pendingFor = (listingId: string) =>
|
|
49
65
|
inbound.filter((o) => o.listingId === listingId && o.status === "pending")
|
|
50
66
|
.length;
|
|
51
67
|
|
|
52
68
|
return (
|
|
53
|
-
<div className="
|
|
54
|
-
<header className="flex items-
|
|
69
|
+
<div className="flex flex-col gap-10 py-2 sm:py-6">
|
|
70
|
+
<header className="flex flex-col items-start justify-between gap-5 sm:flex-row sm:items-end">
|
|
55
71
|
<div>
|
|
56
|
-
<h1 className="text-
|
|
57
|
-
<p className="text-sm text-muted-foreground">
|
|
58
|
-
|
|
72
|
+
<h1 className="text-3xl font-semibold tracking-[-0.03em]">Dashboard</h1>
|
|
73
|
+
<p className="mt-1 text-sm text-muted-foreground">
|
|
74
|
+
Signed in as <span className="font-medium text-foreground">{name}</span>.
|
|
59
75
|
</p>
|
|
60
76
|
</div>
|
|
61
77
|
<Button asChild>
|
|
@@ -63,21 +79,42 @@ function Dashboard() {
|
|
|
63
79
|
</Button>
|
|
64
80
|
</header>
|
|
65
81
|
|
|
66
|
-
<section className="
|
|
82
|
+
<section className="flex flex-col gap-3">
|
|
67
83
|
<h2 className="font-semibold">Your listings ({myListings.length})</h2>
|
|
68
84
|
{myListings.length === 0 ? (
|
|
69
|
-
<
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
85
|
+
<Empty className="border-0 bg-card py-8 shadow-[var(--shadow-border)]">
|
|
86
|
+
<EmptyHeader>
|
|
87
|
+
<EmptyTitle className="text-base">Nothing listed yet</EmptyTitle>
|
|
88
|
+
<EmptyDescription>
|
|
89
|
+
Post your first item and it will appear here.
|
|
90
|
+
</EmptyDescription>
|
|
91
|
+
</EmptyHeader>
|
|
92
|
+
<EmptyContent>
|
|
93
|
+
<Button asChild variant="outline">
|
|
94
|
+
<Link href="/sell">Post your first item</Link>
|
|
95
|
+
</Button>
|
|
96
|
+
</EmptyContent>
|
|
97
|
+
</Empty>
|
|
76
98
|
) : (
|
|
77
|
-
<
|
|
99
|
+
<Card>
|
|
100
|
+
<ul className="divide-y overflow-hidden">
|
|
78
101
|
{myListings.map((l) => (
|
|
79
102
|
<li key={l.id} className="flex items-center justify-between gap-3 p-3">
|
|
80
|
-
<Link
|
|
103
|
+
<Link
|
|
104
|
+
href={`/listing/${l.slug || l.id}`}
|
|
105
|
+
seed={l}
|
|
106
|
+
className="flex min-w-0 items-center gap-3 hover:underline"
|
|
107
|
+
>
|
|
108
|
+
{l.imageUrl ? (
|
|
109
|
+
<img
|
|
110
|
+
src={l.imageUrl}
|
|
111
|
+
alt=""
|
|
112
|
+
width="48"
|
|
113
|
+
height="48"
|
|
114
|
+
loading="lazy"
|
|
115
|
+
className="size-12 shrink-0 rounded-lg object-cover outline outline-1 -outline-offset-1 outline-border"
|
|
116
|
+
/>
|
|
117
|
+
) : null}
|
|
81
118
|
<span className="truncate font-medium">{l.title}</span>
|
|
82
119
|
</Link>
|
|
83
120
|
<div className="flex shrink-0 items-center gap-2 text-sm">
|
|
@@ -95,61 +132,85 @@ function Dashboard() {
|
|
|
95
132
|
</li>
|
|
96
133
|
))}
|
|
97
134
|
</ul>
|
|
135
|
+
</Card>
|
|
98
136
|
)}
|
|
99
137
|
</section>
|
|
100
138
|
|
|
101
|
-
<section className="
|
|
139
|
+
<section className="flex flex-col gap-3">
|
|
102
140
|
<h2 className="font-semibold">Offers you've made ({myOffers.length})</h2>
|
|
103
141
|
{myOffers.length === 0 ? (
|
|
104
|
-
<
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
142
|
+
<Empty className="border-0 bg-card py-8 shadow-[var(--shadow-border)]">
|
|
143
|
+
<EmptyHeader>
|
|
144
|
+
<EmptyTitle className="text-base">No offers out</EmptyTitle>
|
|
145
|
+
<EmptyDescription>
|
|
146
|
+
When you make an offer, its status will stay synced here.
|
|
147
|
+
</EmptyDescription>
|
|
148
|
+
</EmptyHeader>
|
|
149
|
+
<EmptyContent>
|
|
150
|
+
<Button asChild variant="outline">
|
|
151
|
+
<Link href="/">Browse the market</Link>
|
|
152
|
+
</Button>
|
|
153
|
+
</EmptyContent>
|
|
154
|
+
</Empty>
|
|
111
155
|
) : (
|
|
112
|
-
<
|
|
156
|
+
<Card>
|
|
157
|
+
<ul className="divide-y overflow-hidden">
|
|
113
158
|
{myOffers.map((o) => (
|
|
114
|
-
<li
|
|
159
|
+
<li
|
|
160
|
+
key={o.id}
|
|
161
|
+
className="flex flex-col items-start gap-2 p-3 sm:flex-row sm:items-center sm:justify-between"
|
|
162
|
+
>
|
|
115
163
|
<Link
|
|
116
164
|
href={`/listing/${o.listingId}`}
|
|
117
|
-
|
|
165
|
+
seed={listingById.get(o.listingId)}
|
|
166
|
+
className="min-w-0 max-w-full hover:underline"
|
|
118
167
|
>
|
|
119
|
-
<span className="truncate font-medium">{o.listingTitle}</span>
|
|
120
|
-
<span className="
|
|
168
|
+
<span className="block truncate font-medium">{o.listingTitle}</span>
|
|
169
|
+
<span className="block text-sm text-muted-foreground sm:mt-0 sm:inline sm:pl-2">
|
|
121
170
|
{timeAgo(o.createdAt)}
|
|
122
171
|
</span>
|
|
123
172
|
</Link>
|
|
124
|
-
<div className="flex shrink-0 items-center gap-2 text-sm">
|
|
173
|
+
<div className="flex shrink-0 self-end items-center gap-2 text-sm sm:self-auto">
|
|
125
174
|
<span className="font-semibold tabular-nums">{money(o.amount)}</span>
|
|
126
|
-
<Badge
|
|
175
|
+
<Badge
|
|
176
|
+
variant={statusVariant[o.status] ?? "outline"}
|
|
177
|
+
className="capitalize"
|
|
178
|
+
>
|
|
127
179
|
{o.status}
|
|
128
180
|
</Badge>
|
|
129
181
|
</div>
|
|
130
182
|
</li>
|
|
131
183
|
))}
|
|
132
184
|
</ul>
|
|
185
|
+
</Card>
|
|
133
186
|
)}
|
|
134
187
|
</section>
|
|
135
188
|
|
|
136
|
-
<section className="
|
|
189
|
+
<section className="flex flex-col gap-3">
|
|
137
190
|
<h2 className="flex items-center gap-2 font-semibold">
|
|
138
|
-
<Heart
|
|
191
|
+
<Heart
|
|
192
|
+
aria-hidden="true"
|
|
193
|
+
className="size-4 fill-foreground text-foreground"
|
|
194
|
+
/>
|
|
139
195
|
Watching ({watchlist.length})
|
|
140
196
|
</h2>
|
|
141
197
|
{watchlist.length === 0 ? (
|
|
142
|
-
<
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
198
|
+
<Empty className="border-0 bg-card py-8 shadow-[var(--shadow-border)]">
|
|
199
|
+
<EmptyHeader>
|
|
200
|
+
<EmptyTitle className="text-base">Nothing saved yet</EmptyTitle>
|
|
201
|
+
<EmptyDescription>
|
|
202
|
+
Use the heart on any listing to save it privately here.
|
|
203
|
+
</EmptyDescription>
|
|
204
|
+
</EmptyHeader>
|
|
205
|
+
</Empty>
|
|
147
206
|
) : (
|
|
148
|
-
<
|
|
207
|
+
<Card>
|
|
208
|
+
<ul className="divide-y overflow-hidden">
|
|
149
209
|
{watchlist.map((w) => (
|
|
150
210
|
<li key={w.id} className="flex items-center justify-between gap-3 p-3">
|
|
151
211
|
<Link
|
|
152
212
|
href={`/listing/${w.listingId}`}
|
|
213
|
+
seed={listingById.get(w.listingId)}
|
|
153
214
|
className="min-w-0 truncate font-medium hover:underline"
|
|
154
215
|
>
|
|
155
216
|
{w.listingTitle}
|
|
@@ -160,6 +221,7 @@ function Dashboard() {
|
|
|
160
221
|
</li>
|
|
161
222
|
))}
|
|
162
223
|
</ul>
|
|
224
|
+
</Card>
|
|
163
225
|
)}
|
|
164
226
|
</section>
|
|
165
227
|
</div>
|
|
@@ -168,10 +230,18 @@ function Dashboard() {
|
|
|
168
230
|
|
|
169
231
|
export function MyMarket() {
|
|
170
232
|
return (
|
|
171
|
-
<MarketProvider
|
|
233
|
+
<MarketProvider
|
|
234
|
+
fallback={
|
|
235
|
+
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
236
|
+
<Spinner />
|
|
237
|
+
Loading your market…
|
|
238
|
+
</div>
|
|
239
|
+
}
|
|
240
|
+
>
|
|
172
241
|
<AuthGate
|
|
173
|
-
title="Sign in to
|
|
174
|
-
blurb="Your listings,
|
|
242
|
+
title="Sign in to open your dashboard"
|
|
243
|
+
blurb="Your listings, saved finds, and offers stay synced here. The demo account is ready; just select Log in."
|
|
244
|
+
headingLevel={1}
|
|
175
245
|
>
|
|
176
246
|
<Dashboard />
|
|
177
247
|
</AuthGate>
|