@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,158 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Link, type PageAuth } from "@pylonsync/react";
|
|
3
|
+
import { siteConfig } from "@/lib/site.config";
|
|
4
|
+
|
|
5
|
+
// A layout wraps every page. This marketing layout renders a slim nav up top
|
|
6
|
+
// and a footer below, both driven by lib/site.config.ts. `auth.user_id` is
|
|
7
|
+
// resolved server-side from the session cookie before any HTML is sent, so the
|
|
8
|
+
// nav shows "Dashboard" once the owner is signed in and "Sign in" otherwise —
|
|
9
|
+
// no flash, no client fetch.
|
|
10
|
+
interface LayoutProps {
|
|
11
|
+
children: React.ReactNode;
|
|
12
|
+
url: string;
|
|
13
|
+
auth: PageAuth;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export default function RootLayout({ children, url, auth }: LayoutProps) {
|
|
17
|
+
// A guest session (minted by <EnsureGuest> for the live counter) has a
|
|
18
|
+
// `guest_…` user id — that's an anonymous visitor, NOT the signed-in owner,
|
|
19
|
+
// so it shouldn't flip the nav to "Dashboard".
|
|
20
|
+
const signedIn = Boolean(auth?.user_id && !auth.user_id.startsWith("guest_"));
|
|
21
|
+
const { brand, colors } = siteConfig;
|
|
22
|
+
|
|
23
|
+
// The auth screens and the dashboard bring their own chrome, so they render
|
|
24
|
+
// bare (no marketing nav/footer). Match on the path PREFIX, not a substring.
|
|
25
|
+
const path = (url ?? "").split("?")[0];
|
|
26
|
+
const BARE_PREFIXES = ["/login", "/dashboard"];
|
|
27
|
+
const isBare = BARE_PREFIXES.some((p) => path === p || path.startsWith(p + "/"));
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<html
|
|
31
|
+
lang="en"
|
|
32
|
+
// Marketing theme colors come from the single site config. Set as inline
|
|
33
|
+
// CSS vars on <html> so they override globals.css and the whole page
|
|
34
|
+
// re-themes from one place — no CSS edit needed.
|
|
35
|
+
style={
|
|
36
|
+
{
|
|
37
|
+
"--brand": colors.brand,
|
|
38
|
+
"--brand-soft": colors.brandSoft,
|
|
39
|
+
"--paper": colors.paper,
|
|
40
|
+
} as React.CSSProperties
|
|
41
|
+
}
|
|
42
|
+
>
|
|
43
|
+
<head>
|
|
44
|
+
<meta charSet="utf-8" />
|
|
45
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
46
|
+
{/* No <title> here — each page's exported `metadata` sets it. */}
|
|
47
|
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
48
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
|
49
|
+
<link
|
|
50
|
+
rel="stylesheet"
|
|
51
|
+
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
|
|
52
|
+
/>
|
|
53
|
+
{/* Tailwind is compiled by Pylon from app/globals.css and injected here. */}
|
|
54
|
+
</head>
|
|
55
|
+
<body className="flex min-h-screen flex-col bg-background text-foreground antialiased">
|
|
56
|
+
{isBare ? (
|
|
57
|
+
children
|
|
58
|
+
) : (
|
|
59
|
+
<>
|
|
60
|
+
<header className="sticky top-0 z-30 bg-white/80 backdrop-blur">
|
|
61
|
+
<div className="mx-auto flex h-14 max-w-3xl items-center justify-between px-6">
|
|
62
|
+
<Link href="/" className="flex items-center gap-2">
|
|
63
|
+
<span className="flex size-6 items-center justify-center rounded-[7px] bg-zinc-900 text-[13px] font-bold text-white">
|
|
64
|
+
{brand.letter}
|
|
65
|
+
</span>
|
|
66
|
+
<span className="text-[15px] font-semibold tracking-tight text-zinc-900">
|
|
67
|
+
{brand.name}
|
|
68
|
+
</span>
|
|
69
|
+
</Link>
|
|
70
|
+
<nav className="flex items-center gap-2">
|
|
71
|
+
{signedIn ? (
|
|
72
|
+
<Link
|
|
73
|
+
href="/dashboard"
|
|
74
|
+
className="inline-flex items-center rounded-full bg-zinc-900 px-3.5 py-1.5 text-[13px] font-medium text-white transition-colors hover:bg-zinc-700"
|
|
75
|
+
>
|
|
76
|
+
Dashboard
|
|
77
|
+
</Link>
|
|
78
|
+
) : (
|
|
79
|
+
<Link
|
|
80
|
+
href="/login"
|
|
81
|
+
className="rounded-full px-3 py-1.5 text-[13px] font-medium text-zinc-600 transition-colors hover:text-zinc-900"
|
|
82
|
+
>
|
|
83
|
+
Sign in
|
|
84
|
+
</Link>
|
|
85
|
+
)}
|
|
86
|
+
</nav>
|
|
87
|
+
</div>
|
|
88
|
+
</header>
|
|
89
|
+
|
|
90
|
+
<main className="flex-1">{children}</main>
|
|
91
|
+
|
|
92
|
+
<SiteFooter />
|
|
93
|
+
</>
|
|
94
|
+
)}
|
|
95
|
+
</body>
|
|
96
|
+
</html>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function SiteFooter() {
|
|
101
|
+
const { brand } = siteConfig;
|
|
102
|
+
return (
|
|
103
|
+
<footer className="border-t border-zinc-200/70 bg-white">
|
|
104
|
+
<div className="mx-auto max-w-3xl px-6 py-12">
|
|
105
|
+
<div className="flex flex-col items-start justify-between gap-6 sm:flex-row">
|
|
106
|
+
<div className="max-w-sm">
|
|
107
|
+
<Link href="/" className="inline-flex items-center gap-2">
|
|
108
|
+
<span className="flex size-6 items-center justify-center rounded-[7px] bg-zinc-900 text-[13px] font-bold text-white">
|
|
109
|
+
{brand.letter}
|
|
110
|
+
</span>
|
|
111
|
+
<span className="text-[15px] font-semibold tracking-tight text-zinc-900">
|
|
112
|
+
{brand.name}
|
|
113
|
+
</span>
|
|
114
|
+
</Link>
|
|
115
|
+
<p className="mt-3 text-[13px] leading-relaxed text-zinc-500">
|
|
116
|
+
{brand.footerBlurb}
|
|
117
|
+
</p>
|
|
118
|
+
</div>
|
|
119
|
+
<div className="flex items-center gap-4">
|
|
120
|
+
{brand.socials.map((s) => (
|
|
121
|
+
<a
|
|
122
|
+
key={s.label}
|
|
123
|
+
href={s.href}
|
|
124
|
+
aria-label={s.label}
|
|
125
|
+
className="text-zinc-400 transition-colors hover:text-zinc-900"
|
|
126
|
+
>
|
|
127
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
|
128
|
+
<path d={s.path} />
|
|
129
|
+
</svg>
|
|
130
|
+
</a>
|
|
131
|
+
))}
|
|
132
|
+
<a
|
|
133
|
+
href={`mailto:${brand.email}`}
|
|
134
|
+
aria-label="Email"
|
|
135
|
+
className="text-zinc-400 transition-colors hover:text-zinc-900"
|
|
136
|
+
>
|
|
137
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
138
|
+
<rect x="3" y="5" width="18" height="14" rx="2" />
|
|
139
|
+
<path d="m3 7 9 6 9-6" />
|
|
140
|
+
</svg>
|
|
141
|
+
</a>
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
<div className="mt-10 flex flex-col items-start justify-between gap-3 border-t border-zinc-200/70 pt-6 text-[12px] text-zinc-400 sm:flex-row sm:items-center">
|
|
145
|
+
<span>
|
|
146
|
+
© {new Date().getFullYear()} {brand.copyrightName}
|
|
147
|
+
</span>
|
|
148
|
+
<span>
|
|
149
|
+
Built with{" "}
|
|
150
|
+
<a href="https://pylonsync.com" className="font-medium text-zinc-600 hover:text-zinc-900">
|
|
151
|
+
Pylon
|
|
152
|
+
</a>
|
|
153
|
+
</span>
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
</footer>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Link, type Metadata, type PageProps } from "@pylonsync/react";
|
|
3
|
+
import { AuthForm } from "../auth-form";
|
|
4
|
+
import { siteConfig } from "@/lib/site.config";
|
|
5
|
+
|
|
6
|
+
export const metadata: Metadata = {
|
|
7
|
+
title: `Sign in — ${siteConfig.brand.name}`,
|
|
8
|
+
robots: "noindex",
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
// `app/login/page.tsx` → `/login`. The owner's sign-in. Rendered bare (the
|
|
12
|
+
// layout suppresses the marketing nav/footer for /login). Already signed in?
|
|
13
|
+
// Skip straight to the dashboard — `response.redirect` in the synchronous shell
|
|
14
|
+
// render is a real 307 before any HTML is sent.
|
|
15
|
+
export default function LoginPage({ auth, response }: PageProps) {
|
|
16
|
+
// Already signed in (a real account, not an anonymous guest)? Skip the form.
|
|
17
|
+
if (auth.user_id && !auth.user_id.startsWith("guest_")) response.redirect("/dashboard");
|
|
18
|
+
const { brand } = siteConfig;
|
|
19
|
+
return (
|
|
20
|
+
<div className="flex min-h-screen items-center justify-center bg-white px-6 py-12">
|
|
21
|
+
<div className="w-full max-w-[400px] rounded-2xl border border-zinc-200/70 p-8">
|
|
22
|
+
<Link href="/" className="inline-flex">
|
|
23
|
+
<span className="flex size-9 items-center justify-center rounded-xl bg-zinc-900 text-base font-bold text-white">
|
|
24
|
+
{brand.letter}
|
|
25
|
+
</span>
|
|
26
|
+
</Link>
|
|
27
|
+
<h1 className="mt-5 text-[22px] font-semibold tracking-tight text-zinc-900">
|
|
28
|
+
{brand.name} dashboard
|
|
29
|
+
</h1>
|
|
30
|
+
<p className="mt-1 text-[13px] text-zinc-500">
|
|
31
|
+
Sign in to see who's on your waitlist.
|
|
32
|
+
</p>
|
|
33
|
+
<div className="mt-6">
|
|
34
|
+
<AuthForm />
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Link, type NotFoundProps } from "@pylonsync/react";
|
|
3
|
+
|
|
4
|
+
// `app/not-found.tsx` → rendered at HTTP 404 for any unmatched URL (and when a
|
|
5
|
+
// page calls `response.notFound()`). Hydrated, so the link is a client nav.
|
|
6
|
+
export default function NotFound(_props: NotFoundProps) {
|
|
7
|
+
return (
|
|
8
|
+
<div className="mx-auto flex min-h-[60vh] max-w-3xl flex-col items-center justify-center px-6 text-center">
|
|
9
|
+
<h1 className="text-3xl font-semibold tracking-tight">404</h1>
|
|
10
|
+
<p className="mt-2 text-zinc-500">We couldn't find that page.</p>
|
|
11
|
+
<Link
|
|
12
|
+
href="/"
|
|
13
|
+
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"
|
|
14
|
+
>
|
|
15
|
+
Back home
|
|
16
|
+
</Link>
|
|
17
|
+
</div>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { type Metadata } from "@pylonsync/react";
|
|
3
|
+
import { WRAP, Eyebrow, Badge, Divider, SectionHead, FeatureGrid, initials } from "@/components/marketing";
|
|
4
|
+
import { WaitlistHero } from "./waitlist-hero";
|
|
5
|
+
import { siteConfig } from "@/lib/site.config";
|
|
6
|
+
|
|
7
|
+
// SEO metadata — server-rendered into <head>, so the page is fully indexable
|
|
8
|
+
// (view source and the copy is in the HTML). All copy lives in
|
|
9
|
+
// lib/site.config.ts; edit it there to rebrand.
|
|
10
|
+
export const metadata: Metadata = {
|
|
11
|
+
title: siteConfig.seo.title,
|
|
12
|
+
description: siteConfig.seo.description,
|
|
13
|
+
openGraph: {
|
|
14
|
+
title: siteConfig.seo.title,
|
|
15
|
+
description: siteConfig.seo.description,
|
|
16
|
+
type: "website",
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// `app/page.tsx` → `/`. A server-rendered, single-page coming-soon landing.
|
|
21
|
+
// The headline, value props, proof, and FAQ are static server HTML (great for
|
|
22
|
+
// SEO + first paint); the email form and live counter are a client island
|
|
23
|
+
// (<WaitlistHero>) that hydrates in the browser. Every string is sourced from
|
|
24
|
+
// `siteConfig`, so the whole page rebrands from one file.
|
|
25
|
+
//
|
|
26
|
+
// Note: this page intentionally does NOT read `auth`, so it stays a public,
|
|
27
|
+
// cacheable render. Signed-in state (the nav's "Dashboard" link) is resolved in
|
|
28
|
+
// the layout.
|
|
29
|
+
export default function LandingPage() {
|
|
30
|
+
const { hero, counter, valueProps, socialProof, faq } = siteConfig;
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<div className="bg-white text-zinc-900">
|
|
34
|
+
{/* ============================= HERO ============================= */}
|
|
35
|
+
<section className={`${WRAP} pt-20 pb-16 sm:pt-28`}>
|
|
36
|
+
<div className="flex flex-col items-center text-center">
|
|
37
|
+
<Badge>{hero.badge}</Badge>
|
|
38
|
+
<h1 className="mt-6 max-w-2xl text-balance text-[2.5rem] font-semibold leading-[1.05] tracking-[-0.02em] sm:text-[3.25rem]">
|
|
39
|
+
{hero.headline}
|
|
40
|
+
</h1>
|
|
41
|
+
<p className="mt-5 max-w-xl text-balance text-[17px] leading-relaxed text-zinc-500">
|
|
42
|
+
{hero.subcopy}
|
|
43
|
+
</p>
|
|
44
|
+
</div>
|
|
45
|
+
<div className="mt-10">
|
|
46
|
+
<WaitlistHero hero={hero} counter={counter} />
|
|
47
|
+
</div>
|
|
48
|
+
</section>
|
|
49
|
+
|
|
50
|
+
{/* ========================== VALUE PROPS ========================= */}
|
|
51
|
+
<Divider />
|
|
52
|
+
<section className={`${WRAP} py-16`}>
|
|
53
|
+
<SectionHead eyebrow={valueProps.eyebrow} title={valueProps.headline} />
|
|
54
|
+
<div className="mt-10">
|
|
55
|
+
<FeatureGrid items={valueProps.items} />
|
|
56
|
+
</div>
|
|
57
|
+
</section>
|
|
58
|
+
|
|
59
|
+
{/* ========================= SOCIAL PROOF ========================= */}
|
|
60
|
+
{socialProof && socialProof.quotes && socialProof.quotes.length > 0 ? (
|
|
61
|
+
<>
|
|
62
|
+
<Divider />
|
|
63
|
+
<section className={`${WRAP} py-16`}>
|
|
64
|
+
<p className="font-mono text-[11px] uppercase tracking-[0.14em] text-zinc-400">
|
|
65
|
+
{socialProof.label}
|
|
66
|
+
</p>
|
|
67
|
+
<div className="mt-8 grid gap-5 sm:grid-cols-3">
|
|
68
|
+
{socialProof.quotes.map((q) => (
|
|
69
|
+
<figure
|
|
70
|
+
key={q.name + q.role}
|
|
71
|
+
className="flex flex-col rounded-2xl border border-zinc-200 bg-paper p-6"
|
|
72
|
+
>
|
|
73
|
+
<blockquote className="text-[14px] leading-relaxed text-zinc-600">
|
|
74
|
+
“{q.quote}”
|
|
75
|
+
</blockquote>
|
|
76
|
+
<figcaption className="mt-6 flex items-center gap-3">
|
|
77
|
+
<span className="flex size-8 items-center justify-center rounded-full bg-zinc-200 text-[11px] font-semibold text-zinc-500">
|
|
78
|
+
{initials(q.name)}
|
|
79
|
+
</span>
|
|
80
|
+
<div className="leading-tight">
|
|
81
|
+
<div className="text-[13px] font-semibold">{q.name}</div>
|
|
82
|
+
<div className="text-[12px] text-zinc-500">{q.role}</div>
|
|
83
|
+
</div>
|
|
84
|
+
</figcaption>
|
|
85
|
+
</figure>
|
|
86
|
+
))}
|
|
87
|
+
</div>
|
|
88
|
+
</section>
|
|
89
|
+
</>
|
|
90
|
+
) : null}
|
|
91
|
+
|
|
92
|
+
{/* ============================== FAQ ============================= */}
|
|
93
|
+
{faq && faq.items.length > 0 ? (
|
|
94
|
+
<>
|
|
95
|
+
<Divider />
|
|
96
|
+
<section className={`${WRAP} py-16`}>
|
|
97
|
+
<Eyebrow>{faq.eyebrow}</Eyebrow>
|
|
98
|
+
<h2 className="mt-4 text-balance text-2xl font-semibold leading-[1.15] tracking-[-0.02em] sm:text-3xl">
|
|
99
|
+
{faq.headline}
|
|
100
|
+
</h2>
|
|
101
|
+
<div className="mt-8 divide-y divide-zinc-200/70 border-y border-zinc-200/70">
|
|
102
|
+
{faq.items.map((f) => (
|
|
103
|
+
<details key={f.q} className="group py-5">
|
|
104
|
+
<summary className="flex cursor-pointer items-center justify-between text-[15px] font-medium text-zinc-900 marker:hidden [&::-webkit-details-marker]:hidden">
|
|
105
|
+
{f.q}
|
|
106
|
+
<span className="text-brand transition-transform group-open:rotate-45">+</span>
|
|
107
|
+
</summary>
|
|
108
|
+
<p className="mt-3 max-w-2xl text-[14px] leading-relaxed text-zinc-500">
|
|
109
|
+
{f.a}
|
|
110
|
+
</p>
|
|
111
|
+
</details>
|
|
112
|
+
))}
|
|
113
|
+
</div>
|
|
114
|
+
</section>
|
|
115
|
+
</>
|
|
116
|
+
) : null}
|
|
117
|
+
</div>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Robots } from "@pylonsync/react";
|
|
2
|
+
|
|
3
|
+
// app/robots.ts → served at /robots.txt. Point SITE_URL at your domain in prod.
|
|
4
|
+
const SITE = process.env.SITE_URL ?? "http://localhost:4321";
|
|
5
|
+
|
|
6
|
+
export default function robots(): Robots {
|
|
7
|
+
return {
|
|
8
|
+
// Keep the owner dashboard and the API out of the index.
|
|
9
|
+
rules: { userAgent: "*", allow: "/", disallow: ["/dashboard", "/login", "/api/"] },
|
|
10
|
+
sitemap: `${SITE}/sitemap.xml`,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Sitemap } from "@pylonsync/react";
|
|
2
|
+
|
|
3
|
+
// app/sitemap.ts → served at /sitemap.xml. Point SITE_URL at your domain in
|
|
4
|
+
// production. The waitlist is a single public page, so the sitemap is just "/".
|
|
5
|
+
const SITE = process.env.SITE_URL ?? "http://localhost:4321";
|
|
6
|
+
|
|
7
|
+
export default async function sitemap(): Promise<Sitemap> {
|
|
8
|
+
return [{ url: `${SITE}/`, changeFrequency: "weekly", priority: 1 }];
|
|
9
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
4
|
+
import { db, callFn } from "@pylonsync/react";
|
|
5
|
+
import { EnsureGuest } from "@pylonsync/client";
|
|
6
|
+
import type { WaitlistConfig } from "@/lib/site.config";
|
|
7
|
+
|
|
8
|
+
// The interactive top of the landing page: the email-capture form and the LIVE
|
|
9
|
+
// signup counter. This is the realtime proof — the counter is a live
|
|
10
|
+
// `db.useQuery("WaitlistStat")` over the public, PII-free aggregate row, so the
|
|
11
|
+
// moment anyone (this tab or another) submits an email, joinWaitlist updates
|
|
12
|
+
// that row and the new count syncs to every open tab through the replica. No
|
|
13
|
+
// refresh, no polling.
|
|
14
|
+
//
|
|
15
|
+
// The signup form (joinWaitlist) is a public mutation, so it works for any
|
|
16
|
+
// anonymous visitor. The counter needs a live sync connection, so it's wrapped
|
|
17
|
+
// in <EnsureGuest>, which mints an anonymous guest session on first load — that
|
|
18
|
+
// session is what the sync engine connects with. It holds no PII, and the
|
|
19
|
+
// Signup table stays unreadable to it; the page only ever reads the aggregate
|
|
20
|
+
// count, never an email.
|
|
21
|
+
|
|
22
|
+
type HeroProps = {
|
|
23
|
+
hero: WaitlistConfig["hero"];
|
|
24
|
+
counter: WaitlistConfig["counter"];
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function WaitlistHero({ hero, counter }: HeroProps) {
|
|
28
|
+
return (
|
|
29
|
+
<div className="flex flex-col items-center text-center">
|
|
30
|
+
<SignupForm hero={hero} />
|
|
31
|
+
{counter.enabled ? (
|
|
32
|
+
<div className="mt-8">
|
|
33
|
+
<LiveCounter seed={counter.seedCount ?? 0} label={counter.label} />
|
|
34
|
+
</div>
|
|
35
|
+
) : null}
|
|
36
|
+
</div>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/* ----------------------------- signup form ---------------------------- */
|
|
41
|
+
|
|
42
|
+
function SignupForm({ hero }: { hero: WaitlistConfig["hero"] }) {
|
|
43
|
+
const [email, setEmail] = useState("");
|
|
44
|
+
const [status, setStatus] = useState<"idle" | "sending" | "done">("idle");
|
|
45
|
+
const [alreadyJoined, setAlreadyJoined] = useState(false);
|
|
46
|
+
const [error, setError] = useState<string | null>(null);
|
|
47
|
+
|
|
48
|
+
async function onSubmit(e: React.FormEvent) {
|
|
49
|
+
e.preventDefault();
|
|
50
|
+
const value = email.trim();
|
|
51
|
+
if (!value || status === "sending") return;
|
|
52
|
+
setStatus("sending");
|
|
53
|
+
setError(null);
|
|
54
|
+
try {
|
|
55
|
+
const res = await callFn<{ ok: boolean; alreadyJoined: boolean }>(
|
|
56
|
+
"joinWaitlist",
|
|
57
|
+
{ email: value },
|
|
58
|
+
);
|
|
59
|
+
setAlreadyJoined(res.alreadyJoined);
|
|
60
|
+
setStatus("done");
|
|
61
|
+
// The live counter updates itself via the reactive push — no manual
|
|
62
|
+
// increment needed here.
|
|
63
|
+
} catch (err) {
|
|
64
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
65
|
+
setError(
|
|
66
|
+
/valid email|INVALID_ARGS/i.test(msg)
|
|
67
|
+
? "Enter a valid email address."
|
|
68
|
+
: "Something went wrong — try again in a moment.",
|
|
69
|
+
);
|
|
70
|
+
setStatus("idle");
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (status === "done") {
|
|
75
|
+
return (
|
|
76
|
+
<div className="w-full max-w-md rounded-2xl border border-brand/30 bg-brand-soft/60 px-6 py-5 text-center">
|
|
77
|
+
<div className="mx-auto flex size-9 items-center justify-center rounded-full bg-brand text-white">
|
|
78
|
+
<CheckIcon />
|
|
79
|
+
</div>
|
|
80
|
+
<p className="mt-3 text-[15px] font-medium text-zinc-900">
|
|
81
|
+
{alreadyJoined ? "You're already on the list." : hero.successMessage}
|
|
82
|
+
</p>
|
|
83
|
+
<p className="mt-1 text-[13px] text-zinc-500">
|
|
84
|
+
Watch the counter below — it just ticked up for everyone.
|
|
85
|
+
</p>
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<form onSubmit={onSubmit} className="w-full max-w-md">
|
|
92
|
+
<div className="flex flex-col gap-2 sm:flex-row">
|
|
93
|
+
<input
|
|
94
|
+
type="email"
|
|
95
|
+
inputMode="email"
|
|
96
|
+
autoComplete="email"
|
|
97
|
+
value={email}
|
|
98
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
99
|
+
placeholder={hero.emailPlaceholder}
|
|
100
|
+
aria-label="Email address"
|
|
101
|
+
required
|
|
102
|
+
className="h-11 flex-1 rounded-full border border-zinc-300 bg-white px-4 text-[15px] text-zinc-900 outline-none transition placeholder:text-zinc-400 focus:border-brand focus:ring-2 focus:ring-brand/20"
|
|
103
|
+
/>
|
|
104
|
+
<button
|
|
105
|
+
type="submit"
|
|
106
|
+
disabled={status === "sending"}
|
|
107
|
+
className="inline-flex h-11 items-center justify-center rounded-full bg-zinc-900 px-6 text-[15px] font-medium text-white transition-colors hover:bg-zinc-700 disabled:opacity-60"
|
|
108
|
+
>
|
|
109
|
+
{status === "sending" ? "Joining…" : hero.ctaLabel}
|
|
110
|
+
</button>
|
|
111
|
+
</div>
|
|
112
|
+
{error ? (
|
|
113
|
+
<p className="mt-2 text-[13px] text-red-600">{error}</p>
|
|
114
|
+
) : (
|
|
115
|
+
<p className="mt-2 text-[13px] text-zinc-400">
|
|
116
|
+
No spam. One invite, the day we launch.
|
|
117
|
+
</p>
|
|
118
|
+
)}
|
|
119
|
+
</form>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/* ----------------------------- live counter ---------------------------- */
|
|
124
|
+
|
|
125
|
+
interface WaitlistStatRow {
|
|
126
|
+
id: string;
|
|
127
|
+
count: number;
|
|
128
|
+
updatedAt: string;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function LiveCounter({ seed, label }: { seed: number; label: string }) {
|
|
132
|
+
return (
|
|
133
|
+
<EnsureGuest fallback={<CounterView value={seed} label={label} />}>
|
|
134
|
+
<LiveCounterInner seed={seed} label={label} />
|
|
135
|
+
</EnsureGuest>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function LiveCounterInner({ seed, label }: { seed: number; label: string }) {
|
|
140
|
+
// Live entity query over the public WaitlistStat row. `db.useQuery` re-renders
|
|
141
|
+
// the instant the row changes — in THIS tab and every other open tab, because
|
|
142
|
+
// the change syncs through the shared replica. `loading` is true until the
|
|
143
|
+
// first sync settles; until then we show the seed (never a flash of 0).
|
|
144
|
+
const { data, loading } = db.useQuery<WaitlistStatRow>("WaitlistStat");
|
|
145
|
+
const real = data.length > 0 ? data[0].count : 0;
|
|
146
|
+
const value = seed + real;
|
|
147
|
+
return <CounterView value={value} label={label} live={!loading} />;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function CounterView({
|
|
151
|
+
value,
|
|
152
|
+
label,
|
|
153
|
+
live,
|
|
154
|
+
}: {
|
|
155
|
+
value: number;
|
|
156
|
+
label: string;
|
|
157
|
+
live?: boolean;
|
|
158
|
+
}) {
|
|
159
|
+
const shown = useCountUp(value);
|
|
160
|
+
return (
|
|
161
|
+
<div className="inline-flex items-center gap-2.5 rounded-full border border-zinc-200 bg-white px-4 py-2 shadow-sm">
|
|
162
|
+
{live ? (
|
|
163
|
+
<span className="relative flex size-2">
|
|
164
|
+
<span className="absolute inline-flex size-2 animate-ping rounded-full bg-brand/70" />
|
|
165
|
+
<span className="relative inline-flex size-2 rounded-full bg-brand" />
|
|
166
|
+
</span>
|
|
167
|
+
) : (
|
|
168
|
+
<span className="inline-flex size-2 rounded-full bg-zinc-300" />
|
|
169
|
+
)}
|
|
170
|
+
<span className="text-[15px] font-semibold tabular-nums text-zinc-900">
|
|
171
|
+
{shown.toLocaleString()}
|
|
172
|
+
</span>
|
|
173
|
+
<span className="text-[13px] text-zinc-500">{label}</span>
|
|
174
|
+
</div>
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Smoothly tween the displayed number toward `target` whenever it changes, so a
|
|
179
|
+
// live increment animates up instead of snapping. First paint shows the value
|
|
180
|
+
// directly (no animation).
|
|
181
|
+
function useCountUp(target: number): number {
|
|
182
|
+
const [shown, setShown] = useState(target);
|
|
183
|
+
const fromRef = useRef(target);
|
|
184
|
+
const rafRef = useRef<number | null>(null);
|
|
185
|
+
|
|
186
|
+
useEffect(() => {
|
|
187
|
+
const from = fromRef.current;
|
|
188
|
+
if (from === target) return;
|
|
189
|
+
const start = performance.now();
|
|
190
|
+
const duration = 600;
|
|
191
|
+
const step = (now: number) => {
|
|
192
|
+
const t = Math.min(1, (now - start) / duration);
|
|
193
|
+
// easeOutCubic
|
|
194
|
+
const eased = 1 - Math.pow(1 - t, 3);
|
|
195
|
+
const next = Math.round(from + (target - from) * eased);
|
|
196
|
+
setShown(next);
|
|
197
|
+
if (t < 1) {
|
|
198
|
+
rafRef.current = requestAnimationFrame(step);
|
|
199
|
+
} else {
|
|
200
|
+
fromRef.current = target;
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
rafRef.current = requestAnimationFrame(step);
|
|
204
|
+
return () => {
|
|
205
|
+
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
|
|
206
|
+
fromRef.current = target;
|
|
207
|
+
};
|
|
208
|
+
}, [target]);
|
|
209
|
+
|
|
210
|
+
return shown;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function CheckIcon() {
|
|
214
|
+
return (
|
|
215
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
|
216
|
+
<path d="M20 6 9 17l-5-5" />
|
|
217
|
+
</svg>
|
|
218
|
+
);
|
|
219
|
+
}
|