@pylonsync/create-pylon 0.3.274 → 0.3.276
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 +1440 -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 +249 -0
- package/templates/agency/app/robots.ts +12 -0
- package/templates/agency/app/seeder.tsx +26 -0
- package/templates/agency/app/sitemap.ts +9 -0
- package/templates/agency/app/work/[slug]/page.tsx +182 -0
- package/templates/agency/app/work/page.tsx +83 -0
- package/templates/agency/app.ts +284 -0
- package/templates/agency/components/marketing.tsx +187 -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/clientsForOwner.ts +27 -0
- package/templates/agency/functions/declineInquiry.ts +41 -0
- package/templates/agency/functions/deleteClient.ts +27 -0
- package/templates/agency/functions/deleteInvoice.ts +19 -0
- package/templates/agency/functions/deleteProject.ts +20 -0
- package/templates/agency/functions/inquiriesForOwner.ts +31 -0
- package/templates/agency/functions/invoicesForOwner.ts +27 -0
- package/templates/agency/functions/seedCapacity.ts +26 -0
- package/templates/agency/functions/seedProjects.ts +41 -0
- package/templates/agency/functions/seedStudioBackoffice.ts +74 -0
- package/templates/agency/functions/setCapacity.ts +32 -0
- package/templates/agency/functions/setInvoiceStatus.ts +27 -0
- package/templates/agency/functions/setProjectFlags.ts +35 -0
- package/templates/agency/functions/submitInquiry.ts +55 -0
- package/templates/agency/functions/upsertClient.ts +73 -0
- package/templates/agency/functions/upsertInvoice.ts +113 -0
- package/templates/agency/functions/upsertProject.ts +97 -0
- package/templates/agency/gitignore +10 -0
- package/templates/agency/lib/agency.ts +189 -0
- package/templates/agency/lib/invoice-pdf.tsx +174 -0
- package/templates/agency/lib/owner.ts +26 -0
- package/templates/agency/lib/site.config.ts +418 -0
- package/templates/agency/lib/utils.ts +10 -0
- package/templates/agency/package.json +35 -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 +727 -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/functions/deleteConversation.ts +33 -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 +357 -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
|
+
// newsletter — a pre-launch / coming-soon landing page with a LIVE subscriber
|
|
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
|
+
// • Subscriber — one row per email. Holds visitor PII, so it denies ALL client
|
|
18
|
+
// reads/writes (writes go through the subscribe 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 subscribers.
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
// One newsletter subscriber. `email` is the only PII; `createdAt` powers the
|
|
25
|
+
// subscribers-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
|
+
// subscribe can treat the conflict as "already joined".
|
|
28
|
+
const Subscriber = entity(
|
|
29
|
+
"Subscriber",
|
|
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 subscriber count — no emails. `subscribe` keeps it in sync with
|
|
44
|
+
// the real Subscriber count on every new join. The landing page subscribes with
|
|
45
|
+
// `db.useQuery("SubscriberCount")`, 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 SubscriberCount = entity(
|
|
50
|
+
"SubscriberCount",
|
|
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/subscriberStats.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 newsletter 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. Subscriber holds visitor emails, so it denies
|
|
78
|
+
// EVERY client read and write. No `db.useQuery("Subscriber")` can ever pull a row,
|
|
79
|
+
// and no client can insert/update/delete directly. Writes happen only inside
|
|
80
|
+
// the server-side `subscribe` mutation (functions bypass policies); reads
|
|
81
|
+
// happen only inside `newsletterCount` (returns a bare integer) and the
|
|
82
|
+
// owner-gated `subscriberStats`. A marketing site must never leak its own
|
|
83
|
+
// customers' emails — this policy is what guarantees it.
|
|
84
|
+
const subscriberPolicy = policy({
|
|
85
|
+
name: "subscriber_private",
|
|
86
|
+
entity: "Subscriber",
|
|
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 subscribe mutation maintains it server-side.
|
|
96
|
+
const subscriberCountPolicy = policy({
|
|
97
|
+
name: "subscriber_count_public_read",
|
|
98
|
+
entity: "SubscriberCount",
|
|
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: [Subscriber, SubscriberCount, User],
|
|
120
|
+
// subscribe (public mutation) + subscriberStats (owner-gated query) live in
|
|
121
|
+
// functions/ and are discovered automatically — they don't need listing here.
|
|
122
|
+
queries: [],
|
|
123
|
+
actions: [],
|
|
124
|
+
policies: [subscriberPolicy, subscriberCountPolicy, userPolicy],
|
|
125
|
+
// Email/password is on by default against the User entity above. No orgs,
|
|
126
|
+
// no billing — a newsletter 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,148 @@
|
|
|
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
|
+
}
|
|
97
|
+
|
|
98
|
+
// A deliberately-obvious image placeholder. Real sites drop a photo here; this
|
|
99
|
+
// makes the spot unmistakable — dashed border, a photo glyph, and a one-line
|
|
100
|
+
// "swap this" instruction telling you exactly what to replace and where. Looks
|
|
101
|
+
// tidy enough to demo, but no one will mistake it for a finished design.
|
|
102
|
+
//
|
|
103
|
+
// shape — "landscape" | "portrait" | "square" | "circle"
|
|
104
|
+
// title — what photo belongs here ("Your headshot")
|
|
105
|
+
// hint — how to replace it ("Replace in app/page.tsx")
|
|
106
|
+
export function ImagePlaceholder({
|
|
107
|
+
shape = "landscape",
|
|
108
|
+
title,
|
|
109
|
+
hint,
|
|
110
|
+
className = "",
|
|
111
|
+
}: {
|
|
112
|
+
shape?: "landscape" | "portrait" | "square" | "circle";
|
|
113
|
+
title: string;
|
|
114
|
+
hint?: string;
|
|
115
|
+
className?: string;
|
|
116
|
+
}) {
|
|
117
|
+
const aspect =
|
|
118
|
+
shape === "portrait"
|
|
119
|
+
? "aspect-[4/5]"
|
|
120
|
+
: shape === "square" || shape === "circle"
|
|
121
|
+
? "aspect-square"
|
|
122
|
+
: "aspect-[4/3]";
|
|
123
|
+
const radius = shape === "circle" ? "rounded-full" : "rounded-2xl";
|
|
124
|
+
return (
|
|
125
|
+
<div
|
|
126
|
+
className={`relative grid place-items-center overflow-hidden border-2 border-dashed border-zinc-300 bg-zinc-50 ${aspect} ${radius} ${className}`}
|
|
127
|
+
>
|
|
128
|
+
<div className="px-4 text-center">
|
|
129
|
+
<svg
|
|
130
|
+
className="mx-auto size-7 text-zinc-300"
|
|
131
|
+
viewBox="0 0 24 24"
|
|
132
|
+
fill="none"
|
|
133
|
+
stroke="currentColor"
|
|
134
|
+
strokeWidth="1.5"
|
|
135
|
+
strokeLinecap="round"
|
|
136
|
+
strokeLinejoin="round"
|
|
137
|
+
aria-hidden
|
|
138
|
+
>
|
|
139
|
+
<rect x="3" y="3" width="18" height="18" rx="2" />
|
|
140
|
+
<circle cx="9" cy="9" r="1.6" />
|
|
141
|
+
<path d="m21 15-4.5-4.5L7 20" />
|
|
142
|
+
</svg>
|
|
143
|
+
<p className="mt-2 text-[12.5px] font-medium text-zinc-500">{title}</p>
|
|
144
|
+
{hint ? <p className="mt-1 text-[11px] leading-snug text-zinc-400">{hint}</p> : null}
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect } from "react";
|
|
4
|
+
|
|
5
|
+
// Makes in-page section links work. A hydrated Pylon page updates the URL for a
|
|
6
|
+
// plain `<a href="#section">` click but doesn't perform the browser's native
|
|
7
|
+
// fragment scroll, so the page jumps nowhere. This installs ONE delegated click
|
|
8
|
+
// handler that catches any same-page `#`/`/#` anchor and scrolls to it smoothly.
|
|
9
|
+
//
|
|
10
|
+
// Render it once (in the root layout). Renders nothing. Real route links should
|
|
11
|
+
// still use `<Link>` from @pylonsync/react — this only handles `#` anchors.
|
|
12
|
+
export function SectionScroller() {
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
function onClick(e: MouseEvent) {
|
|
15
|
+
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const target = e.target as Element | null;
|
|
19
|
+
const link = target?.closest?.('a[href^="#"], a[href^="/#"]') as HTMLAnchorElement | null;
|
|
20
|
+
if (!link) return;
|
|
21
|
+
const href = link.getAttribute("href") || "";
|
|
22
|
+
const id = href.slice(href.indexOf("#") + 1);
|
|
23
|
+
if (!id) return;
|
|
24
|
+
const el = document.getElementById(id);
|
|
25
|
+
if (!el) return; // target not on this page — leave it to the browser
|
|
26
|
+
e.preventDefault();
|
|
27
|
+
el.scrollIntoView({ block: "start" });
|
|
28
|
+
history.replaceState(null, "", "#" + id);
|
|
29
|
+
}
|
|
30
|
+
document.addEventListener("click", onClick);
|
|
31
|
+
return () => document.removeEventListener("click", onClick);
|
|
32
|
+
}, []);
|
|
33
|
+
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
@@ -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
|
+
// subscribe — the ONLY way a Subscriber row is ever written. It's a `mutation`
|
|
16
|
+
// (not an `action`): mutations get `ctx.db` access and run as one atomic
|
|
17
|
+
// transaction. It also bumps the public SubscriberCount row, which the landing
|
|
18
|
+
// page reads via `db.useQuery` — so the counter ticks up on every open tab in
|
|
19
|
+
// 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 Subscriber
|
|
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
|
+
// Subscriber 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("Subscriber", "email", email);
|
|
45
|
+
if (existing) {
|
|
46
|
+
return { ok: true, alreadyJoined: true };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
await ctx.db.unsafe.insert("Subscriber", {
|
|
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 SubscriberCount 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("Subscriber")).length;
|
|
70
|
+
const stat = (await ctx.db.unsafe.list("SubscriberCount"))[0] as
|
|
71
|
+
| { id: string }
|
|
72
|
+
| undefined;
|
|
73
|
+
const now = new Date().toISOString();
|
|
74
|
+
if (stat) {
|
|
75
|
+
await ctx.db.unsafe.update("SubscriberCount", stat.id, { count: total, updatedAt: now });
|
|
76
|
+
} else {
|
|
77
|
+
await ctx.db.unsafe.insert("SubscriberCount", { 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 { SubscriberRow, SubscriberStatsResult } from "../lib/stats";
|
|
4
|
+
|
|
5
|
+
// subscriberStats — the owner's view of the raw subscribers, 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 SubscriberCount 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 Subscriber 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<SubscriberStatsResult> {
|
|
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
|
+
// Subscriber 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("Subscriber")) as unknown as SubscriberRow[];
|
|
40
|
+
|
|
41
|
+
const subscribers = 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 subscribers.
|
|
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
|
+
subscribers,
|
|
73
|
+
};
|
|
74
|
+
},
|
|
75
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Who owns this newsletter? A newsletter 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 `subscriberStats`
|
|
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 subscribers". 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
|
+
}
|