@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.
Files changed (53) hide show
  1. package/package.json +1 -1
  2. package/templates/marketplace/README.md +29 -14
  3. package/templates/marketplace/app/error.tsx +30 -16
  4. package/templates/marketplace/app/globals.css +140 -24
  5. package/templates/marketplace/app/layout.tsx +60 -31
  6. package/templates/marketplace/app/listing/[id]/page.tsx +160 -60
  7. package/templates/marketplace/app/me/page.tsx +3 -3
  8. package/templates/marketplace/app/not-found.tsx +19 -10
  9. package/templates/marketplace/app/page.tsx +287 -97
  10. package/templates/marketplace/app/sell/page.tsx +11 -9
  11. package/templates/marketplace/app.ts +5 -4
  12. package/templates/marketplace/client/AuthNav.tsx +4 -8
  13. package/templates/marketplace/client/LiveListingStatus.tsx +60 -0
  14. package/templates/marketplace/client/LiveTicker.tsx +22 -10
  15. package/templates/marketplace/client/LoginCard.tsx +108 -62
  16. package/templates/marketplace/client/MarketProvider.tsx +16 -2
  17. package/templates/marketplace/client/MyMarket.tsx +126 -56
  18. package/templates/marketplace/client/OfferPanel.tsx +222 -112
  19. package/templates/marketplace/client/ScrollToListingsLink.tsx +29 -0
  20. package/templates/marketplace/client/SeedOnEmpty.tsx +7 -6
  21. package/templates/marketplace/client/SellForm.tsx +198 -40
  22. package/templates/marketplace/client/ThemeToggle.tsx +62 -0
  23. package/templates/marketplace/client/WatchButton.tsx +19 -8
  24. package/templates/marketplace/client/market.ts +10 -4
  25. package/templates/marketplace/components/ui/alert.tsx +66 -0
  26. package/templates/marketplace/components/ui/badge.tsx +5 -1
  27. package/templates/marketplace/components/ui/button.tsx +9 -7
  28. package/templates/marketplace/components/ui/card.tsx +2 -2
  29. package/templates/marketplace/components/ui/empty.tsx +104 -0
  30. package/templates/marketplace/components/ui/field.tsx +246 -0
  31. package/templates/marketplace/components/ui/input.tsx +2 -2
  32. package/templates/marketplace/components/ui/label.tsx +4 -3
  33. package/templates/marketplace/components/ui/native-select.tsx +62 -0
  34. package/templates/marketplace/components/ui/separator.tsx +26 -0
  35. package/templates/marketplace/components/ui/skeleton.tsx +13 -0
  36. package/templates/marketplace/components/ui/spinner.tsx +16 -0
  37. package/templates/marketplace/components/ui/textarea.tsx +2 -2
  38. package/templates/marketplace/{ui → components/ui}/tokens.css +1 -1
  39. package/templates/marketplace/components.json +20 -0
  40. package/templates/marketplace/functions/seedMarket.ts +14 -12
  41. package/templates/marketplace/lib/catalog.ts +32 -0
  42. package/templates/marketplace/package.json +10 -9
  43. package/templates/marketplace/public/images/marketplace-hero.webp +0 -0
  44. package/templates/marketplace/tests/example.test.ts +76 -8
  45. package/templates/marketplace/tsconfig.json +13 -2
  46. package/templates/marketplace/components/ui/select.tsx +0 -39
  47. package/templates/marketplace/ui/badge.tsx +0 -30
  48. package/templates/marketplace/ui/button.tsx +0 -49
  49. package/templates/marketplace/ui/card.tsx +0 -48
  50. package/templates/marketplace/ui/input.tsx +0 -17
  51. package/templates/marketplace/ui/label.tsx +0 -18
  52. package/templates/marketplace/ui/textarea.tsx +0 -17
  53. package/templates/marketplace/ui/utils.ts +0 -6
@@ -2,12 +2,32 @@
2
2
 
3
3
  import React, { useState } from "react";
4
4
  import { db, useRouter } from "@pylonsync/react";
5
- import { Button } from "../ui/button";
6
- import { Input } from "../ui/input";
7
- import { Textarea } from "../ui/textarea";
8
- import { Label } from "../ui/label";
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
- const selectClass =
19
- "flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm outline-none focus-visible:ring-1 focus-visible:ring-ring";
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} className="space-y-5">
74
- <div className="space-y-1.5">
75
- <Label htmlFor="title">Title</Label>
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
- </div>
83
- <div className="space-y-1.5">
84
- <Label htmlFor="description">Description</Label>
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
- </div>
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
- <div className="space-y-1.5">
95
- <Label htmlFor="price">Price ($)</Label>
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
- </div>
106
- <div className="space-y-1.5">
107
- <Label htmlFor="condition">Condition</Label>
108
- <select
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
- <option key={c} value={c}>
116
- {c}
117
- </option>
255
+ <NativeSelectOption key={c} value={c}>
256
+ {conditionLabel(c)}
257
+ </NativeSelectOption>
118
258
  ))}
119
- </select>
120
- </div>
259
+ </NativeSelect>
260
+ </Field>
121
261
  </div>
122
- <div className="space-y-1.5">
123
- <Label htmlFor="category">Category</Label>
124
- <select
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
- <option key={c} value={c}>
132
- {c}
133
- </option>
271
+ <NativeSelectOption key={c} value={c}>
272
+ {c[0]?.toUpperCase()}{c.slice(1)}
273
+ </NativeSelectOption>
134
274
  ))}
135
- </select>
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
- {err ? <p className="text-sm text-destructive">{err}</p> : null}
138
- <Button type="submit" disabled={busy} className="w-full">
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> — buyers'
143
- offers land in <a href="/me" className="underline">My Market</a>.
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 are tied to you. The demo account is prefilled just hit Log in."
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
- <Form />
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 { cn } from "../ui/utils";
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", { where: { listingId } });
51
- const mine = identity ? data?.find((w) => w.userId === identity.userId) : undefined;
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
- <button
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
- "grid size-9 place-items-center rounded-full bg-background/80 backdrop-blur transition hover:bg-background",
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
- "size-5 transition",
83
- watched ? "fill-rose-500 text-rose-500" : "text-foreground/70",
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
- </button>
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 gradient "photo" from a seed no image hosting needed. */
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) localStorage.setItem(DISPLAY_NAME, 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") return Promise.resolve();
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-md border px-2 py-0.5 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
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-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
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
- "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
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: "h-9 px-4 py-2",
24
- sm: "h-8 rounded-md px-3 text-xs",
25
- lg: "h-10 rounded-md px-8",
26
- icon: "h-9 w-9",
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
- className={cn(buttonVariants({ variant, size, className }))}
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-xl border bg-card text-card-foreground shadow-sm",
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 space-y-1.5 p-6", className)}
25
+ className={cn("flex flex-col gap-1.5 p-6", className)}
26
26
  {...props}
27
27
  />
28
28
  );