@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,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
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// THE single source of truth for everything personal/business-specific on this
|
|
2
|
+
// creator site. Rebrand the whole page by editing this ONE file — the landing
|
|
3
|
+
// page and layout read from here and stay generic. The create-pylon scaffolder
|
|
4
|
+
// and Mast target this file too.
|
|
5
|
+
//
|
|
6
|
+
// Colors live here (applied as CSS variables on <html> in app/layout.tsx).
|
|
7
|
+
// Fictional demo copy — replace the values, keep the shape.
|
|
8
|
+
|
|
9
|
+
/* ----------------------------- types ----------------------------- */
|
|
10
|
+
|
|
11
|
+
export type Social = { label: string; href: string; path: string };
|
|
12
|
+
|
|
13
|
+
export type BaseConfig = {
|
|
14
|
+
brand: {
|
|
15
|
+
name: string;
|
|
16
|
+
letter: string; // monogram
|
|
17
|
+
domain: string;
|
|
18
|
+
email: string;
|
|
19
|
+
footerBlurb: string;
|
|
20
|
+
copyrightName: string;
|
|
21
|
+
socials: Social[];
|
|
22
|
+
};
|
|
23
|
+
colors: { brand: string; brandSoft: string; paper: string };
|
|
24
|
+
seo: { title: string; description: string };
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type Offering = { title: string; body: string; price?: string };
|
|
28
|
+
export type Testimonial = { quote: string; name: string; role: string };
|
|
29
|
+
export type LinkItem = { label: string; href: string; note?: string };
|
|
30
|
+
|
|
31
|
+
export type CreatorConfig = BaseConfig & {
|
|
32
|
+
hero: {
|
|
33
|
+
badge: string;
|
|
34
|
+
name: string; // the person / personal brand
|
|
35
|
+
tagline: string; // one-line "what you do"
|
|
36
|
+
intro: string; // a short paragraph
|
|
37
|
+
};
|
|
38
|
+
about: { eyebrow: string; headline: string; paragraphs: string[] };
|
|
39
|
+
offerings: { eyebrow: string; headline: string; items: Offering[] };
|
|
40
|
+
testimonials?: { eyebrow: string; headline: string; items: Testimonial[] };
|
|
41
|
+
// The realtime feature: a live newsletter subscriber count.
|
|
42
|
+
newsletter: {
|
|
43
|
+
eyebrow: string;
|
|
44
|
+
headline: string;
|
|
45
|
+
subcopy: string;
|
|
46
|
+
emailPlaceholder: string;
|
|
47
|
+
ctaLabel: string;
|
|
48
|
+
successMessage: string;
|
|
49
|
+
counterLabel: string; // e.g. "readers subscribed"
|
|
50
|
+
seedCount?: number; // vanity baseline added to the real live count
|
|
51
|
+
};
|
|
52
|
+
links?: { eyebrow: string; headline: string; items: LinkItem[] };
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/* ----------------------------- config ---------------------------- */
|
|
56
|
+
|
|
57
|
+
export const siteConfig: CreatorConfig = {
|
|
58
|
+
brand: {
|
|
59
|
+
name: "Maya Rivera",
|
|
60
|
+
letter: "M",
|
|
61
|
+
domain: "mayarivera.co",
|
|
62
|
+
email: "hello@mayarivera.example",
|
|
63
|
+
footerBlurb:
|
|
64
|
+
"Product design coach and writer. I help designers do braver work — through 1:1 coaching, portfolio reviews, and a weekly newsletter.",
|
|
65
|
+
copyrightName: "Maya Rivera",
|
|
66
|
+
socials: [
|
|
67
|
+
{
|
|
68
|
+
label: "X",
|
|
69
|
+
href: "https://x.com/pylonsync",
|
|
70
|
+
path: "M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z",
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
label: "GitHub",
|
|
74
|
+
href: "https://github.com/pylonsync/pylon",
|
|
75
|
+
path: "M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12",
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
colors: { brand: "#0d9488", brandSoft: "#ccfbf1", paper: "#fafafa" },
|
|
81
|
+
|
|
82
|
+
seo: {
|
|
83
|
+
title: "Maya Rivera — product design coach & writer",
|
|
84
|
+
description:
|
|
85
|
+
"Product design coaching, portfolio reviews, and a weekly newsletter for designers who want to do braver work.",
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
hero: {
|
|
89
|
+
badge: "Now coaching · 2 spots open",
|
|
90
|
+
name: "Maya Rivera",
|
|
91
|
+
tagline: "Product design coach & writer.",
|
|
92
|
+
intro:
|
|
93
|
+
"I help product designers get unstuck, sharpen their portfolios, and do the bravest work of their careers. Fifteen years in the room; now I spend it in yours.",
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
about: {
|
|
97
|
+
eyebrow: "About",
|
|
98
|
+
headline: "Hi, I'm Maya.",
|
|
99
|
+
paragraphs: [
|
|
100
|
+
"I've led design at two startups and a public company, shipped products to millions, and mentored designers who now lead teams of their own.",
|
|
101
|
+
"These days I coach one-on-one, review portfolios, and write a weekly newsletter about the craft and the career. No fluff, no hustle — just the things that actually move your work forward.",
|
|
102
|
+
],
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
offerings: {
|
|
106
|
+
eyebrow: "Work with me",
|
|
107
|
+
headline: "Three ways I can help.",
|
|
108
|
+
items: [
|
|
109
|
+
{
|
|
110
|
+
title: "1:1 coaching",
|
|
111
|
+
body: "Monthly sessions on whatever's in your way — craft, career, confidence. We build a plan and I hold you to it.",
|
|
112
|
+
price: "from $400/mo",
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
title: "Portfolio review",
|
|
116
|
+
body: "A deep, honest teardown of your portfolio and a recorded walkthrough with everything I'd change.",
|
|
117
|
+
price: "$250",
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
title: "Team workshops",
|
|
121
|
+
body: "Half-day workshops on critique, design systems, and shipping faster without lowering the bar.",
|
|
122
|
+
price: "let's talk",
|
|
123
|
+
},
|
|
124
|
+
],
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
testimonials: {
|
|
128
|
+
eyebrow: "Kind words",
|
|
129
|
+
headline: "From people I've worked with.",
|
|
130
|
+
items: [
|
|
131
|
+
{
|
|
132
|
+
quote:
|
|
133
|
+
"Maya helped me land a senior role in three months. The portfolio review alone was worth ten times the price.",
|
|
134
|
+
name: "Daniel Reyes",
|
|
135
|
+
role: "Senior Product Designer",
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
quote:
|
|
139
|
+
"Our whole team got sharper after Maya's critique workshop. Calmer feedback, better work, less ego.",
|
|
140
|
+
name: "Hannah Brooks",
|
|
141
|
+
role: "Design Manager",
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
quote:
|
|
145
|
+
"The newsletter is the only one I actually read every week. It's like a coaching session in my inbox.",
|
|
146
|
+
name: "Marcus Bell",
|
|
147
|
+
role: "Product Designer",
|
|
148
|
+
},
|
|
149
|
+
],
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
newsletter: {
|
|
153
|
+
eyebrow: "The Studio Notes",
|
|
154
|
+
headline: "A weekly letter on the craft and the career.",
|
|
155
|
+
subcopy:
|
|
156
|
+
"One short, useful email every Sunday — on design, taste, and doing brave work. No spam, unsubscribe anytime.",
|
|
157
|
+
emailPlaceholder: "you@email.com",
|
|
158
|
+
ctaLabel: "Subscribe",
|
|
159
|
+
successMessage: "You're in — watch your inbox this Sunday.",
|
|
160
|
+
counterLabel: "designers reading",
|
|
161
|
+
seedCount: 2400,
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
links: {
|
|
165
|
+
eyebrow: "Elsewhere",
|
|
166
|
+
headline: "Find me around the web.",
|
|
167
|
+
items: [
|
|
168
|
+
{ label: "Portfolio", href: "#", note: "Selected work, 2010–today" },
|
|
169
|
+
{ label: "Read the archive", href: "#", note: "Every past issue of The Studio Notes" },
|
|
170
|
+
{ label: "Book a free intro call", href: "#", note: "15 minutes, no pitch" },
|
|
171
|
+
],
|
|
172
|
+
},
|
|
173
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Shape of the owner dashboard's data, shared by the server query
|
|
2
|
+
// (functions/subscriberStats.ts) that produces it and the client view
|
|
3
|
+
// (app/dashboard/dashboard-client.tsx) that renders it. Keeping it here means
|
|
4
|
+
// the client never imports server code — just the type.
|
|
5
|
+
|
|
6
|
+
export interface SubscriberRow {
|
|
7
|
+
id: string;
|
|
8
|
+
email: string;
|
|
9
|
+
createdAt: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface SubscriberStatsData {
|
|
13
|
+
total: number;
|
|
14
|
+
today: number;
|
|
15
|
+
last7: number;
|
|
16
|
+
/** Last 30 days, ascending — continuous (zero-filled) for the chart. */
|
|
17
|
+
daily: { date: string; count: number }[];
|
|
18
|
+
/** Every subscriber, newest first — powers the list + CSV export. */
|
|
19
|
+
subscribers: SubscriberRow[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// subscriberStats returns a DISCRIMINATED result rather than throwing on a
|
|
23
|
+
// non-owner: a query handler has no `ctx.error`, and a plain `throw` reaches
|
|
24
|
+
// the client as a generic, message-stripped HANDLER_ERROR — useless for telling
|
|
25
|
+
// "you're not the owner" apart from a real failure. So a non-owner gets
|
|
26
|
+
// `{ authorized: false }` (and crucially, NO subscriber data); the owner gets the
|
|
27
|
+
// stats. The client switches on `authorized`.
|
|
28
|
+
export type SubscriberStatsResult =
|
|
29
|
+
| ({ authorized: true } & SubscriberStatsData)
|
|
30
|
+
| { authorized: false };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type ClassValue, clsx } from "clsx";
|
|
2
|
+
import { twMerge } from "tailwind-merge";
|
|
3
|
+
|
|
4
|
+
// `cn` — the shadcn class merger. clsx resolves conditional/array class
|
|
5
|
+
// inputs; tailwind-merge then dedupes conflicting Tailwind utilities so
|
|
6
|
+
// the last one wins (e.g. `cn("px-2", "px-4")` → "px-4"). Every shadcn
|
|
7
|
+
// component routes its className through this.
|
|
8
|
+
export function cn(...inputs: ClassValue[]) {
|
|
9
|
+
return twMerge(clsx(inputs));
|
|
10
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "__APP_NAME_KEBAB__",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "pylon dev",
|
|
8
|
+
"deploy": "pylon deploy",
|
|
9
|
+
"check": "tsc --noEmit"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@pylonsync/react": "^__PYLON_VERSION__",
|
|
13
|
+
"@pylonsync/sdk": "^__PYLON_VERSION__",
|
|
14
|
+
"@pylonsync/functions": "^__PYLON_VERSION__",
|
|
15
|
+
"@pylonsync/client": "^__PYLON_VERSION__",
|
|
16
|
+
"react": "^19.0.0",
|
|
17
|
+
"react-dom": "^19.0.0",
|
|
18
|
+
"tailwindcss": "^4.3.0",
|
|
19
|
+
"@tailwindcss/cli": "^4.3.0",
|
|
20
|
+
"tw-animate-css": "^1.2.0",
|
|
21
|
+
"class-variance-authority": "^0.7.1",
|
|
22
|
+
"clsx": "^2.1.1",
|
|
23
|
+
"tailwind-merge": "^2.5.0",
|
|
24
|
+
"lucide-react": "^0.460.0",
|
|
25
|
+
"@radix-ui/react-slot": "^1.1.0"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@pylonsync/cli": "^__PYLON_VERSION__",
|
|
29
|
+
"@types/node": "^22.0.0",
|
|
30
|
+
"@types/react": "^19.0.0",
|
|
31
|
+
"@types/react-dom": "^19.0.0",
|
|
32
|
+
"typescript": "^5.6.0"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"jsx": "react",
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"strict": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"lib": ["ES2022", "DOM"],
|
|
11
|
+
"types": ["react", "react-dom", "node"],
|
|
12
|
+
"baseUrl": ".",
|
|
13
|
+
"paths": {
|
|
14
|
+
"@/*": ["./*"]
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"include": ["app.ts", "app/**/*", "components/**/*", "lib/**/*", "functions/**/*"]
|
|
18
|
+
}
|
|
@@ -2,6 +2,7 @@ import React from "react";
|
|
|
2
2
|
import { Link, type PageAuth } from "@pylonsync/react";
|
|
3
3
|
import { PRODUCTS } from "@/lib/products";
|
|
4
4
|
import { SOLUTIONS, RESOURCES, COMPANY, COMPARISONS } from "@/lib/site";
|
|
5
|
+
import { siteConfig } from "@/lib/site.config";
|
|
5
6
|
|
|
6
7
|
// A layout receives the page props plus `children`. `auth.user_id` is null for
|
|
7
8
|
// anonymous visitors and the signed-in user's id otherwise — resolved
|
|
@@ -27,11 +28,11 @@ const PRODUCT_MENU: MenuItem[] = PRODUCTS.map((p) => ({
|
|
|
27
28
|
}));
|
|
28
29
|
|
|
29
30
|
const RESOURCES_MENU: MenuItem[] = [
|
|
30
|
-
{ icon: "▢", title: "Docs", desc:
|
|
31
|
+
{ icon: "▢", title: "Docs", desc: `Set up and use ${siteConfig.brand.name}.`, href: "/resources/docs" },
|
|
31
32
|
{ icon: "✎", title: "Guides", desc: "Playbooks and walkthroughs.", href: "/resources/guides" },
|
|
32
|
-
{ icon: "✦", title: "Changelog", desc:
|
|
33
|
-
{ icon: "⌘", title: "API reference", desc:
|
|
34
|
-
{ icon: "◈", title: "Compare", desc:
|
|
33
|
+
{ icon: "✦", title: "Changelog", desc: `What's new in ${siteConfig.brand.name}.`, href: "/resources/changelog" },
|
|
34
|
+
{ icon: "⌘", title: "API reference", desc: `Build on the ${siteConfig.brand.name} API.`, href: "/resources/api" },
|
|
35
|
+
{ icon: "◈", title: "Compare", desc: `See how ${siteConfig.brand.name} stacks up.`, href: `/compare/${COMPARISONS[0].slug}` },
|
|
35
36
|
];
|
|
36
37
|
|
|
37
38
|
// A hover/focus dropdown — pure CSS via `group`, so the nav stays a server
|
|
@@ -184,7 +185,19 @@ export default function RootLayout({ children, url, auth }: LayoutProps) {
|
|
|
184
185
|
const BARE_PREFIXES = ["/login", "/signup", "/onboarding", "/dashboard"];
|
|
185
186
|
const isBare = BARE_PREFIXES.some((p) => path === p || path.startsWith(p + "/"));
|
|
186
187
|
return (
|
|
187
|
-
<html
|
|
188
|
+
<html
|
|
189
|
+
lang="en"
|
|
190
|
+
// Marketing theme colors come from the single site config (lib/site.config.ts).
|
|
191
|
+
// Set as inline CSS vars on <html> so they override globals.css defaults and
|
|
192
|
+
// the whole site re-themes from one place — no CSS edit needed.
|
|
193
|
+
style={
|
|
194
|
+
{
|
|
195
|
+
"--brand": siteConfig.colors.brand,
|
|
196
|
+
"--brand-soft": siteConfig.colors.brandSoft,
|
|
197
|
+
"--paper": siteConfig.colors.paper,
|
|
198
|
+
} as React.CSSProperties
|
|
199
|
+
}
|
|
200
|
+
>
|
|
188
201
|
<head>
|
|
189
202
|
<meta charSet="utf-8" />
|
|
190
203
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
@@ -217,10 +230,10 @@ export default function RootLayout({ children, url, auth }: LayoutProps) {
|
|
|
217
230
|
<div className="flex items-center gap-8">
|
|
218
231
|
<Link href="/" className="flex items-center gap-2">
|
|
219
232
|
<span className="flex size-6 items-center justify-center rounded-[7px] bg-zinc-900 text-[13px] font-bold text-white">
|
|
220
|
-
|
|
233
|
+
{siteConfig.brand.letter}
|
|
221
234
|
</span>
|
|
222
235
|
<span className="text-[15px] font-semibold tracking-tight text-zinc-900">
|
|
223
|
-
|
|
236
|
+
{siteConfig.brand.name}
|
|
224
237
|
</span>
|
|
225
238
|
</Link>
|
|
226
239
|
<nav className="hidden items-center gap-6 md:flex">
|
|
@@ -369,19 +382,6 @@ function FooterGroup({
|
|
|
369
382
|
);
|
|
370
383
|
}
|
|
371
384
|
|
|
372
|
-
const SOCIALS = [
|
|
373
|
-
{
|
|
374
|
-
label: "X",
|
|
375
|
-
href: "https://x.com/pylonsync",
|
|
376
|
-
path: "M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z",
|
|
377
|
-
},
|
|
378
|
-
{
|
|
379
|
-
label: "GitHub",
|
|
380
|
-
href: "https://github.com/pylonsync/pylon",
|
|
381
|
-
path: "M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12",
|
|
382
|
-
},
|
|
383
|
-
];
|
|
384
|
-
|
|
385
385
|
function SiteFooter() {
|
|
386
386
|
return (
|
|
387
387
|
<footer className="border-t border-zinc-200/70 bg-white">
|
|
@@ -390,18 +390,17 @@ function SiteFooter() {
|
|
|
390
390
|
<div className="max-w-xs">
|
|
391
391
|
<Link href="/" className="inline-flex items-center gap-2">
|
|
392
392
|
<span className="flex size-7 items-center justify-center rounded-lg bg-zinc-900 text-sm font-bold text-white">
|
|
393
|
-
|
|
393
|
+
{siteConfig.brand.letter}
|
|
394
394
|
</span>
|
|
395
395
|
<span className="text-[15px] font-semibold tracking-tight text-zinc-900">
|
|
396
|
-
|
|
396
|
+
{siteConfig.brand.name}
|
|
397
397
|
</span>
|
|
398
398
|
</Link>
|
|
399
399
|
<p className="mt-4 text-[13px] leading-relaxed text-zinc-500">
|
|
400
|
-
|
|
401
|
-
projects, docs, and automation in one place.
|
|
400
|
+
{siteConfig.brand.footerBlurb}
|
|
402
401
|
</p>
|
|
403
402
|
<div className="mt-5 flex items-center gap-4">
|
|
404
|
-
{
|
|
403
|
+
{siteConfig.brand.socials.map((s) => (
|
|
405
404
|
<a
|
|
406
405
|
key={s.label}
|
|
407
406
|
href={s.href}
|
|
@@ -414,7 +413,7 @@ function SiteFooter() {
|
|
|
414
413
|
</a>
|
|
415
414
|
))}
|
|
416
415
|
<a
|
|
417
|
-
href=
|
|
416
|
+
href={`mailto:${siteConfig.brand.email}`}
|
|
418
417
|
aria-label="Email"
|
|
419
418
|
className="text-zinc-400 transition-colors hover:text-zinc-900"
|
|
420
419
|
>
|
|
@@ -449,7 +448,7 @@ function SiteFooter() {
|
|
|
449
448
|
</div>
|
|
450
449
|
|
|
451
450
|
<div className="mt-14 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">
|
|
452
|
-
<span>© {new Date().getFullYear()}
|
|
451
|
+
<span>© {new Date().getFullYear()} {siteConfig.brand.copyrightName}</span>
|
|
453
452
|
<span>
|
|
454
453
|
Built with{" "}
|
|
455
454
|
<a
|