@pylonsync/create-pylon 0.3.274 → 0.3.275
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/bin/create-pylon.js +80 -0
- package/package.json +1 -1
- package/templates/ARCHETYPES.md +339 -0
- package/templates/agency/.env.example +12 -0
- package/templates/agency/AGENTS.md +61 -0
- package/templates/agency/README.md +90 -0
- package/templates/agency/app/auth-form.tsx +129 -0
- package/templates/agency/app/contact-form.tsx +258 -0
- package/templates/agency/app/dashboard/dashboard-client.tsx +286 -0
- package/templates/agency/app/dashboard/page.tsx +70 -0
- package/templates/agency/app/error.tsx +26 -0
- package/templates/agency/app/globals.css +148 -0
- package/templates/agency/app/layout.tsx +174 -0
- package/templates/agency/app/login/page.tsx +39 -0
- package/templates/agency/app/not-found.tsx +19 -0
- package/templates/agency/app/page.tsx +207 -0
- package/templates/agency/app/robots.ts +12 -0
- package/templates/agency/app/sitemap.ts +9 -0
- package/templates/agency/app.ts +135 -0
- package/templates/agency/components/marketing.tsx +148 -0
- package/templates/agency/components/section-scroller.tsx +35 -0
- package/templates/agency/components/ui/button.tsx +56 -0
- package/templates/agency/components/ui/card.tsx +90 -0
- package/templates/agency/components.json +20 -0
- package/templates/agency/functions/bookInquiry.ts +42 -0
- package/templates/agency/functions/declineInquiry.ts +41 -0
- package/templates/agency/functions/inquiriesForOwner.ts +31 -0
- package/templates/agency/functions/seedCapacity.ts +26 -0
- package/templates/agency/functions/setCapacity.ts +32 -0
- package/templates/agency/functions/submitInquiry.ts +55 -0
- package/templates/agency/gitignore +10 -0
- package/templates/agency/lib/agency.ts +27 -0
- package/templates/agency/lib/owner.ts +26 -0
- package/templates/agency/lib/site.config.ts +239 -0
- package/templates/agency/lib/utils.ts +10 -0
- package/templates/agency/package.json +34 -0
- package/templates/agency/tsconfig.json +18 -0
- package/templates/ai-chat/.env.example +33 -0
- package/templates/ai-chat/AGENTS.md +61 -0
- package/templates/ai-chat/README.md +99 -0
- package/templates/ai-chat/app/auth-form.tsx +124 -0
- package/templates/ai-chat/app/chat-client.tsx +414 -0
- package/templates/ai-chat/app/error.tsx +26 -0
- package/templates/ai-chat/app/globals.css +148 -0
- package/templates/ai-chat/app/layout.tsx +75 -0
- package/templates/ai-chat/app/login/page.tsx +39 -0
- package/templates/ai-chat/app/not-found.tsx +19 -0
- package/templates/ai-chat/app/page.tsx +23 -0
- package/templates/ai-chat/app.ts +121 -0
- package/templates/ai-chat/components.json +20 -0
- package/templates/ai-chat/gitignore +10 -0
- package/templates/ai-chat/lib/site.config.ts +103 -0
- package/templates/ai-chat/lib/utils.ts +10 -0
- package/templates/ai-chat/package.json +34 -0
- package/templates/ai-chat/tsconfig.json +18 -0
- package/templates/ai-studio/.env.example +19 -0
- package/templates/ai-studio/AGENTS.md +61 -0
- package/templates/ai-studio/README.md +83 -0
- package/templates/ai-studio/app/auth-form.tsx +124 -0
- package/templates/ai-studio/app/error.tsx +26 -0
- package/templates/ai-studio/app/globals.css +148 -0
- package/templates/ai-studio/app/layout.tsx +75 -0
- package/templates/ai-studio/app/login/page.tsx +39 -0
- package/templates/ai-studio/app/not-found.tsx +19 -0
- package/templates/ai-studio/app/page.tsx +34 -0
- package/templates/ai-studio/app/studio-client.tsx +214 -0
- package/templates/ai-studio/app.ts +108 -0
- package/templates/ai-studio/components.json +20 -0
- package/templates/ai-studio/functions/_getGeneration.ts +25 -0
- package/templates/ai-studio/functions/_updateGeneration.ts +37 -0
- package/templates/ai-studio/functions/generate.ts +42 -0
- package/templates/ai-studio/functions/pollGeneration.ts +134 -0
- package/templates/ai-studio/gitignore +10 -0
- package/templates/ai-studio/lib/site.config.ts +80 -0
- package/templates/ai-studio/lib/studio.ts +52 -0
- package/templates/ai-studio/lib/utils.ts +10 -0
- package/templates/ai-studio/package.json +34 -0
- package/templates/ai-studio/tsconfig.json +18 -0
- package/templates/creator/.env.example +12 -0
- package/templates/creator/AGENTS.md +61 -0
- package/templates/creator/README.md +67 -0
- package/templates/creator/app/auth-form.tsx +129 -0
- package/templates/creator/app/dashboard/dashboard-client.tsx +297 -0
- package/templates/creator/app/dashboard/page.tsx +70 -0
- package/templates/creator/app/error.tsx +26 -0
- package/templates/creator/app/globals.css +148 -0
- package/templates/creator/app/layout.tsx +160 -0
- package/templates/creator/app/login/page.tsx +39 -0
- package/templates/creator/app/newsletter-signup.tsx +162 -0
- package/templates/creator/app/not-found.tsx +19 -0
- package/templates/creator/app/page.tsx +160 -0
- package/templates/creator/app/robots.ts +12 -0
- package/templates/creator/app/sitemap.ts +9 -0
- package/templates/creator/app.ts +134 -0
- package/templates/creator/components/marketing.tsx +148 -0
- package/templates/creator/components/section-scroller.tsx +35 -0
- package/templates/creator/components/ui/button.tsx +56 -0
- package/templates/creator/components/ui/card.tsx +90 -0
- package/templates/creator/components.json +20 -0
- package/templates/creator/functions/subscribe.ts +82 -0
- package/templates/creator/functions/subscriberStats.ts +75 -0
- package/templates/creator/gitignore +10 -0
- package/templates/creator/lib/owner.ts +26 -0
- package/templates/creator/lib/site.config.ts +173 -0
- package/templates/creator/lib/stats.ts +30 -0
- package/templates/creator/lib/utils.ts +10 -0
- package/templates/creator/package.json +34 -0
- package/templates/creator/tsconfig.json +18 -0
- package/templates/default/app/layout.tsx +26 -27
- package/templates/default/app/page.tsx +90 -274
- package/templates/default/lib/products.ts +9 -122
- package/templates/default/lib/site.config.ts +739 -0
- package/templates/default/lib/site.ts +14 -261
- package/templates/directory/.env.example +12 -0
- package/templates/directory/AGENTS.md +61 -0
- package/templates/directory/README.md +80 -0
- package/templates/directory/app/auth-form.tsx +129 -0
- package/templates/directory/app/dashboard/dashboard-client.tsx +205 -0
- package/templates/directory/app/dashboard/page.tsx +70 -0
- package/templates/directory/app/directory-browse.tsx +328 -0
- package/templates/directory/app/error.tsx +26 -0
- package/templates/directory/app/globals.css +148 -0
- package/templates/directory/app/layout.tsx +171 -0
- package/templates/directory/app/login/page.tsx +39 -0
- package/templates/directory/app/not-found.tsx +19 -0
- package/templates/directory/app/page.tsx +50 -0
- package/templates/directory/app/robots.ts +12 -0
- package/templates/directory/app/sitemap.ts +9 -0
- package/templates/directory/app/submit/page.tsx +30 -0
- package/templates/directory/app/submit-form.tsx +151 -0
- package/templates/directory/app.ts +146 -0
- package/templates/directory/components/marketing.tsx +148 -0
- package/templates/directory/components/section-scroller.tsx +35 -0
- package/templates/directory/components/ui/button.tsx +56 -0
- package/templates/directory/components/ui/card.tsx +90 -0
- package/templates/directory/components.json +20 -0
- package/templates/directory/functions/approveSubmission.ts +45 -0
- package/templates/directory/functions/rejectSubmission.ts +20 -0
- package/templates/directory/functions/seedListings.ts +33 -0
- package/templates/directory/functions/submissionsForOwner.ts +29 -0
- package/templates/directory/functions/submitListing.ts +63 -0
- package/templates/directory/functions/upvote.ts +24 -0
- package/templates/directory/gitignore +10 -0
- package/templates/directory/lib/directory.ts +45 -0
- package/templates/directory/lib/owner.ts +26 -0
- package/templates/directory/lib/site.config.ts +130 -0
- package/templates/directory/lib/utils.ts +10 -0
- package/templates/directory/package.json +34 -0
- package/templates/directory/tsconfig.json +18 -0
- package/templates/local-service/.env.example +12 -0
- package/templates/local-service/AGENTS.md +61 -0
- package/templates/local-service/README.md +82 -0
- package/templates/local-service/app/auth-form.tsx +129 -0
- package/templates/local-service/app/booking-widget.tsx +399 -0
- package/templates/local-service/app/dashboard/dashboard-client.tsx +304 -0
- package/templates/local-service/app/dashboard/page.tsx +63 -0
- package/templates/local-service/app/error.tsx +26 -0
- package/templates/local-service/app/globals.css +148 -0
- package/templates/local-service/app/layout.tsx +151 -0
- package/templates/local-service/app/login/page.tsx +39 -0
- package/templates/local-service/app/not-found.tsx +19 -0
- package/templates/local-service/app/page.tsx +233 -0
- package/templates/local-service/app/robots.ts +12 -0
- package/templates/local-service/app/sitemap.ts +9 -0
- package/templates/local-service/app.ts +131 -0
- package/templates/local-service/components/marketing.tsx +162 -0
- package/templates/local-service/components/section-scroller.tsx +35 -0
- package/templates/local-service/components/ui/button.tsx +56 -0
- package/templates/local-service/components/ui/card.tsx +90 -0
- package/templates/local-service/components.json +20 -0
- package/templates/local-service/functions/bookingsForOwner.ts +30 -0
- package/templates/local-service/functions/cancelBooking.ts +27 -0
- package/templates/local-service/functions/confirmBooking.ts +18 -0
- package/templates/local-service/functions/createBooking.ts +98 -0
- package/templates/local-service/gitignore +10 -0
- package/templates/local-service/lib/booking.ts +24 -0
- package/templates/local-service/lib/owner.ts +26 -0
- package/templates/local-service/lib/site.config.ts +232 -0
- package/templates/local-service/lib/slots.ts +97 -0
- package/templates/local-service/lib/utils.ts +10 -0
- package/templates/local-service/package.json +34 -0
- package/templates/local-service/tsconfig.json +18 -0
- package/templates/marketplace/.env.example +9 -0
- package/templates/marketplace/AGENTS.md +61 -0
- package/templates/marketplace/README.md +78 -0
- package/templates/marketplace/app/_components/CategoryIcon.tsx +40 -0
- package/templates/marketplace/app/error.tsx +26 -0
- package/templates/marketplace/app/globals.css +64 -0
- package/templates/marketplace/app/layout.tsx +60 -0
- package/templates/marketplace/app/listing/[id]/page.tsx +163 -0
- package/templates/marketplace/app/me/page.tsx +15 -0
- package/templates/marketplace/app/not-found.tsx +20 -0
- package/templates/marketplace/app/page.tsx +159 -0
- package/templates/marketplace/app/robots.ts +12 -0
- package/templates/marketplace/app/sell/page.tsx +26 -0
- package/templates/marketplace/app/sitemap.ts +14 -0
- package/templates/marketplace/app.ts +190 -0
- package/templates/marketplace/client/AuthNav.tsx +46 -0
- package/templates/marketplace/client/LiveTicker.tsx +104 -0
- package/templates/marketplace/client/LoginCard.tsx +130 -0
- package/templates/marketplace/client/MarketProvider.tsx +148 -0
- package/templates/marketplace/client/MyMarket.tsx +180 -0
- package/templates/marketplace/client/OfferPanel.tsx +355 -0
- package/templates/marketplace/client/SeedOnEmpty.tsx +26 -0
- package/templates/marketplace/client/SellForm.tsx +160 -0
- package/templates/marketplace/client/WatchButton.tsx +88 -0
- package/templates/marketplace/client/market.ts +341 -0
- package/templates/marketplace/functions/buyNow.ts +78 -0
- package/templates/marketplace/functions/makeOffer.ts +65 -0
- package/templates/marketplace/functions/respondToOffer.ts +62 -0
- package/templates/marketplace/functions/seedMarket.ts +90 -0
- package/templates/marketplace/gitignore +10 -0
- package/templates/marketplace/package.json +35 -0
- package/templates/marketplace/tsconfig.json +14 -0
- package/templates/marketplace/ui/badge.tsx +30 -0
- package/templates/marketplace/ui/button.tsx +49 -0
- package/templates/marketplace/ui/card.tsx +48 -0
- package/templates/marketplace/ui/input.tsx +17 -0
- package/templates/marketplace/ui/label.tsx +18 -0
- package/templates/marketplace/ui/textarea.tsx +17 -0
- package/templates/marketplace/ui/tokens.css +32 -0
- package/templates/marketplace/ui/utils.ts +6 -0
- package/templates/restaurant/.env.example +12 -0
- package/templates/restaurant/AGENTS.md +61 -0
- package/templates/restaurant/README.md +77 -0
- package/templates/restaurant/app/auth-form.tsx +129 -0
- package/templates/restaurant/app/dashboard/dashboard-client.tsx +263 -0
- package/templates/restaurant/app/dashboard/page.tsx +59 -0
- package/templates/restaurant/app/error.tsx +26 -0
- package/templates/restaurant/app/globals.css +148 -0
- package/templates/restaurant/app/layout.tsx +151 -0
- package/templates/restaurant/app/login/page.tsx +39 -0
- package/templates/restaurant/app/not-found.tsx +19 -0
- package/templates/restaurant/app/page.tsx +194 -0
- package/templates/restaurant/app/reservation-widget.tsx +359 -0
- package/templates/restaurant/app/robots.ts +12 -0
- package/templates/restaurant/app/sitemap.ts +9 -0
- package/templates/restaurant/app.ts +115 -0
- package/templates/restaurant/components/marketing.tsx +162 -0
- package/templates/restaurant/components/section-scroller.tsx +35 -0
- package/templates/restaurant/components/ui/button.tsx +56 -0
- package/templates/restaurant/components/ui/card.tsx +90 -0
- package/templates/restaurant/components.json +20 -0
- package/templates/restaurant/functions/cancelReservation.ts +26 -0
- package/templates/restaurant/functions/confirmReservation.ts +17 -0
- package/templates/restaurant/functions/createReservation.ts +92 -0
- package/templates/restaurant/functions/reservationsForOwner.ts +28 -0
- package/templates/restaurant/gitignore +10 -0
- package/templates/restaurant/lib/owner.ts +26 -0
- package/templates/restaurant/lib/reservation.ts +22 -0
- package/templates/restaurant/lib/site.config.ts +218 -0
- package/templates/restaurant/lib/slots.ts +55 -0
- package/templates/restaurant/lib/utils.ts +10 -0
- package/templates/restaurant/package.json +34 -0
- package/templates/restaurant/tsconfig.json +18 -0
- package/templates/shop/.env.example +32 -0
- package/templates/shop/AGENTS.md +61 -0
- package/templates/shop/README.md +102 -0
- package/templates/shop/app/auth-form.tsx +129 -0
- package/templates/shop/app/dashboard/dashboard-client.tsx +264 -0
- package/templates/shop/app/dashboard/page.tsx +59 -0
- package/templates/shop/app/error.tsx +26 -0
- package/templates/shop/app/globals.css +148 -0
- package/templates/shop/app/layout.tsx +160 -0
- package/templates/shop/app/login/page.tsx +39 -0
- package/templates/shop/app/not-found.tsx +19 -0
- package/templates/shop/app/page.tsx +95 -0
- package/templates/shop/app/robots.ts +12 -0
- package/templates/shop/app/shop-client.tsx +436 -0
- package/templates/shop/app/sitemap.ts +9 -0
- package/templates/shop/app/success/page.tsx +33 -0
- package/templates/shop/app.ts +134 -0
- package/templates/shop/components/marketing.tsx +96 -0
- package/templates/shop/components/section-scroller.tsx +35 -0
- package/templates/shop/components/ui/button.tsx +56 -0
- package/templates/shop/components/ui/card.tsx +90 -0
- package/templates/shop/components.json +20 -0
- package/templates/shop/functions/cancelOrder.ts +33 -0
- package/templates/shop/functions/checkout.ts +130 -0
- package/templates/shop/functions/fulfillOrder.ts +17 -0
- package/templates/shop/functions/markGroupPaid.ts +26 -0
- package/templates/shop/functions/ordersForOwner.ts +28 -0
- package/templates/shop/functions/releaseGroup.ts +36 -0
- package/templates/shop/functions/reserveCart.ts +87 -0
- package/templates/shop/functions/restockProduct.ts +23 -0
- package/templates/shop/functions/seedProducts.ts +30 -0
- package/templates/shop/functions/stripeWebhook.ts +72 -0
- package/templates/shop/gitignore +10 -0
- package/templates/shop/lib/owner.ts +26 -0
- package/templates/shop/lib/shop.ts +45 -0
- package/templates/shop/lib/site.config.ts +198 -0
- package/templates/shop/lib/utils.ts +10 -0
- package/templates/shop/package.json +35 -0
- package/templates/shop/tsconfig.json +18 -0
- package/templates/waitlist/.env.example +12 -0
- package/templates/waitlist/AGENTS.md +61 -0
- package/templates/waitlist/README.md +81 -0
- package/templates/waitlist/app/auth-form.tsx +129 -0
- package/templates/waitlist/app/dashboard/dashboard-client.tsx +297 -0
- package/templates/waitlist/app/dashboard/page.tsx +70 -0
- package/templates/waitlist/app/error.tsx +26 -0
- package/templates/waitlist/app/globals.css +148 -0
- package/templates/waitlist/app/layout.tsx +158 -0
- package/templates/waitlist/app/login/page.tsx +39 -0
- package/templates/waitlist/app/not-found.tsx +19 -0
- package/templates/waitlist/app/page.tsx +119 -0
- package/templates/waitlist/app/robots.ts +12 -0
- package/templates/waitlist/app/sitemap.ts +9 -0
- package/templates/waitlist/app/waitlist-hero.tsx +219 -0
- package/templates/waitlist/app.ts +134 -0
- package/templates/waitlist/components/marketing.tsx +96 -0
- package/templates/waitlist/components/ui/button.tsx +56 -0
- package/templates/waitlist/components/ui/card.tsx +90 -0
- package/templates/waitlist/components.json +20 -0
- package/templates/waitlist/functions/joinWaitlist.ts +82 -0
- package/templates/waitlist/functions/waitlistStats.ts +75 -0
- package/templates/waitlist/gitignore +10 -0
- package/templates/waitlist/lib/owner.ts +26 -0
- package/templates/waitlist/lib/site.config.ts +178 -0
- package/templates/waitlist/lib/stats.ts +30 -0
- package/templates/waitlist/lib/utils.ts +10 -0
- package/templates/waitlist/package.json +34 -0
- package/templates/waitlist/tsconfig.json +18 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState } from "react";
|
|
4
|
+
import {
|
|
5
|
+
passwordLogin,
|
|
6
|
+
passwordRegister,
|
|
7
|
+
persistSession,
|
|
8
|
+
ApiError,
|
|
9
|
+
} from "@pylonsync/client";
|
|
10
|
+
|
|
11
|
+
// The owner's email/password form — one form, two modes. It calls the built-in
|
|
12
|
+
// auth API directly (`passwordLogin` / `passwordRegister` POST to
|
|
13
|
+
// `/api/auth/password/*`), then `persistSession` writes the freshly-minted
|
|
14
|
+
// token to local storage so the sync engine + `callFn` authenticate AS THE
|
|
15
|
+
// OWNER on the next load. This step matters here specifically: the landing page
|
|
16
|
+
// mints an anonymous guest session (for the live counter), and without
|
|
17
|
+
// persisting the real session that stale guest token would shadow the owner's
|
|
18
|
+
// — so the owner-only `waitlistStats` call would come back as a guest and get
|
|
19
|
+
// rejected. We then do a full navigation to /dashboard so the SSR runtime
|
|
20
|
+
// re-resolves auth from the HttpOnly cookie and renders server-side.
|
|
21
|
+
//
|
|
22
|
+
// A waitlist is single-tenant: there's no public signup funnel, just the owner
|
|
23
|
+
// creating their one account. Whoever signs in only sees data if their email
|
|
24
|
+
// matches PYLON_OWNER_EMAIL — enforced by the waitlistStats function.
|
|
25
|
+
export function AuthForm() {
|
|
26
|
+
const [mode, setMode] = useState<"login" | "signup">("login");
|
|
27
|
+
const [email, setEmail] = useState("");
|
|
28
|
+
const [password, setPassword] = useState("");
|
|
29
|
+
const [error, setError] = useState<string | null>(null);
|
|
30
|
+
const [pending, setPending] = useState(false);
|
|
31
|
+
|
|
32
|
+
async function onSubmit(e: React.FormEvent) {
|
|
33
|
+
e.preventDefault();
|
|
34
|
+
setError(null);
|
|
35
|
+
setPending(true);
|
|
36
|
+
try {
|
|
37
|
+
const session =
|
|
38
|
+
mode === "login"
|
|
39
|
+
? await passwordLogin({ email, password })
|
|
40
|
+
: await passwordRegister({ email, password });
|
|
41
|
+
// Make this session authoritative, replacing any anonymous guest token.
|
|
42
|
+
persistSession(session);
|
|
43
|
+
window.location.assign("/dashboard");
|
|
44
|
+
} catch (err) {
|
|
45
|
+
setError(messageFor(err));
|
|
46
|
+
setPending(false);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div className="space-y-5">
|
|
52
|
+
<form onSubmit={onSubmit} className="space-y-4">
|
|
53
|
+
<label className="block">
|
|
54
|
+
<span className="mb-1.5 block text-[13px] font-medium text-zinc-700">Email</span>
|
|
55
|
+
<input
|
|
56
|
+
type="email"
|
|
57
|
+
value={email}
|
|
58
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
59
|
+
required
|
|
60
|
+
autoComplete="email"
|
|
61
|
+
placeholder="you@yourbusiness.com"
|
|
62
|
+
className="h-10 w-full rounded-lg border border-zinc-300 bg-white px-3 text-sm text-zinc-900 outline-none transition placeholder:text-zinc-400 focus:border-brand focus:ring-2 focus:ring-brand/20"
|
|
63
|
+
/>
|
|
64
|
+
</label>
|
|
65
|
+
<label className="block">
|
|
66
|
+
<span className="mb-1.5 block text-[13px] font-medium text-zinc-700">Password</span>
|
|
67
|
+
<input
|
|
68
|
+
type="password"
|
|
69
|
+
value={password}
|
|
70
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
71
|
+
required
|
|
72
|
+
autoComplete={mode === "login" ? "current-password" : "new-password"}
|
|
73
|
+
placeholder={mode === "login" ? "Your password" : "At least 10 characters"}
|
|
74
|
+
className="h-10 w-full rounded-lg border border-zinc-300 bg-white px-3 text-sm text-zinc-900 outline-none transition placeholder:text-zinc-400 focus:border-brand focus:ring-2 focus:ring-brand/20"
|
|
75
|
+
/>
|
|
76
|
+
</label>
|
|
77
|
+
{error ? (
|
|
78
|
+
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-[13px] leading-snug text-red-700">
|
|
79
|
+
{error}
|
|
80
|
+
</p>
|
|
81
|
+
) : null}
|
|
82
|
+
<button
|
|
83
|
+
type="submit"
|
|
84
|
+
disabled={pending}
|
|
85
|
+
className="inline-flex h-10 w-full items-center justify-center rounded-lg bg-zinc-900 text-sm font-medium text-white transition-colors hover:bg-zinc-700 disabled:opacity-60"
|
|
86
|
+
>
|
|
87
|
+
{pending ? "…" : mode === "login" ? "Sign in" : "Create account"}
|
|
88
|
+
</button>
|
|
89
|
+
</form>
|
|
90
|
+
|
|
91
|
+
<p className="text-center text-[13px] text-zinc-500">
|
|
92
|
+
{mode === "login" ? "First time here?" : "Already have an account?"}{" "}
|
|
93
|
+
<button
|
|
94
|
+
type="button"
|
|
95
|
+
onClick={() => {
|
|
96
|
+
setMode(mode === "login" ? "signup" : "login");
|
|
97
|
+
setError(null);
|
|
98
|
+
}}
|
|
99
|
+
className="font-medium text-zinc-900 underline underline-offset-2"
|
|
100
|
+
>
|
|
101
|
+
{mode === "login" ? "Create the owner account" : "Sign in"}
|
|
102
|
+
</button>
|
|
103
|
+
</p>
|
|
104
|
+
</div>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Map the framework's auth error codes to friendly copy. `ApiError` carries a
|
|
109
|
+
// stable `.code` so you branch on the code, not the message.
|
|
110
|
+
function messageFor(err: unknown): string {
|
|
111
|
+
if (err instanceof ApiError) {
|
|
112
|
+
switch (err.code) {
|
|
113
|
+
case "INVALID_CREDENTIALS":
|
|
114
|
+
return "Wrong email or password.";
|
|
115
|
+
case "USER_EXISTS":
|
|
116
|
+
return "That email is already registered — sign in instead.";
|
|
117
|
+
case "WEAK_PASSWORD":
|
|
118
|
+
return "Pick a longer password — at least 10 characters.";
|
|
119
|
+
case "PWNED_PASSWORD":
|
|
120
|
+
return "That password has appeared in a known data breach. Choose a different one.";
|
|
121
|
+
case "RATE_LIMITED":
|
|
122
|
+
return "Too many attempts — try again in a minute.";
|
|
123
|
+
default:
|
|
124
|
+
return err.message;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (err instanceof Error) return err.message;
|
|
128
|
+
return "Something went wrong. Try again.";
|
|
129
|
+
}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useMemo, useState } from "react";
|
|
4
|
+
import { db, callFn } from "@pylonsync/react";
|
|
5
|
+
import { useAuth } from "@pylonsync/client";
|
|
6
|
+
import type { WaitlistStatsData, WaitlistStatsResult, SignupRow } from "@/lib/stats";
|
|
7
|
+
|
|
8
|
+
// The owner's live dashboard. Liveness rides the SAME public aggregate the
|
|
9
|
+
// landing page uses: `db.useQuery("WaitlistStat")` re-renders the instant the
|
|
10
|
+
// count changes (cross-tab, via the replica). The emails themselves never sync
|
|
11
|
+
// — they come from the owner-gated `waitlistStats` function, (re)fetched on
|
|
12
|
+
// mount and whenever the live count ticks. So the total, chart, and list all
|
|
13
|
+
// stay live, but PII only ever travels through the gated call.
|
|
14
|
+
export function WaitlistDashboard({ userEmail }: { userEmail: string }) {
|
|
15
|
+
const { data: statRows } = db.useQuery<{ id: string; count: number }>("WaitlistStat");
|
|
16
|
+
const liveCount = statRows.length > 0 ? statRows[0].count : 0;
|
|
17
|
+
|
|
18
|
+
const [data, setData] = useState<WaitlistStatsData | null>(null);
|
|
19
|
+
const [denied, setDenied] = useState(false);
|
|
20
|
+
const [error, setError] = useState<string | null>(null);
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
let cancelled = false;
|
|
24
|
+
callFn<WaitlistStatsResult>("waitlistStats", {})
|
|
25
|
+
.then((r) => {
|
|
26
|
+
if (cancelled) return;
|
|
27
|
+
// The owner gate (PYLON_OWNER_EMAIL) lives in the function; a non-owner
|
|
28
|
+
// comes back as `{ authorized: false }` (with no data) → locked card.
|
|
29
|
+
if (!r.authorized) {
|
|
30
|
+
setDenied(true);
|
|
31
|
+
} else {
|
|
32
|
+
setData(r);
|
|
33
|
+
setError(null);
|
|
34
|
+
setDenied(false);
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
.catch((e) => {
|
|
38
|
+
if (!cancelled) setError(e instanceof Error ? e.message : String(e));
|
|
39
|
+
});
|
|
40
|
+
return () => {
|
|
41
|
+
cancelled = true;
|
|
42
|
+
};
|
|
43
|
+
// Re-fetch whenever a new signup moves the live count.
|
|
44
|
+
}, [liveCount]);
|
|
45
|
+
|
|
46
|
+
if (denied) return <OwnerOnly email={userEmail} />;
|
|
47
|
+
if (error) {
|
|
48
|
+
return (
|
|
49
|
+
<div className="rounded-xl border border-red-200 bg-red-50 px-5 py-4 text-sm text-red-700">
|
|
50
|
+
{error}
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
if (!data) return <Skeleton />;
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div className="space-y-8">
|
|
58
|
+
<div>
|
|
59
|
+
<h1 className="text-xl font-semibold tracking-tight">Waitlist</h1>
|
|
60
|
+
<p className="mt-1 text-sm text-zinc-500">
|
|
61
|
+
Live — new signups appear here the moment they happen.
|
|
62
|
+
</p>
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
<div className="grid gap-4 sm:grid-cols-3">
|
|
66
|
+
<Stat label="Total signups" value={data.total} />
|
|
67
|
+
<Stat label="Last 7 days" value={data.last7} />
|
|
68
|
+
<Stat label="Today" value={data.today} />
|
|
69
|
+
</div>
|
|
70
|
+
|
|
71
|
+
<TrendChart daily={data.daily} />
|
|
72
|
+
|
|
73
|
+
<SignupTable signups={data.signups} />
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function Stat({ label, value }: { label: string; value: number }) {
|
|
79
|
+
return (
|
|
80
|
+
<div className="rounded-xl border border-zinc-200 bg-white p-4">
|
|
81
|
+
<div className="text-[11px] font-medium uppercase tracking-wide text-zinc-400">
|
|
82
|
+
{label}
|
|
83
|
+
</div>
|
|
84
|
+
<div className="mt-1 text-2xl font-semibold tabular-nums text-zinc-900">
|
|
85
|
+
{value.toLocaleString()}
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/* ----------------------------- trend chart ---------------------------- */
|
|
92
|
+
|
|
93
|
+
function TrendChart({ daily }: { daily: { date: string; count: number }[] }) {
|
|
94
|
+
const max = Math.max(1, ...daily.map((d) => d.count));
|
|
95
|
+
return (
|
|
96
|
+
<div className="rounded-xl border border-zinc-200 bg-white p-5">
|
|
97
|
+
<div className="flex items-center justify-between">
|
|
98
|
+
<h2 className="text-sm font-semibold text-zinc-900">Last 30 days</h2>
|
|
99
|
+
<span className="text-[12px] text-zinc-400">
|
|
100
|
+
peak {max.toLocaleString()}/day
|
|
101
|
+
</span>
|
|
102
|
+
</div>
|
|
103
|
+
{/* Each column is a FULL-HEIGHT flex cell (h-full) that bottom-aligns its
|
|
104
|
+
bar — so the bar's percentage height resolves against the 7rem track,
|
|
105
|
+
not an auto-height parent (which would collapse it to nothing). */}
|
|
106
|
+
<div className="mt-4 flex h-28 items-end gap-1">
|
|
107
|
+
{daily.map((d) => (
|
|
108
|
+
<div
|
|
109
|
+
key={d.date}
|
|
110
|
+
className="group relative flex h-full flex-1 flex-col justify-end"
|
|
111
|
+
>
|
|
112
|
+
<div
|
|
113
|
+
className="w-full rounded-t bg-brand/80 transition-colors group-hover:bg-brand"
|
|
114
|
+
style={{ height: `${Math.max(2, (d.count / max) * 100)}%` }}
|
|
115
|
+
/>
|
|
116
|
+
{/* Tooltip on hover — date + count. */}
|
|
117
|
+
<div className="pointer-events-none absolute bottom-full left-1/2 z-10 mb-1 hidden -translate-x-1/2 whitespace-nowrap rounded bg-zinc-900 px-2 py-1 text-[11px] text-white group-hover:block">
|
|
118
|
+
{fmtDay(d.date)} · {d.count}
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
))}
|
|
122
|
+
</div>
|
|
123
|
+
<div className="mt-2 flex justify-between text-[11px] text-zinc-400">
|
|
124
|
+
<span>{fmtDay(daily[0]?.date)}</span>
|
|
125
|
+
<span>{fmtDay(daily[daily.length - 1]?.date)}</span>
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/* ----------------------------- signup list ---------------------------- */
|
|
132
|
+
|
|
133
|
+
function SignupTable({ signups }: { signups: SignupRow[] }) {
|
|
134
|
+
const [q, setQ] = useState("");
|
|
135
|
+
const filtered = useMemo(() => {
|
|
136
|
+
const needle = q.trim().toLowerCase();
|
|
137
|
+
if (!needle) return signups;
|
|
138
|
+
return signups.filter((s) => s.email.toLowerCase().includes(needle));
|
|
139
|
+
}, [q, signups]);
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<div className="rounded-xl border border-zinc-200 bg-white">
|
|
143
|
+
<div className="flex flex-col gap-3 border-b border-zinc-100 p-4 sm:flex-row sm:items-center sm:justify-between">
|
|
144
|
+
<h2 className="text-sm font-semibold text-zinc-900">
|
|
145
|
+
Signups{" "}
|
|
146
|
+
<span className="font-normal text-zinc-400">
|
|
147
|
+
({filtered.length.toLocaleString()})
|
|
148
|
+
</span>
|
|
149
|
+
</h2>
|
|
150
|
+
<div className="flex items-center gap-2">
|
|
151
|
+
<input
|
|
152
|
+
value={q}
|
|
153
|
+
onChange={(e) => setQ(e.target.value)}
|
|
154
|
+
placeholder="Search email…"
|
|
155
|
+
aria-label="Search signups"
|
|
156
|
+
className="h-9 w-44 rounded-md border border-zinc-300 bg-white px-3 text-sm outline-none transition placeholder:text-zinc-400 focus:border-brand focus:ring-2 focus:ring-brand/20"
|
|
157
|
+
/>
|
|
158
|
+
<button
|
|
159
|
+
type="button"
|
|
160
|
+
onClick={() => exportCsv(signups)}
|
|
161
|
+
disabled={signups.length === 0}
|
|
162
|
+
className="inline-flex h-9 items-center rounded-md border border-zinc-300 px-3 text-[13px] font-medium text-zinc-700 transition-colors hover:bg-zinc-50 disabled:opacity-40"
|
|
163
|
+
>
|
|
164
|
+
Export CSV
|
|
165
|
+
</button>
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
|
|
169
|
+
{filtered.length === 0 ? (
|
|
170
|
+
<p className="p-8 text-center text-sm text-zinc-500">
|
|
171
|
+
{signups.length === 0
|
|
172
|
+
? "No signups yet. Share your landing page and watch them roll in — live."
|
|
173
|
+
: "No emails match your search."}
|
|
174
|
+
</p>
|
|
175
|
+
) : (
|
|
176
|
+
<ul className="divide-y divide-zinc-100">
|
|
177
|
+
{filtered.map((s) => (
|
|
178
|
+
<li key={s.id} className="flex items-center justify-between gap-3 px-4 py-2.5">
|
|
179
|
+
<span className="truncate text-sm text-zinc-800">{s.email}</span>
|
|
180
|
+
<span className="shrink-0 text-[12px] text-zinc-400">
|
|
181
|
+
{fmtDateTime(s.createdAt)}
|
|
182
|
+
</span>
|
|
183
|
+
</li>
|
|
184
|
+
))}
|
|
185
|
+
</ul>
|
|
186
|
+
)}
|
|
187
|
+
</div>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/* --------------------------- owner-only gate -------------------------- */
|
|
192
|
+
|
|
193
|
+
// Shown to a signed-in user the `waitlistStats` function refused (not the
|
|
194
|
+
// configured owner, or no PYLON_OWNER_EMAIL set). Fails closed — no signup data
|
|
195
|
+
// is fetched or shown.
|
|
196
|
+
function OwnerOnly({ email }: { email: string }) {
|
|
197
|
+
return (
|
|
198
|
+
<div className="rounded-xl border border-dashed border-zinc-300 px-6 py-12 text-center">
|
|
199
|
+
<h1 className="text-lg font-semibold">This dashboard is owner-only</h1>
|
|
200
|
+
<p className="mx-auto mt-2 max-w-md text-sm text-zinc-500">
|
|
201
|
+
You're signed in as{" "}
|
|
202
|
+
<span className="font-medium text-zinc-700">{email || "this account"}</span>.
|
|
203
|
+
A waitlist is single-tenant — only the owner can see the signups. Set{" "}
|
|
204
|
+
<code className="rounded bg-zinc-100 px-1.5 py-0.5 text-[12px]">
|
|
205
|
+
PYLON_OWNER_EMAIL={email || "you@yourbusiness.com"}
|
|
206
|
+
</code>{" "}
|
|
207
|
+
in your{" "}
|
|
208
|
+
<code className="rounded bg-zinc-100 px-1.5 py-0.5 text-[12px]">.env</code>,
|
|
209
|
+
restart, and reload — or sign in with the owner account.
|
|
210
|
+
</p>
|
|
211
|
+
</div>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/* ----------------------------- user menu ------------------------------ */
|
|
216
|
+
|
|
217
|
+
export function UserMenu({ email }: { email: string }) {
|
|
218
|
+
const { signOut } = useAuth();
|
|
219
|
+
const initial = (email.trim()[0] || "?").toUpperCase();
|
|
220
|
+
async function onSignOut() {
|
|
221
|
+
await signOut();
|
|
222
|
+
window.location.assign("/");
|
|
223
|
+
}
|
|
224
|
+
return (
|
|
225
|
+
<details className="group relative">
|
|
226
|
+
<summary className="flex size-8 cursor-pointer select-none list-none items-center justify-center rounded-full bg-zinc-900 text-[12px] font-semibold text-white marker:hidden [&::-webkit-details-marker]:hidden">
|
|
227
|
+
{initial}
|
|
228
|
+
</summary>
|
|
229
|
+
<div className="absolute right-0 top-full z-40 mt-2 w-56 overflow-hidden rounded-xl border border-zinc-200 bg-white py-1 shadow-[0_16px_48px_-16px_rgba(0,0,0,0.25)]">
|
|
230
|
+
<div className="border-b border-zinc-100 px-3 py-2">
|
|
231
|
+
<div className="truncate text-[13px] font-medium text-zinc-900">
|
|
232
|
+
{email || "Signed in"}
|
|
233
|
+
</div>
|
|
234
|
+
</div>
|
|
235
|
+
<button
|
|
236
|
+
type="button"
|
|
237
|
+
onClick={onSignOut}
|
|
238
|
+
className="flex w-full items-center px-3 py-2 text-left text-[13px] text-zinc-700 transition-colors hover:bg-zinc-50"
|
|
239
|
+
>
|
|
240
|
+
Sign out
|
|
241
|
+
</button>
|
|
242
|
+
</div>
|
|
243
|
+
</details>
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/* ------------------------------ helpers ------------------------------- */
|
|
248
|
+
|
|
249
|
+
function Skeleton() {
|
|
250
|
+
return (
|
|
251
|
+
<div className="space-y-8">
|
|
252
|
+
<div className="h-6 w-32 animate-pulse rounded bg-zinc-100" />
|
|
253
|
+
<div className="grid gap-4 sm:grid-cols-3">
|
|
254
|
+
{[0, 1, 2].map((i) => (
|
|
255
|
+
<div key={i} className="h-20 animate-pulse rounded-xl bg-zinc-100" />
|
|
256
|
+
))}
|
|
257
|
+
</div>
|
|
258
|
+
<div className="h-44 animate-pulse rounded-xl bg-zinc-100" />
|
|
259
|
+
<div className="h-64 animate-pulse rounded-xl bg-zinc-100" />
|
|
260
|
+
</div>
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function exportCsv(signups: SignupRow[]) {
|
|
265
|
+
const cell = (v: string) =>
|
|
266
|
+
/[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v;
|
|
267
|
+
const rows = [
|
|
268
|
+
"email,joined_at",
|
|
269
|
+
...signups.map((s) => `${cell(s.email)},${cell(s.createdAt)}`),
|
|
270
|
+
];
|
|
271
|
+
const blob = new Blob([rows.join("\n")], { type: "text/csv;charset=utf-8" });
|
|
272
|
+
const url = URL.createObjectURL(blob);
|
|
273
|
+
const a = document.createElement("a");
|
|
274
|
+
a.href = url;
|
|
275
|
+
a.download = `waitlist-${new Date().toISOString().slice(0, 10)}.csv`;
|
|
276
|
+
document.body.appendChild(a);
|
|
277
|
+
a.click();
|
|
278
|
+
a.remove();
|
|
279
|
+
URL.revokeObjectURL(url);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function fmtDay(iso?: string) {
|
|
283
|
+
if (!iso) return "";
|
|
284
|
+
const d = new Date(iso + "T00:00:00Z");
|
|
285
|
+
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function fmtDateTime(iso: string) {
|
|
289
|
+
const t = Date.parse(iso);
|
|
290
|
+
if (Number.isNaN(t)) return "";
|
|
291
|
+
return new Date(t).toLocaleString(undefined, {
|
|
292
|
+
month: "short",
|
|
293
|
+
day: "numeric",
|
|
294
|
+
hour: "numeric",
|
|
295
|
+
minute: "2-digit",
|
|
296
|
+
});
|
|
297
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import React, { use } from "react";
|
|
2
|
+
import { Link, type Metadata, type PageProps } from "@pylonsync/react";
|
|
3
|
+
import { siteConfig } from "@/lib/site.config";
|
|
4
|
+
import { UserMenu, WaitlistDashboard } from "./dashboard-client";
|
|
5
|
+
|
|
6
|
+
export const metadata: Metadata = {
|
|
7
|
+
title: `Dashboard — ${siteConfig.brand.name}`,
|
|
8
|
+
robots: "noindex",
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
// `app/dashboard/page.tsx` → `/dashboard`. Server-side auth gate only — any
|
|
12
|
+
// signed-in user can load the shell. The OWNER gate (a waitlist is
|
|
13
|
+
// single-tenant: only PYLON_OWNER_EMAIL may see the signups) lives in the
|
|
14
|
+
// `waitlistStats` function, which checks `ctx.env.PYLON_OWNER_EMAIL` — the
|
|
15
|
+
// authoritative server env. Non-owners get a clean "owner-only" card from the
|
|
16
|
+
// client when that call is denied. (Env-based config is read in the function,
|
|
17
|
+
// not here: an SSR page render can't reliably read arbitrary `process.env`.)
|
|
18
|
+
export default function DashboardPage({ auth, response, serverData }: PageProps) {
|
|
19
|
+
// Anonymous visitors and guest sessions (guest_… ids) get bounced to login —
|
|
20
|
+
// the dashboard is for the real, signed-in owner only.
|
|
21
|
+
if (!auth.user_id || auth.user_id.startsWith("guest_")) {
|
|
22
|
+
response.redirect("/login");
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// The User self-read policy lets the signed-in user read their own row → the
|
|
27
|
+
// email shown in the account menu (and on the owner-only card).
|
|
28
|
+
const me = use(serverData.get<{ email?: string }>("User", auth.user_id));
|
|
29
|
+
const email = me?.email ?? "";
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<Shell email={email}>
|
|
33
|
+
<WaitlistDashboard userEmail={email} />
|
|
34
|
+
</Shell>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Dashboard chrome: a slim top bar with the logo, a link back to the public
|
|
39
|
+
// site, and the account menu (a client island for sign-out).
|
|
40
|
+
function Shell({ email, children }: { email: string; children: React.ReactNode }) {
|
|
41
|
+
const { brand } = siteConfig;
|
|
42
|
+
return (
|
|
43
|
+
<div className="flex min-h-screen flex-col bg-white text-zinc-900">
|
|
44
|
+
<header className="border-b border-zinc-200">
|
|
45
|
+
<div className="mx-auto flex h-14 max-w-4xl items-center justify-between px-6">
|
|
46
|
+
<div className="flex items-center gap-2">
|
|
47
|
+
<span className="flex size-6 items-center justify-center rounded-[7px] bg-zinc-900 text-[13px] font-bold text-white">
|
|
48
|
+
{brand.letter}
|
|
49
|
+
</span>
|
|
50
|
+
<span className="text-[15px] font-semibold tracking-tight">
|
|
51
|
+
{brand.name} <span className="text-zinc-400">/ waitlist</span>
|
|
52
|
+
</span>
|
|
53
|
+
</div>
|
|
54
|
+
<div className="flex items-center gap-4">
|
|
55
|
+
<Link
|
|
56
|
+
href="/"
|
|
57
|
+
className="text-[13px] text-zinc-500 transition-colors hover:text-zinc-900"
|
|
58
|
+
>
|
|
59
|
+
View site ↗
|
|
60
|
+
</Link>
|
|
61
|
+
<UserMenu email={email} />
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
</header>
|
|
65
|
+
<main className="flex-1">
|
|
66
|
+
<div className="mx-auto max-w-4xl px-6 py-8">{children}</div>
|
|
67
|
+
</main>
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { type ErrorBoundaryProps } from "@pylonsync/react";
|
|
3
|
+
|
|
4
|
+
// `app/error.tsx` → the error boundary for this segment. Hydrated + interactive:
|
|
5
|
+
// `reset()` re-attempts the route. The thrown error reaches the client as
|
|
6
|
+
// `{ message, digest }` only — the stack stays in the dev overlay / server logs.
|
|
7
|
+
export default function Error({ error, reset }: ErrorBoundaryProps) {
|
|
8
|
+
return (
|
|
9
|
+
<div className="mx-auto flex min-h-[60vh] max-w-3xl flex-col items-center justify-center px-6 text-center">
|
|
10
|
+
<h1 className="text-2xl font-semibold tracking-tight">Something went wrong</h1>
|
|
11
|
+
<p className="mt-2 text-zinc-500">{error.message}</p>
|
|
12
|
+
{error.digest ? (
|
|
13
|
+
<p className="mt-1 text-xs text-zinc-400">
|
|
14
|
+
Reference: <code>{error.digest}</code>
|
|
15
|
+
</p>
|
|
16
|
+
) : null}
|
|
17
|
+
<button
|
|
18
|
+
type="button"
|
|
19
|
+
onClick={reset}
|
|
20
|
+
className="mt-6 inline-flex h-10 items-center rounded-full bg-zinc-900 px-5 text-sm font-medium text-white transition-colors hover:bg-zinc-700"
|
|
21
|
+
>
|
|
22
|
+
Try again
|
|
23
|
+
</button>
|
|
24
|
+
</div>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
2
|
+
@import "tw-animate-css";
|
|
3
|
+
|
|
4
|
+
/* Tailwind v4 scans these globs for class names. Add more @source lines if you
|
|
5
|
+
put markup elsewhere. The @pylonsync/client line lets its components
|
|
6
|
+
(EnsureGuest, auth helpers) keep any classes they ship. */
|
|
7
|
+
@source "../app/**/*.{tsx,ts,jsx,js}";
|
|
8
|
+
@source "../components/**/*.{tsx,ts,jsx,js}";
|
|
9
|
+
@source "../lib/**/*.{tsx,ts,jsx,js}";
|
|
10
|
+
@source "../node_modules/@pylonsync/client/**/*.{tsx,ts,jsx,js}";
|
|
11
|
+
|
|
12
|
+
@custom-variant dark (&:where(.dark, .dark *));
|
|
13
|
+
|
|
14
|
+
/* shadcn/ui design tokens (new-york / zinc) + the marketing brand accent. The
|
|
15
|
+
three brand vars are defaults — app/layout.tsx overrides them from
|
|
16
|
+
lib/site.config.ts on <html>, so re-theming the whole page is one edit there. */
|
|
17
|
+
:root {
|
|
18
|
+
--radius: 0.625rem;
|
|
19
|
+
--brand: #4f46e5;
|
|
20
|
+
--brand-soft: #eef2ff;
|
|
21
|
+
--paper: #fafafa;
|
|
22
|
+
--background: oklch(1 0 0);
|
|
23
|
+
--foreground: oklch(0.141 0.005 285.823);
|
|
24
|
+
--card: oklch(1 0 0);
|
|
25
|
+
--card-foreground: oklch(0.141 0.005 285.823);
|
|
26
|
+
--popover: oklch(1 0 0);
|
|
27
|
+
--popover-foreground: oklch(0.141 0.005 285.823);
|
|
28
|
+
--primary: oklch(0.21 0.006 285.885);
|
|
29
|
+
--primary-foreground: oklch(0.985 0 0);
|
|
30
|
+
--secondary: oklch(0.967 0.001 286.375);
|
|
31
|
+
--secondary-foreground: oklch(0.21 0.006 285.885);
|
|
32
|
+
--muted: oklch(0.967 0.001 286.375);
|
|
33
|
+
--muted-foreground: oklch(0.552 0.016 285.938);
|
|
34
|
+
--accent: oklch(0.967 0.001 286.375);
|
|
35
|
+
--accent-foreground: oklch(0.21 0.006 285.885);
|
|
36
|
+
--destructive: oklch(0.577 0.245 27.325);
|
|
37
|
+
--border: oklch(0.92 0.004 286.32);
|
|
38
|
+
--input: oklch(0.92 0.004 286.32);
|
|
39
|
+
--ring: oklch(0.705 0.015 286.067);
|
|
40
|
+
--chart-1: oklch(0.646 0.222 41.116);
|
|
41
|
+
--chart-2: oklch(0.6 0.118 184.704);
|
|
42
|
+
--chart-3: oklch(0.398 0.07 227.392);
|
|
43
|
+
--chart-4: oklch(0.828 0.189 84.429);
|
|
44
|
+
--chart-5: oklch(0.769 0.188 70.08);
|
|
45
|
+
--sidebar: oklch(0.985 0 0);
|
|
46
|
+
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
|
47
|
+
--sidebar-primary: oklch(0.21 0.006 285.885);
|
|
48
|
+
--sidebar-primary-foreground: oklch(0.985 0 0);
|
|
49
|
+
--sidebar-accent: oklch(0.967 0.001 286.375);
|
|
50
|
+
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
|
51
|
+
--sidebar-border: oklch(0.92 0.004 286.32);
|
|
52
|
+
--sidebar-ring: oklch(0.705 0.015 286.067);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.dark {
|
|
56
|
+
--background: oklch(0.141 0.005 285.823);
|
|
57
|
+
--foreground: oklch(0.985 0 0);
|
|
58
|
+
--card: oklch(0.21 0.006 285.885);
|
|
59
|
+
--card-foreground: oklch(0.985 0 0);
|
|
60
|
+
--popover: oklch(0.21 0.006 285.885);
|
|
61
|
+
--popover-foreground: oklch(0.985 0 0);
|
|
62
|
+
--primary: oklch(0.92 0.004 286.32);
|
|
63
|
+
--primary-foreground: oklch(0.21 0.006 285.885);
|
|
64
|
+
--secondary: oklch(0.274 0.006 286.033);
|
|
65
|
+
--secondary-foreground: oklch(0.985 0 0);
|
|
66
|
+
--muted: oklch(0.274 0.006 286.033);
|
|
67
|
+
--muted-foreground: oklch(0.705 0.015 286.067);
|
|
68
|
+
--accent: oklch(0.274 0.006 286.033);
|
|
69
|
+
--accent-foreground: oklch(0.985 0 0);
|
|
70
|
+
--destructive: oklch(0.704 0.191 22.216);
|
|
71
|
+
--border: oklch(1 0 0 / 10%);
|
|
72
|
+
--input: oklch(1 0 0 / 15%);
|
|
73
|
+
--ring: oklch(0.552 0.016 285.938);
|
|
74
|
+
--chart-1: oklch(0.488 0.243 264.376);
|
|
75
|
+
--chart-2: oklch(0.696 0.17 162.48);
|
|
76
|
+
--chart-3: oklch(0.769 0.188 70.08);
|
|
77
|
+
--chart-4: oklch(0.627 0.265 303.9);
|
|
78
|
+
--chart-5: oklch(0.645 0.246 16.439);
|
|
79
|
+
--sidebar: oklch(0.21 0.006 285.885);
|
|
80
|
+
--sidebar-foreground: oklch(0.985 0 0);
|
|
81
|
+
--sidebar-primary: oklch(0.488 0.243 264.376);
|
|
82
|
+
--sidebar-primary-foreground: oklch(0.985 0 0);
|
|
83
|
+
--sidebar-accent: oklch(0.274 0.006 286.033);
|
|
84
|
+
--sidebar-accent-foreground: oklch(0.985 0 0);
|
|
85
|
+
--sidebar-border: oklch(1 0 0 / 10%);
|
|
86
|
+
--sidebar-ring: oklch(0.552 0.016 285.938);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
@theme inline {
|
|
90
|
+
--radius-sm: calc(var(--radius) - 4px);
|
|
91
|
+
--radius-md: calc(var(--radius) - 2px);
|
|
92
|
+
--radius-lg: var(--radius);
|
|
93
|
+
--radius-xl: calc(var(--radius) + 4px);
|
|
94
|
+
--color-background: var(--background);
|
|
95
|
+
--color-foreground: var(--foreground);
|
|
96
|
+
--color-card: var(--card);
|
|
97
|
+
--color-card-foreground: var(--card-foreground);
|
|
98
|
+
--color-popover: var(--popover);
|
|
99
|
+
--color-popover-foreground: var(--popover-foreground);
|
|
100
|
+
--color-primary: var(--primary);
|
|
101
|
+
--color-primary-foreground: var(--primary-foreground);
|
|
102
|
+
--color-secondary: var(--secondary);
|
|
103
|
+
--color-secondary-foreground: var(--secondary-foreground);
|
|
104
|
+
--color-muted: var(--muted);
|
|
105
|
+
--color-muted-foreground: var(--muted-foreground);
|
|
106
|
+
--color-accent: var(--accent);
|
|
107
|
+
--color-accent-foreground: var(--accent-foreground);
|
|
108
|
+
--color-destructive: var(--destructive);
|
|
109
|
+
--color-border: var(--border);
|
|
110
|
+
--color-input: var(--input);
|
|
111
|
+
--color-ring: var(--ring);
|
|
112
|
+
--color-brand: var(--brand);
|
|
113
|
+
--color-brand-soft: var(--brand-soft);
|
|
114
|
+
--color-paper: var(--paper);
|
|
115
|
+
--color-chart-1: var(--chart-1);
|
|
116
|
+
--color-chart-2: var(--chart-2);
|
|
117
|
+
--color-chart-3: var(--chart-3);
|
|
118
|
+
--color-chart-4: var(--chart-4);
|
|
119
|
+
--color-chart-5: var(--chart-5);
|
|
120
|
+
--color-sidebar: var(--sidebar);
|
|
121
|
+
--color-sidebar-foreground: var(--sidebar-foreground);
|
|
122
|
+
--color-sidebar-primary: var(--sidebar-primary);
|
|
123
|
+
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
|
124
|
+
--color-sidebar-accent: var(--sidebar-accent);
|
|
125
|
+
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
|
126
|
+
--color-sidebar-border: var(--sidebar-border);
|
|
127
|
+
--color-sidebar-ring: var(--sidebar-ring);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
@layer base {
|
|
131
|
+
*,
|
|
132
|
+
::after,
|
|
133
|
+
::before,
|
|
134
|
+
::backdrop,
|
|
135
|
+
::file-selector-button {
|
|
136
|
+
border-color: var(--color-border, currentColor);
|
|
137
|
+
outline-color: var(--color-ring);
|
|
138
|
+
}
|
|
139
|
+
body {
|
|
140
|
+
background-color: var(--color-background);
|
|
141
|
+
color: var(--color-foreground);
|
|
142
|
+
font-family: Inter, ui-sans-serif, system-ui, -apple-system, sans-serif;
|
|
143
|
+
-webkit-font-smoothing: antialiased;
|
|
144
|
+
}
|
|
145
|
+
button {
|
|
146
|
+
cursor: pointer;
|
|
147
|
+
}
|
|
148
|
+
}
|