@pylonsync/create-pylon 0.3.273 → 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,134 @@
|
|
|
1
|
+
import {
|
|
2
|
+
entity,
|
|
3
|
+
field,
|
|
4
|
+
policy,
|
|
5
|
+
auth,
|
|
6
|
+
buildManifest,
|
|
7
|
+
discoverAppRoutes,
|
|
8
|
+
} from "@pylonsync/sdk";
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// waitlist — a pre-launch / coming-soon landing page with a LIVE signup
|
|
12
|
+
// counter. The whole point is the realtime hook: open the page in two tabs,
|
|
13
|
+
// submit an email in one, and the counter on the other ticks up with no
|
|
14
|
+
// refresh. That's the proof it's a real live app and not a static page.
|
|
15
|
+
//
|
|
16
|
+
// The data model is deliberately tiny — two entities:
|
|
17
|
+
// • Signup — one row per email. Holds visitor PII, so it denies ALL client
|
|
18
|
+
// reads/writes (writes go through the joinWaitlist mutation; the
|
|
19
|
+
// public page only ever sees an aggregate count, never an email).
|
|
20
|
+
// • User — the business owner's account (email/password is built in), so
|
|
21
|
+
// the owner can sign in to the dashboard and see their signups.
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
// One waitlist signup. `email` is the only PII; `createdAt` powers the
|
|
25
|
+
// signups-over-time chart on the dashboard. The unique index on email dedupes
|
|
26
|
+
// at the database level — a duplicate insert is rejected even under a race, so
|
|
27
|
+
// joinWaitlist can treat the conflict as "already joined".
|
|
28
|
+
const Signup = entity(
|
|
29
|
+
"Signup",
|
|
30
|
+
{
|
|
31
|
+
email: field.string(),
|
|
32
|
+
createdAt: field.datetime().defaultNow(),
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
indexes: [
|
|
36
|
+
{ name: "by_email", fields: ["email"], unique: true },
|
|
37
|
+
{ name: "by_created", fields: ["createdAt"], unique: false },
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
// A single-row, PII-FREE aggregate the public page can safely read live. It
|
|
43
|
+
// holds only the signup count — no emails. `joinWaitlist` keeps it in sync with
|
|
44
|
+
// the real Signup count on every new join. The landing page subscribes with
|
|
45
|
+
// `db.useQuery("WaitlistStat")`, which syncs across every open tab through the
|
|
46
|
+
// replica — so the counter ticks up everywhere the instant someone joins. This
|
|
47
|
+
// is the cross-tab-safe realtime primitive (entity sync), not a per-connection
|
|
48
|
+
// server-query subscription.
|
|
49
|
+
const WaitlistStat = entity(
|
|
50
|
+
"WaitlistStat",
|
|
51
|
+
{
|
|
52
|
+
count: field.int().default(0),
|
|
53
|
+
updatedAt: field.datetime().defaultNow(),
|
|
54
|
+
},
|
|
55
|
+
{},
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
// The business owner's account. Email/password auth is built in against an
|
|
59
|
+
// entity named "User" (passwordHash is server-only; the register route stamps
|
|
60
|
+
// avatarColor). The dashboard is gated to the owner — see PYLON_OWNER_EMAIL in
|
|
61
|
+
// functions/waitlistStats.ts and app/dashboard/page.tsx.
|
|
62
|
+
const User = entity(
|
|
63
|
+
"User",
|
|
64
|
+
{
|
|
65
|
+
email: field.string(),
|
|
66
|
+
displayName: field.string().optional(),
|
|
67
|
+
passwordHash: field.string().serverOnly().optional(),
|
|
68
|
+
avatarColor: field.string().optional(),
|
|
69
|
+
// Set when the owner verifies their email; unused by the waitlist flow but
|
|
70
|
+
// declared so the framework's email-verification routes have a column.
|
|
71
|
+
emailVerified: field.datetime().optional(),
|
|
72
|
+
createdAt: field.datetime().defaultNow(),
|
|
73
|
+
},
|
|
74
|
+
{ indexes: [{ name: "by_email", fields: ["email"], unique: true }] },
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
// PRIVACY — the heart of the spec. Signup holds visitor emails, so it denies
|
|
78
|
+
// EVERY client read and write. No `db.useQuery("Signup")` can ever pull a row,
|
|
79
|
+
// and no client can insert/update/delete directly. Writes happen only inside
|
|
80
|
+
// the server-side `joinWaitlist` mutation (functions bypass policies); reads
|
|
81
|
+
// happen only inside `waitlistCount` (returns a bare integer) and the
|
|
82
|
+
// owner-gated `waitlistStats`. A marketing site must never leak its own
|
|
83
|
+
// customers' emails — this policy is what guarantees it.
|
|
84
|
+
const signupPolicy = policy({
|
|
85
|
+
name: "signup_private",
|
|
86
|
+
entity: "Signup",
|
|
87
|
+
allowRead: "false",
|
|
88
|
+
allowInsert: "false",
|
|
89
|
+
allowUpdate: "false",
|
|
90
|
+
allowDelete: "false",
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// The aggregate count is public to READ (it's just a number — the whole point
|
|
94
|
+
// is that the landing page shows it live to everyone). Clients can't WRITE it;
|
|
95
|
+
// only the joinWaitlist mutation maintains it server-side.
|
|
96
|
+
const waitlistStatPolicy = policy({
|
|
97
|
+
name: "waitlist_stat_public_read",
|
|
98
|
+
entity: "WaitlistStat",
|
|
99
|
+
allowRead: "true",
|
|
100
|
+
allowInsert: "false",
|
|
101
|
+
allowUpdate: "false",
|
|
102
|
+
allowDelete: "false",
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// The owner reads their own User row (the dashboard resolves their email this
|
|
106
|
+
// way to check ownership). The auth subsystem owns all writes.
|
|
107
|
+
const userPolicy = policy({
|
|
108
|
+
name: "user_self",
|
|
109
|
+
entity: "User",
|
|
110
|
+
allowRead: "auth.userId == data.id",
|
|
111
|
+
allowInsert: "false",
|
|
112
|
+
allowUpdate: "false",
|
|
113
|
+
allowDelete: "false",
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const manifest = buildManifest({
|
|
117
|
+
name: "__APP_NAME__",
|
|
118
|
+
version: "0.1.0",
|
|
119
|
+
entities: [Signup, WaitlistStat, User],
|
|
120
|
+
// joinWaitlist (public mutation) + waitlistStats (owner-gated query) live in
|
|
121
|
+
// functions/ and are discovered automatically — they don't need listing here.
|
|
122
|
+
queries: [],
|
|
123
|
+
actions: [],
|
|
124
|
+
policies: [signupPolicy, waitlistStatPolicy, userPolicy],
|
|
125
|
+
// Email/password is on by default against the User entity above. No orgs,
|
|
126
|
+
// no billing — a waitlist is single-tenant (one business, one owner).
|
|
127
|
+
auth: auth(),
|
|
128
|
+
routes: await discoverAppRoutes(),
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// Emit the canonical manifest JSON to stdout — `pylon dev` captures this.
|
|
132
|
+
console.log(JSON.stringify(manifest, null, 2));
|
|
133
|
+
|
|
134
|
+
export default manifest;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
// Reusable presentational pieces for the landing page. All server-rendered —
|
|
4
|
+
// no client JS. Restyle here and the whole page follows. The brand accent
|
|
5
|
+
// (`text-brand`, `bg-brand-soft`) comes from CSS vars set on <html> in
|
|
6
|
+
// app/layout.tsx, which read lib/site.config.ts — so re-theming is one edit.
|
|
7
|
+
|
|
8
|
+
// Shared container: a contained, centered column.
|
|
9
|
+
export const WRAP = "mx-auto w-full max-w-3xl px-6";
|
|
10
|
+
|
|
11
|
+
export function Eyebrow({ children }: { children: React.ReactNode }) {
|
|
12
|
+
return (
|
|
13
|
+
<p className="font-mono text-[11px] font-semibold uppercase tracking-[0.14em] text-brand">
|
|
14
|
+
{children}
|
|
15
|
+
</p>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// "New / Coming soon"-style pill for the hero.
|
|
20
|
+
export function Badge({ children }: { children: React.ReactNode }) {
|
|
21
|
+
return (
|
|
22
|
+
<span className="inline-flex items-center gap-2 rounded-full border border-zinc-200 bg-white py-1 pl-1.5 pr-3 text-[13px] text-zinc-600 shadow-sm">
|
|
23
|
+
<span className="inline-block size-1.5 rounded-full bg-brand" />
|
|
24
|
+
{children}
|
|
25
|
+
</span>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function Divider() {
|
|
30
|
+
return (
|
|
31
|
+
<div className={WRAP}>
|
|
32
|
+
<div className="border-t border-zinc-200/70" />
|
|
33
|
+
</div>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function SectionHead({
|
|
38
|
+
eyebrow,
|
|
39
|
+
title,
|
|
40
|
+
body,
|
|
41
|
+
}: {
|
|
42
|
+
eyebrow: string;
|
|
43
|
+
title: string;
|
|
44
|
+
body?: string;
|
|
45
|
+
}) {
|
|
46
|
+
return (
|
|
47
|
+
<div>
|
|
48
|
+
<Eyebrow>{eyebrow}</Eyebrow>
|
|
49
|
+
<h2 className="mt-4 text-balance text-2xl font-semibold leading-[1.15] tracking-[-0.02em] sm:text-3xl">
|
|
50
|
+
{title}
|
|
51
|
+
</h2>
|
|
52
|
+
{body ? (
|
|
53
|
+
<p className="mt-4 max-w-xl text-[15px] leading-relaxed text-zinc-500">
|
|
54
|
+
{body}
|
|
55
|
+
</p>
|
|
56
|
+
) : null}
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// A grid of value props — icon + title + body.
|
|
62
|
+
export function FeatureGrid({
|
|
63
|
+
items,
|
|
64
|
+
}: {
|
|
65
|
+
items: { title: string; body: string; icon?: string }[];
|
|
66
|
+
}) {
|
|
67
|
+
return (
|
|
68
|
+
<div className="grid gap-6 sm:grid-cols-3">
|
|
69
|
+
{items.map((f) => (
|
|
70
|
+
<div key={f.title}>
|
|
71
|
+
{f.icon ? (
|
|
72
|
+
<span className="flex size-9 items-center justify-center rounded-lg bg-brand-soft text-brand">
|
|
73
|
+
{f.icon}
|
|
74
|
+
</span>
|
|
75
|
+
) : null}
|
|
76
|
+
<h3 className="mt-4 text-[15px] font-semibold text-zinc-900">
|
|
77
|
+
{f.title}
|
|
78
|
+
</h3>
|
|
79
|
+
<p className="mt-2 text-[14px] leading-relaxed text-zinc-500">
|
|
80
|
+
{f.body}
|
|
81
|
+
</p>
|
|
82
|
+
</div>
|
|
83
|
+
))}
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Initials for testimonial avatars, so the cards look finished without a photo.
|
|
89
|
+
export function initials(name: string) {
|
|
90
|
+
return name
|
|
91
|
+
.split(/\s+/)
|
|
92
|
+
.map((w) => w[0])
|
|
93
|
+
.join("")
|
|
94
|
+
.slice(0, 2)
|
|
95
|
+
.toUpperCase();
|
|
96
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { Slot } from "@radix-ui/react-slot";
|
|
3
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
4
|
+
|
|
5
|
+
import { cn } from "@/lib/utils";
|
|
6
|
+
|
|
7
|
+
const buttonVariants = cva(
|
|
8
|
+
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
|
9
|
+
{
|
|
10
|
+
variants: {
|
|
11
|
+
variant: {
|
|
12
|
+
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
|
13
|
+
destructive:
|
|
14
|
+
"bg-destructive text-white hover:bg-destructive/90",
|
|
15
|
+
outline:
|
|
16
|
+
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
|
17
|
+
secondary:
|
|
18
|
+
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
|
19
|
+
ghost: "hover:bg-accent hover:text-accent-foreground",
|
|
20
|
+
link: "text-primary underline-offset-4 hover:underline",
|
|
21
|
+
},
|
|
22
|
+
size: {
|
|
23
|
+
default: "h-9 px-4 py-2",
|
|
24
|
+
sm: "h-8 rounded-md px-3 text-xs",
|
|
25
|
+
lg: "h-10 rounded-md px-8",
|
|
26
|
+
icon: "h-9 w-9",
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
defaultVariants: {
|
|
30
|
+
variant: "default",
|
|
31
|
+
size: "default",
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
export interface ButtonProps
|
|
37
|
+
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
|
38
|
+
VariantProps<typeof buttonVariants> {
|
|
39
|
+
asChild?: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
43
|
+
({ className, variant, size, asChild = false, ...props }, ref) => {
|
|
44
|
+
const Comp = asChild ? Slot : "button";
|
|
45
|
+
return (
|
|
46
|
+
<Comp
|
|
47
|
+
className={cn(buttonVariants({ variant, size, className }))}
|
|
48
|
+
ref={ref}
|
|
49
|
+
{...props}
|
|
50
|
+
/>
|
|
51
|
+
);
|
|
52
|
+
},
|
|
53
|
+
);
|
|
54
|
+
Button.displayName = "Button";
|
|
55
|
+
|
|
56
|
+
export { Button, buttonVariants };
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
import { cn } from "@/lib/utils";
|
|
4
|
+
|
|
5
|
+
function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
|
6
|
+
return (
|
|
7
|
+
<div
|
|
8
|
+
data-slot="card"
|
|
9
|
+
className={cn(
|
|
10
|
+
"rounded-xl border bg-card text-card-foreground shadow-sm",
|
|
11
|
+
className,
|
|
12
|
+
)}
|
|
13
|
+
{...props}
|
|
14
|
+
/>
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function CardHeader({
|
|
19
|
+
className,
|
|
20
|
+
...props
|
|
21
|
+
}: React.HTMLAttributes<HTMLDivElement>) {
|
|
22
|
+
return (
|
|
23
|
+
<div
|
|
24
|
+
data-slot="card-header"
|
|
25
|
+
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
|
26
|
+
{...props}
|
|
27
|
+
/>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function CardTitle({
|
|
32
|
+
className,
|
|
33
|
+
...props
|
|
34
|
+
}: React.HTMLAttributes<HTMLDivElement>) {
|
|
35
|
+
return (
|
|
36
|
+
<div
|
|
37
|
+
data-slot="card-title"
|
|
38
|
+
className={cn("font-semibold leading-none tracking-tight", className)}
|
|
39
|
+
{...props}
|
|
40
|
+
/>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function CardDescription({
|
|
45
|
+
className,
|
|
46
|
+
...props
|
|
47
|
+
}: React.HTMLAttributes<HTMLDivElement>) {
|
|
48
|
+
return (
|
|
49
|
+
<div
|
|
50
|
+
data-slot="card-description"
|
|
51
|
+
className={cn("text-sm text-muted-foreground", className)}
|
|
52
|
+
{...props}
|
|
53
|
+
/>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function CardContent({
|
|
58
|
+
className,
|
|
59
|
+
...props
|
|
60
|
+
}: React.HTMLAttributes<HTMLDivElement>) {
|
|
61
|
+
return (
|
|
62
|
+
<div
|
|
63
|
+
data-slot="card-content"
|
|
64
|
+
className={cn("p-6 pt-0", className)}
|
|
65
|
+
{...props}
|
|
66
|
+
/>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function CardFooter({
|
|
71
|
+
className,
|
|
72
|
+
...props
|
|
73
|
+
}: React.HTMLAttributes<HTMLDivElement>) {
|
|
74
|
+
return (
|
|
75
|
+
<div
|
|
76
|
+
data-slot="card-footer"
|
|
77
|
+
className={cn("flex items-center p-6 pt-0", className)}
|
|
78
|
+
{...props}
|
|
79
|
+
/>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export {
|
|
84
|
+
Card,
|
|
85
|
+
CardHeader,
|
|
86
|
+
CardFooter,
|
|
87
|
+
CardTitle,
|
|
88
|
+
CardDescription,
|
|
89
|
+
CardContent,
|
|
90
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema.json",
|
|
3
|
+
"style": "new-york",
|
|
4
|
+
"rsc": false,
|
|
5
|
+
"tsx": true,
|
|
6
|
+
"tailwind": {
|
|
7
|
+
"config": "",
|
|
8
|
+
"css": "app/globals.css",
|
|
9
|
+
"baseColor": "zinc",
|
|
10
|
+
"cssVariables": true
|
|
11
|
+
},
|
|
12
|
+
"aliases": {
|
|
13
|
+
"components": "@/components",
|
|
14
|
+
"utils": "@/lib/utils",
|
|
15
|
+
"ui": "@/components/ui",
|
|
16
|
+
"lib": "@/lib",
|
|
17
|
+
"hooks": "@/hooks"
|
|
18
|
+
},
|
|
19
|
+
"iconLibrary": "lucide"
|
|
20
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { mutation, v } from "@pylonsync/functions";
|
|
2
|
+
|
|
3
|
+
// Normalize + sanity-check an email without pulling in a dependency. This is a
|
|
4
|
+
// pragmatic "looks like an email" check, not RFC 5322 — the unique index is the
|
|
5
|
+
// real integrity guard. We cap the length so a hostile caller can't stuff a
|
|
6
|
+
// megabyte string into the row.
|
|
7
|
+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
8
|
+
function normalizeEmail(raw: string): string | null {
|
|
9
|
+
const email = raw.trim().toLowerCase();
|
|
10
|
+
if (email.length < 3 || email.length > 254) return null;
|
|
11
|
+
if (!EMAIL_RE.test(email)) return null;
|
|
12
|
+
return email;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// joinWaitlist — the ONLY way a Signup row is ever written. It's a `mutation`
|
|
16
|
+
// (not an `action`): mutations get `ctx.db` access and run as one atomic
|
|
17
|
+
// transaction, and the insert fires a change event that re-runs the live
|
|
18
|
+
// `waitlistCount` query for every open tab — that's what makes the counter tick
|
|
19
|
+
// up in realtime.
|
|
20
|
+
//
|
|
21
|
+
// `auth: "public"` because a landing-page visitor has no account. Public
|
|
22
|
+
// mutations are still rate-limited at the HTTP layer in production (the
|
|
23
|
+
// framework's rate_limit plugin); here we also validate + dedupe so the same
|
|
24
|
+
// email can't inflate the count.
|
|
25
|
+
//
|
|
26
|
+
// PRIVACY: this returns only `{ ok, alreadyJoined }`. It never returns a Signup
|
|
27
|
+
// row, an id, or anyone else's email.
|
|
28
|
+
export default mutation<{ email: string }, { ok: boolean; alreadyJoined: boolean }>({
|
|
29
|
+
auth: "public",
|
|
30
|
+
args: { email: v.string() },
|
|
31
|
+
async handler(ctx, args) {
|
|
32
|
+
const email = normalizeEmail(args.email);
|
|
33
|
+
if (!email) {
|
|
34
|
+
throw ctx.error("INVALID_ARGS", "Enter a valid email address.");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Signup denies ALL client access by policy, so these go through
|
|
38
|
+
// `ctx.db.unsafe` — the explicit "this is an intentional cross-user write
|
|
39
|
+
// from a trusted handler" surface (also future-proofs against
|
|
40
|
+
// PYLON_STRICT_FN_POLICIES). The handler IS the only writer.
|
|
41
|
+
//
|
|
42
|
+
// Dedupe: if this email already joined, report success idempotently rather
|
|
43
|
+
// than erroring — re-submitting the same address is a no-op, not a failure.
|
|
44
|
+
const existing = await ctx.db.unsafe.lookup("Signup", "email", email);
|
|
45
|
+
if (existing) {
|
|
46
|
+
return { ok: true, alreadyJoined: true };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
await ctx.db.unsafe.insert("Signup", {
|
|
51
|
+
email,
|
|
52
|
+
createdAt: new Date().toISOString(),
|
|
53
|
+
});
|
|
54
|
+
} catch (e) {
|
|
55
|
+
// Lost a race to a concurrent insert of the same email — the unique index
|
|
56
|
+
// on `email` rejected the duplicate. That's still "you're on the list".
|
|
57
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
58
|
+
if (/unique|constraint|conflict|duplicate/i.test(msg)) {
|
|
59
|
+
return { ok: true, alreadyJoined: true };
|
|
60
|
+
}
|
|
61
|
+
throw e;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Keep the public, PII-free WaitlistStat singleton in sync with the real
|
|
65
|
+
// count. We RECOUNT (rather than +1) so the number can never drift, and
|
|
66
|
+
// this whole handler is one transaction — on SQLite writers serialize, so
|
|
67
|
+
// the recount-then-write is consistent. The landing page reads this row via
|
|
68
|
+
// `db.useQuery`, which syncs the new value to every open tab.
|
|
69
|
+
const total = (await ctx.db.unsafe.list("Signup")).length;
|
|
70
|
+
const stat = (await ctx.db.unsafe.list("WaitlistStat"))[0] as
|
|
71
|
+
| { id: string }
|
|
72
|
+
| undefined;
|
|
73
|
+
const now = new Date().toISOString();
|
|
74
|
+
if (stat) {
|
|
75
|
+
await ctx.db.unsafe.update("WaitlistStat", stat.id, { count: total, updatedAt: now });
|
|
76
|
+
} else {
|
|
77
|
+
await ctx.db.unsafe.insert("WaitlistStat", { count: total, updatedAt: now });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return { ok: true, alreadyJoined: false };
|
|
81
|
+
},
|
|
82
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { query } from "@pylonsync/functions";
|
|
2
|
+
import { emailMatchesOwner } from "../lib/owner";
|
|
3
|
+
import type { SignupRow, WaitlistStatsResult } from "../lib/stats";
|
|
4
|
+
|
|
5
|
+
// waitlistStats — the owner's view of the raw signups, INCLUDING emails. This
|
|
6
|
+
// is the one function allowed to return PII, so it's gated hard: only the
|
|
7
|
+
// configured owner (PYLON_OWNER_EMAIL) gets data; anyone else is denied.
|
|
8
|
+
//
|
|
9
|
+
// The dashboard calls this with `callFn` and re-fetches whenever the live,
|
|
10
|
+
// public WaitlistStat count ticks — so the total / chart / list stay current as
|
|
11
|
+
// people join, while the emails themselves never travel over entity sync.
|
|
12
|
+
//
|
|
13
|
+
// `auth: "user"` means an anonymous caller never reaches the handler; the
|
|
14
|
+
// extra owner check is what stops a *different* signed-in user from reading the
|
|
15
|
+
// list. Functions bypass the Signup read policy, which is exactly why the gate
|
|
16
|
+
// has to live here.
|
|
17
|
+
function ymd(iso: string): string {
|
|
18
|
+
// Bucket by calendar day in UTC. `toISOString()` is always YYYY-MM-DDT…Z.
|
|
19
|
+
return new Date(iso).toISOString().slice(0, 10);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export default query({
|
|
23
|
+
auth: "user",
|
|
24
|
+
async handler(ctx): Promise<WaitlistStatsResult> {
|
|
25
|
+
// AuthInfo carries the userId, not the email — resolve the caller's email
|
|
26
|
+
// from their own User row (the User self-read policy allows that), then
|
|
27
|
+
// check it against the configured owner. A non-owner gets `authorized:
|
|
28
|
+
// false` and NOTHING else — no count, no emails. (We return a flag instead
|
|
29
|
+
// of throwing: a query has no `ctx.error`, and a bare throw reaches the
|
|
30
|
+
// client as a generic, message-stripped HANDLER_ERROR.)
|
|
31
|
+
const me = await ctx.db.get("User", ctx.auth.userId);
|
|
32
|
+
const email = (me?.email as string | undefined) ?? null;
|
|
33
|
+
if (!emailMatchesOwner(email, ctx.env.PYLON_OWNER_EMAIL)) {
|
|
34
|
+
return { authorized: false };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Signup denies all client reads; this returns every row (owner-only), so
|
|
38
|
+
// it goes through the intentional cross-user read surface.
|
|
39
|
+
const rows = (await ctx.db.unsafe.list("Signup")) as unknown as SignupRow[];
|
|
40
|
+
|
|
41
|
+
const signups = rows
|
|
42
|
+
.map((r) => ({ id: r.id, email: r.email, createdAt: r.createdAt }))
|
|
43
|
+
.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
|
|
44
|
+
|
|
45
|
+
// Daily buckets for the last 30 days, zero-filled so the chart is
|
|
46
|
+
// continuous even on days with no signups.
|
|
47
|
+
const byDay = new Map<string, number>();
|
|
48
|
+
for (const r of rows) {
|
|
49
|
+
const key = ymd(r.createdAt);
|
|
50
|
+
byDay.set(key, (byDay.get(key) ?? 0) + 1);
|
|
51
|
+
}
|
|
52
|
+
const now = new Date();
|
|
53
|
+
const todayKey = now.toISOString().slice(0, 10);
|
|
54
|
+
const daily: { date: string; count: number }[] = [];
|
|
55
|
+
for (let i = 29; i >= 0; i--) {
|
|
56
|
+
const d = new Date(now);
|
|
57
|
+
d.setUTCDate(d.getUTCDate() - i);
|
|
58
|
+
const key = d.toISOString().slice(0, 10);
|
|
59
|
+
daily.push({ date: key, count: byDay.get(key) ?? 0 });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const sevenDaysAgo = new Date(now);
|
|
63
|
+
sevenDaysAgo.setUTCDate(sevenDaysAgo.getUTCDate() - 7);
|
|
64
|
+
const last7 = rows.filter((r) => new Date(r.createdAt) >= sevenDaysAgo).length;
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
authorized: true,
|
|
68
|
+
total: rows.length,
|
|
69
|
+
today: byDay.get(todayKey) ?? 0,
|
|
70
|
+
last7,
|
|
71
|
+
daily,
|
|
72
|
+
signups,
|
|
73
|
+
};
|
|
74
|
+
},
|
|
75
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Who owns this waitlist? A waitlist is single-tenant — one business, one
|
|
2
|
+
// owner — so ownership is just "the email the owner signs in with", configured
|
|
3
|
+
// once via the PYLON_OWNER_EMAIL env var. The owner-only `waitlistStats`
|
|
4
|
+
// function reads that env (via `ctx.env`) and compares it here.
|
|
5
|
+
//
|
|
6
|
+
// Fail closed: if PYLON_OWNER_EMAIL is unset, NOBODY is the owner and the
|
|
7
|
+
// dashboard stays locked. That's deliberate — an unset owner on a public site
|
|
8
|
+
// must not mean "everyone can read the signups". Set it in .env (see
|
|
9
|
+
// .env.example) before signing in.
|
|
10
|
+
|
|
11
|
+
export function normalizeOwner(raw: string | null | undefined): string | null {
|
|
12
|
+
const v = raw?.trim().toLowerCase();
|
|
13
|
+
return v && v.length > 0 ? v : null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Pure comparator — the caller supplies the configured owner value (from
|
|
17
|
+
// `ctx.env.PYLON_OWNER_EMAIL`), so the rule lives in one place and stays
|
|
18
|
+
// testable without reaching for the environment here.
|
|
19
|
+
export function emailMatchesOwner(
|
|
20
|
+
email: string | null | undefined,
|
|
21
|
+
ownerRaw: string | null | undefined,
|
|
22
|
+
): boolean {
|
|
23
|
+
const owner = normalizeOwner(ownerRaw);
|
|
24
|
+
if (!owner) return false;
|
|
25
|
+
return (email ?? "").trim().toLowerCase() === owner;
|
|
26
|
+
}
|