@pylonsync/create-pylon 0.3.296 → 0.3.297
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/package.json +1 -1
- package/templates/agency/components/marketing.tsx +2 -4
- package/templates/agency/lib/site.config.ts +3 -5
- package/templates/ai-chat/app/layout.tsx +0 -2
- package/templates/ai-chat/app.ts +3 -5
- package/templates/ai-chat/lib/site.config.ts +3 -3
- package/templates/ai-studio/app/layout.tsx +3 -5
- package/templates/ai-studio/app.ts +5 -7
- package/templates/ai-studio/lib/site.config.ts +2 -2
- package/templates/ai-studio/lib/studio.ts +1 -1
- package/templates/backend/b2b/apps/api/schema.ts +10 -24
- package/templates/backend/consumer/apps/api/schema.ts +4 -7
- package/templates/barebones/app/layout.tsx +3 -3
- package/templates/barebones/app.ts +2 -3
- package/templates/chat/app.ts +3 -9
- package/templates/consumer/app.ts +2 -3
- package/templates/creator/.env.example +3 -3
- package/templates/creator/app.ts +8 -10
- package/templates/creator/components/marketing.tsx +2 -4
- package/templates/default/lib/products.ts +2 -3
- package/templates/default/lib/site.config.ts +1 -2
- package/templates/default/lib/site.ts +3 -4
- package/templates/directory/app/auth-form.tsx +5 -5
- package/templates/directory/app/sitemap.ts +1 -1
- package/templates/directory/lib/owner.ts +5 -5
- package/templates/expo/chat/apps/expo/App.tsx +2 -3
- package/templates/local-service/app/auth-form.tsx +4 -4
- package/templates/local-service/app/sitemap.ts +1 -1
- package/templates/local-service/components/marketing.tsx +2 -4
- package/templates/local-service/lib/owner.ts +3 -3
- package/templates/local-service/lib/site.config.ts +4 -6
- package/templates/marketplace/app/listing/[id]/page.tsx +3 -3
- package/templates/marketplace/functions/makeOffer.ts +0 -1
- package/templates/restaurant/app/auth-form.tsx +5 -5
- package/templates/restaurant/app/sitemap.ts +1 -1
- package/templates/restaurant/lib/owner.ts +5 -5
- package/templates/shop/app/auth-form.tsx +5 -5
- package/templates/shop/app/layout.tsx +1 -1
- package/templates/shop/app/sitemap.ts +1 -1
- package/templates/shop/lib/owner.ts +6 -5
- package/templates/todo/app/layout.tsx +2 -2
- package/templates/todo/app/sitemap.ts +1 -1
- package/templates/todo/app.ts +4 -5
- package/templates/vite/todo/apps/web/vite.config.ts +5 -9
- package/templates/waitlist/app/waitlist-hero.tsx +4 -5
- package/templates/waitlist/app.ts +11 -9
- package/templates/waitlist/lib/site.config.ts +3 -5
- package/templates/web/barebones/apps/web/postcss.config.mjs +0 -1
- package/templates/web/barebones/apps/web/src/app/globals.css +1 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pylonsync/create-pylon",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.297",
|
|
4
4
|
"description": "Scaffold a new Pylon app — realtime backend + web/mobile/expo frontends in one command. Run via `npm create @pylonsync/pylon@latest`.",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -134,10 +134,8 @@ export function ProjectCard({ p }: { p: ProjectView }) {
|
|
|
134
134
|
);
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
-
// A deliberately-obvious image placeholder
|
|
138
|
-
//
|
|
139
|
-
// "swap this" instruction telling you exactly what to replace and where. Looks
|
|
140
|
-
// tidy enough to demo, but no one will mistake it for a finished design.
|
|
137
|
+
// A deliberately-obvious image placeholder for spots where a real photo belongs:
|
|
138
|
+
// dashed border, a photo glyph, and a one-line "swap this" instruction.
|
|
141
139
|
//
|
|
142
140
|
// shape — "landscape" | "portrait" | "square" | "circle"
|
|
143
141
|
// title — what photo belongs here ("Your headshot")
|
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
//
|
|
2
|
-
// site. Rebrand the whole studio by editing this
|
|
3
|
-
// layout, and the seedCapacity function all read from here.
|
|
4
|
-
// scaffolder and Mast target this file: a whole studio site is themed from one
|
|
5
|
-
// typed object.
|
|
1
|
+
// The single source of truth for everything business-specific on this agency
|
|
2
|
+
// site. Rebrand the whole studio by editing this one file — the landing page,
|
|
3
|
+
// layout, and the seedCapacity function all read from here.
|
|
6
4
|
//
|
|
7
5
|
// Colors live here (applied as CSS variables on <html> in app/layout.tsx).
|
|
8
6
|
// Fictional demo copy — replace the values, keep the shape. Anywhere a real
|
|
@@ -13,8 +13,6 @@ interface LayoutProps {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
export default function RootLayout({ children, url, auth }: LayoutProps) {
|
|
16
|
-
// Resolved server-side from the session cookie. The app requires sign-in, so
|
|
17
|
-
// this is set on every in-app page; the header reflects it with no flash.
|
|
18
16
|
const signedIn = Boolean(auth?.user_id);
|
|
19
17
|
const { brand, colors } = siteConfig;
|
|
20
18
|
|
package/templates/ai-chat/app.ts
CHANGED
|
@@ -11,9 +11,8 @@ import {
|
|
|
11
11
|
// ---------------------------------------------------------------------------
|
|
12
12
|
// ai-chat — a streaming AI chat app. Tokens stream from the built-in
|
|
13
13
|
// `POST /api/ai/stream` endpoint (your PYLON_AI_API_KEY never leaves the
|
|
14
|
-
// server); the conversation itself is sync-backed, so your chats
|
|
15
|
-
// across tabs and devices in realtime
|
|
16
|
-
// one shows up in the other as it's saved.
|
|
14
|
+
// server); the conversation itself is sync-backed, so your chats stay in sync
|
|
15
|
+
// across tabs and devices in realtime.
|
|
17
16
|
//
|
|
18
17
|
// Two data entities (+ User):
|
|
19
18
|
// • Conversation — a chat thread. Owner-scoped: you only ever see your own.
|
|
@@ -75,8 +74,7 @@ const User = entity(
|
|
|
75
74
|
// Conversations + messages are PRIVATE: a signed-in (or guest) user can only
|
|
76
75
|
// read, create, and modify their OWN rows. `field.owner()` stamps userId from
|
|
77
76
|
// the session on insert, so "create your own" is enforced at write time and
|
|
78
|
-
// reads are scoped by the same id
|
|
79
|
-
// the sync engine only ever ships you yours.
|
|
77
|
+
// reads are scoped by the same id.
|
|
80
78
|
const conversationPolicy = policy({
|
|
81
79
|
name: "conversation_owner",
|
|
82
80
|
entity: "Conversation",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// Single source of truth for everything brand-specific on this AI chat app —
|
|
2
|
+
// edit this one file to rebrand; the layout + chat UI read from here. The
|
|
3
|
+
// create-pylon scaffolder and Mast target this file.
|
|
4
4
|
//
|
|
5
5
|
// Colors live here (applied as CSS variables on <html> in app/layout.tsx).
|
|
6
6
|
// Fictional demo copy — replace the values, keep the shape.
|
|
@@ -2,10 +2,10 @@ import React from "react";
|
|
|
2
2
|
import { Link, type PageAuth } from "@pylonsync/react";
|
|
3
3
|
import { siteConfig } from "@/lib/site.config";
|
|
4
4
|
|
|
5
|
-
// App shell: a slim top bar over
|
|
5
|
+
// App shell: a slim top bar over the studio. `auth.user_id` is resolved
|
|
6
6
|
// server-side from the session cookie before any HTML is sent, so the bar shows
|
|
7
|
-
// the account / "Sign in" with no flash. The
|
|
8
|
-
// viewport (h-[calc(100vh-3.5rem)]
|
|
7
|
+
// the account / "Sign in" with no flash. The header is h-14; the studio page
|
|
8
|
+
// fills the rest of the viewport (min-h-[calc(100vh-3.5rem)]).
|
|
9
9
|
interface LayoutProps {
|
|
10
10
|
children: React.ReactNode;
|
|
11
11
|
url: string;
|
|
@@ -13,8 +13,6 @@ interface LayoutProps {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
export default function RootLayout({ children, url, auth }: LayoutProps) {
|
|
16
|
-
// Resolved server-side from the session cookie. The app requires sign-in, so
|
|
17
|
-
// this is set on every in-app page; the header reflects it with no flash.
|
|
18
16
|
const signedIn = Boolean(auth?.user_id);
|
|
19
17
|
const { brand, colors } = siteConfig;
|
|
20
18
|
|
|
@@ -68,11 +68,9 @@ const User = entity(
|
|
|
68
68
|
{ indexes: [{ name: "by_email", fields: ["email"], unique: true }] },
|
|
69
69
|
);
|
|
70
70
|
|
|
71
|
-
// Generations are PRIVATE per user:
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
// live (the sync engine ships you your rows as the action updates them) without
|
|
75
|
-
// ever exposing one user's generations to another.
|
|
71
|
+
// Generations are PRIVATE per user: read-your-own, with no client writes at all
|
|
72
|
+
// (the server-side pipeline is the only writer). The gallery stays live via the
|
|
73
|
+
// sync engine without ever exposing one user's generations to another.
|
|
76
74
|
const generationPolicy = policy({
|
|
77
75
|
name: "generation_owner_read",
|
|
78
76
|
entity: "Generation",
|
|
@@ -95,8 +93,8 @@ const manifest = buildManifest({
|
|
|
95
93
|
name: "__APP_NAME__",
|
|
96
94
|
version: "0.1.0",
|
|
97
95
|
entities: [Generation, User],
|
|
98
|
-
// generate (
|
|
99
|
-
//
|
|
96
|
+
// generate (mutation) + pollGeneration (job) + the internal _getGeneration /
|
|
97
|
+
// _updateGeneration live in functions/ and are discovered automatically.
|
|
100
98
|
queries: [],
|
|
101
99
|
actions: [],
|
|
102
100
|
policies: [generationPolicy, userPolicy],
|
|
@@ -28,8 +28,8 @@ export type StudioConfig = BaseConfig & {
|
|
|
28
28
|
headline: string;
|
|
29
29
|
subcopy: string;
|
|
30
30
|
inputPlaceholder: string;
|
|
31
|
-
// The generation kinds shown as a selector. `wired` flags which
|
|
32
|
-
//
|
|
31
|
+
// The generation kinds shown as a selector. `wired` flags which call a
|
|
32
|
+
// provider (all three do via Replicate when REPLICATE_API_TOKEN is set).
|
|
33
33
|
kinds: { id: GenerationKind; label: string; wired: boolean }[];
|
|
34
34
|
// Starter prompts; clicking one fills the box.
|
|
35
35
|
examples: string[];
|
|
@@ -8,7 +8,7 @@ export interface GenerationRow {
|
|
|
8
8
|
userId: string;
|
|
9
9
|
kind: string;
|
|
10
10
|
prompt: string;
|
|
11
|
-
status: string; // "pending" | "done" | "failed"
|
|
11
|
+
status: string; // "pending" | "processing" | "done" | "failed"
|
|
12
12
|
resultUrl?: string | null;
|
|
13
13
|
error?: string | null;
|
|
14
14
|
demo: boolean;
|
|
@@ -27,12 +27,8 @@ import {
|
|
|
27
27
|
const Org = entity("Org", {
|
|
28
28
|
slug: field.string(),
|
|
29
29
|
name: field.string(),
|
|
30
|
-
// `.readonly()` blocks HTTP PATCH from rewriting ownership
|
|
31
|
-
//
|
|
32
|
-
// `PATCH /api/entities/Org/<id>` with `{ownerId: <them>}` and the
|
|
33
|
-
// policy's `existing.ownerId == auth.userId` would already be true
|
|
34
|
-
// because they read the row. Server-side ctx.db.update still goes
|
|
35
|
-
// through, so admin migrations + transfers work.
|
|
30
|
+
// `.readonly()` blocks HTTP PATCH from rewriting ownership. Server-side
|
|
31
|
+
// ctx.db.update still goes through, so admin migrations + transfers work.
|
|
36
32
|
ownerId: field.id("User").readonly(),
|
|
37
33
|
createdAt: field.datetime().readonly(),
|
|
38
34
|
});
|
|
@@ -115,13 +111,9 @@ const orgPolicy = policy({
|
|
|
115
111
|
name: "org_membership",
|
|
116
112
|
entity: "Org",
|
|
117
113
|
// Anyone can read an org if they're a member; only the owner can
|
|
118
|
-
// update / delete. Update + delete pin `existing.ownerId` (the
|
|
119
|
-
// current
|
|
120
|
-
//
|
|
121
|
-
// `{ownerId: <attacker>}` and the policy would happily compare
|
|
122
|
-
// the payload value to their own userId. `ownerId` is also marked
|
|
123
|
-
// `.readonly()` on the entity so updates never get to set it via
|
|
124
|
-
// HTTP regardless — belt + suspenders.
|
|
114
|
+
// update / delete. Update + delete pin `existing.ownerId` (the row's
|
|
115
|
+
// current value) not `data.ownerId` (the payload), so an attacker
|
|
116
|
+
// can't PATCH `{ownerId: <them>}` past the check.
|
|
125
117
|
allowRead: "exists(Membership where orgId = data.id and userId = auth.userId)",
|
|
126
118
|
allowInsert: "auth.userId == data.ownerId",
|
|
127
119
|
allowUpdate: "existing.ownerId == auth.userId",
|
|
@@ -133,11 +125,8 @@ const membershipPolicy = policy({
|
|
|
133
125
|
entity: "Membership",
|
|
134
126
|
// You can see your own memberships, plus all memberships in any org
|
|
135
127
|
// where you're an owner/admin (so the admin UI can list everyone).
|
|
136
|
-
// Update/delete pin `existing.orgId`
|
|
137
|
-
//
|
|
138
|
-
// also marks `orgId` + `userId` as `.readonly()` so HTTP PATCH
|
|
139
|
-
// rejects those fields outright — server actions like
|
|
140
|
-
// `setMemberRole` write only `role`.
|
|
128
|
+
// Update/delete pin `existing.orgId` (not the payload); `orgId` +
|
|
129
|
+
// `userId` are `.readonly()` too, so `setMemberRole` writes only `role`.
|
|
141
130
|
allowRead:
|
|
142
131
|
"data.userId == auth.userId or exists(Membership where orgId = data.orgId and userId = auth.userId and (role = 'owner' or role = 'admin'))",
|
|
143
132
|
allowInsert:
|
|
@@ -152,12 +141,9 @@ const projectPolicy = policy({
|
|
|
152
141
|
name: "project_org_scope",
|
|
153
142
|
entity: "Project",
|
|
154
143
|
// Tenant scope: you can only touch a project if you're a member of
|
|
155
|
-
// its org. `existing.orgId`
|
|
156
|
-
//
|
|
157
|
-
// `
|
|
158
|
-
// into one they own. `orgId` is also `.readonly()` on the entity,
|
|
159
|
-
// so PATCH can't even set it. Insert uses `data.orgId` because
|
|
160
|
-
// there is no `existing` row yet.
|
|
144
|
+
// its org. Update/delete pin `existing.orgId` (not the payload) so a
|
|
145
|
+
// project can't be "imported" into a foreign org; insert uses
|
|
146
|
+
// `data.orgId` since there's no `existing` row yet.
|
|
161
147
|
allowRead:
|
|
162
148
|
"exists(Membership where orgId = data.orgId and userId = auth.userId)",
|
|
163
149
|
allowInsert:
|
|
@@ -82,10 +82,8 @@ const profilePolicy = policy({
|
|
|
82
82
|
entity: "Profile",
|
|
83
83
|
allowRead: "true",
|
|
84
84
|
allowInsert: "auth.userId == data.userId",
|
|
85
|
-
// Update + delete pin `existing.userId` so an
|
|
86
|
-
// PATCH `{userId: <
|
|
87
|
-
// `userId` is also `.readonly()` on the entity so PATCH bounces
|
|
88
|
-
// the field outright — belt + suspenders.
|
|
85
|
+
// Update + delete pin `existing.userId` (not the payload) so an
|
|
86
|
+
// attacker can't PATCH `{userId: <them>}` to claim another profile.
|
|
89
87
|
allowUpdate: "auth.userId == existing.userId",
|
|
90
88
|
allowDelete: "auth.userId == existing.userId",
|
|
91
89
|
});
|
|
@@ -97,9 +95,8 @@ const postPolicy = policy({
|
|
|
97
95
|
// Insert: caller must own a Profile they're claiming as author.
|
|
98
96
|
allowInsert:
|
|
99
97
|
"exists(Profile where id = data.authorId and userId = auth.userId)",
|
|
100
|
-
// Update + delete pin `existing.authorId` so an
|
|
101
|
-
// rewrite the author
|
|
102
|
-
// access. `authorId` is also `.readonly()` on the entity.
|
|
98
|
+
// Update + delete pin `existing.authorId` (not the payload) so an
|
|
99
|
+
// attacker can't rewrite the author to grant themselves access.
|
|
103
100
|
allowUpdate:
|
|
104
101
|
"exists(Profile where id = existing.authorId and userId = auth.userId)",
|
|
105
102
|
allowDelete:
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
|
|
3
|
-
// A layout wraps every page. This one is a
|
|
4
|
-
//
|
|
5
|
-
//
|
|
3
|
+
// A layout wraps every page. This one is a centered column — the page renders
|
|
4
|
+
// server-side first (shell + copy in the HTML), then hydrates into the live
|
|
5
|
+
// list.
|
|
6
6
|
interface LayoutProps {
|
|
7
7
|
children: React.ReactNode;
|
|
8
8
|
}
|
|
@@ -36,9 +36,8 @@ const itemPolicy = policy({
|
|
|
36
36
|
|
|
37
37
|
// The manifest is your whole app: data, policies, and the file-based routes
|
|
38
38
|
// under `app/`. `pylon dev` serves the SSR frontend and the API from one
|
|
39
|
-
// port.
|
|
40
|
-
//
|
|
41
|
-
// a second `entity()`, and the typed client + REST/realtime API follow.
|
|
39
|
+
// port. Add fields to `Item`, or a second `entity()`, and the typed client +
|
|
40
|
+
// REST/realtime API follow.
|
|
42
41
|
const manifest = buildManifest({
|
|
43
42
|
name: "__APP_NAME__",
|
|
44
43
|
version: "0.1.0",
|
package/templates/chat/app.ts
CHANGED
|
@@ -9,9 +9,7 @@ import {
|
|
|
9
9
|
|
|
10
10
|
// A chat message in the shared room. `authorId: field.owner()` stamps the
|
|
11
11
|
// signed-in (guest) user's id server-side, so an optimistic
|
|
12
|
-
// `db.insert("Message", { text })` can't forge the sender.
|
|
13
|
-
// public-read — everyone in it sees every message (that's what makes it a
|
|
14
|
-
// chat room) — while delete is owner-only.
|
|
12
|
+
// `db.insert("Message", { text })` can't forge the sender.
|
|
15
13
|
const Message = entity(
|
|
16
14
|
"Message",
|
|
17
15
|
{
|
|
@@ -37,12 +35,8 @@ const messagePolicy = policy({
|
|
|
37
35
|
allowDelete: "auth.userId == data.authorId",
|
|
38
36
|
});
|
|
39
37
|
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
// `db.useQuery("Message")` is a live subscription, so messages appear the
|
|
43
|
-
// instant they're sent, in this tab or another. Natural next steps: a `Room`
|
|
44
|
-
// entity (+ `roomId` on Message) for multiple rooms, and a presence channel
|
|
45
|
-
// (`ctx.connections.*`) for a "who's here" list.
|
|
38
|
+
// buildManifest assembles the entities, policies, auth, and routes into the
|
|
39
|
+
// single manifest `pylon dev` serves — SSR room + realtime API on one port.
|
|
46
40
|
const manifest = buildManifest({
|
|
47
41
|
name: "__APP_NAME__",
|
|
48
42
|
version: "0.1.0",
|
|
@@ -43,9 +43,8 @@ const Like = entity(
|
|
|
43
43
|
},
|
|
44
44
|
);
|
|
45
45
|
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
// to clients by default, so these allow-lists are what make the feed work.
|
|
46
|
+
// An entity with no policy is denied to clients by default, so these
|
|
47
|
+
// allow-lists are what make the feed work.
|
|
49
48
|
// `allowInsert` is `auth.userId != null`, not `== data.authorId`: the owner
|
|
50
49
|
// field is stamped by field.owner() *after* the policy check, so it's null at
|
|
51
50
|
// insert-time. The stamp still guarantees the new row is owned by the caller,
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# Copy to `.env` and fill in. `pylon dev` loads `.env` automatically.
|
|
2
2
|
|
|
3
3
|
# ── Owner (required to use the dashboard) ────────────────────────────────────
|
|
4
|
-
# A
|
|
4
|
+
# A newsletter is single-tenant: one business, one owner. The /dashboard is
|
|
5
5
|
# unlocked only for the account whose email matches this value, and the
|
|
6
|
-
# owner-only data function refuses to return any
|
|
7
|
-
# the email you'll sign in with, then create that account at /login.
|
|
6
|
+
# owner-only data function refuses to return any subscribers otherwise. Set this
|
|
7
|
+
# to the email you'll sign in with, then create that account at /login.
|
|
8
8
|
PYLON_OWNER_EMAIL=you@yourbusiness.com
|
|
9
9
|
|
|
10
10
|
# ── Site URL (optional) ──────────────────────────────────────────────────────
|
package/templates/creator/app.ts
CHANGED
|
@@ -10,9 +10,8 @@ import {
|
|
|
10
10
|
|
|
11
11
|
// ---------------------------------------------------------------------------
|
|
12
12
|
// newsletter — a pre-launch / coming-soon landing page with a LIVE subscriber
|
|
13
|
-
// counter. The
|
|
14
|
-
//
|
|
15
|
-
// refresh. That's the proof it's a real live app and not a static page.
|
|
13
|
+
// counter. The realtime hook: open the page in two tabs, submit an email in
|
|
14
|
+
// one, and the counter on the other ticks up with no refresh.
|
|
16
15
|
//
|
|
17
16
|
// The data model is deliberately tiny — two entities:
|
|
18
17
|
// • Subscriber — one row per email. Holds visitor PII, so it denies ALL client
|
|
@@ -75,13 +74,12 @@ const User = entity(
|
|
|
75
74
|
{ indexes: [{ name: "by_email", fields: ["email"], unique: true }] },
|
|
76
75
|
);
|
|
77
76
|
|
|
78
|
-
// PRIVACY
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
// customers' emails — this policy is what guarantees it.
|
|
77
|
+
// PRIVACY. Subscriber holds visitor emails, so it denies EVERY client read and
|
|
78
|
+
// write. No `db.useQuery("Subscriber")` can ever pull a row, and no client can
|
|
79
|
+
// insert/update/delete directly. Writes happen only inside the server-side
|
|
80
|
+
// `subscribe` mutation (functions bypass policies); the emails come back only
|
|
81
|
+
// through the owner-gated `subscriberStats`. A marketing site must never leak
|
|
82
|
+
// its own customers' emails — this policy is what guarantees it.
|
|
85
83
|
const subscriberPolicy = policy({
|
|
86
84
|
name: "subscriber_private",
|
|
87
85
|
entity: "Subscriber",
|
|
@@ -95,10 +95,8 @@ export function initials(name: string) {
|
|
|
95
95
|
.toUpperCase();
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
// A deliberately-obvious image placeholder
|
|
99
|
-
//
|
|
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.
|
|
98
|
+
// A deliberately-obvious image placeholder — dashed border, photo glyph, and a
|
|
99
|
+
// "swap this" hint. Real sites drop a photo here.
|
|
102
100
|
//
|
|
103
101
|
// shape — "landscape" | "portrait" | "square" | "circle"
|
|
104
102
|
// title — what photo belongs here ("Your headshot")
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
// existing imports (`@/lib/products`) keep working. Edit lib/site.config.ts.
|
|
1
|
+
// Re-exports the marketing products from the single site config so existing
|
|
2
|
+
// `@/lib/products` imports keep working. Edit lib/site.config.ts.
|
|
4
3
|
export {
|
|
5
4
|
PRODUCTS,
|
|
6
5
|
productBySlug,
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
// THE single source of truth for everything business-specific in this template.
|
|
2
2
|
// Rebrand the entire site by editing this ONE file — the marketing components
|
|
3
3
|
// (hero, pricing, FAQ, footer, nav) all read from here and stay generic. The
|
|
4
|
-
// `create-pylon` scaffolder and automated generators target this file too
|
|
5
|
-
// whole site can be themed by producing one typed object.
|
|
4
|
+
// `create-pylon` scaffolder and automated generators target this file too.
|
|
6
5
|
//
|
|
7
6
|
// Colors live here (applied as CSS variables on <html> in app/layout.tsx), so
|
|
8
7
|
// you don't touch globals.css to re-theme the marketing pages.
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// working. Edit lib/site.config.ts.
|
|
1
|
+
// Re-exports the marketing content (solutions, resources, company, comparisons)
|
|
2
|
+
// from the single site config so existing `@/lib/site` imports keep working.
|
|
3
|
+
// Edit lib/site.config.ts.
|
|
5
4
|
export {
|
|
6
5
|
SOLUTIONS,
|
|
7
6
|
RESOURCES,
|
|
@@ -13,15 +13,15 @@ import {
|
|
|
13
13
|
// `/api/auth/password/*`), then `persistSession` writes the freshly-minted
|
|
14
14
|
// token to local storage so the sync engine + `callFn` authenticate AS THE
|
|
15
15
|
// OWNER on the next load. This step matters here specifically: the landing page
|
|
16
|
-
// mints an anonymous guest session (for the live
|
|
16
|
+
// mints an anonymous guest session (for the live upvotes), and without
|
|
17
17
|
// persisting the real session that stale guest token would shadow the owner's
|
|
18
|
-
// — so the owner-only `
|
|
18
|
+
// — so the owner-only `submissionsForOwner` call would come back as a guest and
|
|
19
19
|
// get rejected. We then do a full navigation to /dashboard so the SSR runtime
|
|
20
20
|
// re-resolves auth from the HttpOnly cookie and renders server-side.
|
|
21
21
|
//
|
|
22
|
-
// A
|
|
23
|
-
// their one account. Whoever signs in only sees data if their email
|
|
24
|
-
// PYLON_OWNER_EMAIL — enforced by the
|
|
22
|
+
// A directory is single-tenant: there's no public sign-up, just the owner
|
|
23
|
+
// creating their one account. Whoever signs in only sees data if their email
|
|
24
|
+
// matches PYLON_OWNER_EMAIL — enforced by the submissionsForOwner function.
|
|
25
25
|
export function AuthForm() {
|
|
26
26
|
const [mode, setMode] = useState<"login" | "register">("login");
|
|
27
27
|
const [email, setEmail] = useState("");
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Sitemap } from "@pylonsync/react";
|
|
2
2
|
|
|
3
3
|
// app/sitemap.ts → served at /sitemap.xml. Point SITE_URL at your domain in
|
|
4
|
-
// production.
|
|
4
|
+
// production. This lists the public homepage; add more URLs as the site grows.
|
|
5
5
|
const SITE = process.env.SITE_URL ?? "http://localhost:4321";
|
|
6
6
|
|
|
7
7
|
export default async function sitemap(): Promise<Sitemap> {
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
// Who owns this
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// Who owns this directory? A directory is single-tenant — one curator — so
|
|
2
|
+
// ownership is just "the email the owner signs in with", configured once via
|
|
3
|
+
// the PYLON_OWNER_EMAIL env var. The owner-only functions (submissionsForOwner
|
|
4
|
+
// etc.) read that env (via `ctx.env`) and compare it here.
|
|
5
5
|
//
|
|
6
6
|
// Fail closed: if PYLON_OWNER_EMAIL is unset, NOBODY is the owner and the
|
|
7
7
|
// dashboard stays locked. That's deliberate — an unset owner on a public site
|
|
8
|
-
// must not mean "everyone can read the
|
|
8
|
+
// must not mean "everyone can read the submissions". Set it in .env (see
|
|
9
9
|
// .env.example) before signing in.
|
|
10
10
|
|
|
11
11
|
export function normalizeOwner(raw: string | null | undefined): string | null {
|
|
@@ -62,9 +62,8 @@ export default function App() {
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
function Chat() {
|
|
65
|
-
// Live subscriptions — sync engine pushes diffs over WebSocket
|
|
66
|
-
//
|
|
67
|
-
// re-render this component without polling.
|
|
65
|
+
// Live subscriptions — the sync engine pushes diffs over WebSocket, so new
|
|
66
|
+
// rooms/messages from any device re-render this component without polling.
|
|
68
67
|
const { data: rooms = [] } = db.useQuery<Room>("Room", {
|
|
69
68
|
orderBy: { createdAt: "asc" },
|
|
70
69
|
});
|
|
@@ -13,15 +13,15 @@ import {
|
|
|
13
13
|
// `/api/auth/password/*`), then `persistSession` writes the freshly-minted
|
|
14
14
|
// token to local storage so the sync engine + `callFn` authenticate AS THE
|
|
15
15
|
// OWNER on the next load. This step matters here specifically: the landing page
|
|
16
|
-
// mints an anonymous guest session (for the live
|
|
16
|
+
// mints an anonymous guest session (for the live booking picker), and without
|
|
17
17
|
// persisting the real session that stale guest token would shadow the owner's
|
|
18
|
-
// — so the owner-only `
|
|
18
|
+
// — so the owner-only `bookingsForOwner` call would come back as a guest and get
|
|
19
19
|
// rejected. We then do a full navigation to /dashboard so the SSR runtime
|
|
20
20
|
// re-resolves auth from the HttpOnly cookie and renders server-side.
|
|
21
21
|
//
|
|
22
|
-
// A
|
|
22
|
+
// A booking site is single-tenant: there's no public signup funnel, just the owner
|
|
23
23
|
// creating their one account. Whoever signs in only sees data if their email
|
|
24
|
-
// matches PYLON_OWNER_EMAIL — enforced by the
|
|
24
|
+
// matches PYLON_OWNER_EMAIL — enforced by the bookingsForOwner function.
|
|
25
25
|
export function AuthForm() {
|
|
26
26
|
const [mode, setMode] = useState<"login" | "signup">("login");
|
|
27
27
|
const [email, setEmail] = useState("");
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Sitemap } from "@pylonsync/react";
|
|
2
2
|
|
|
3
3
|
// app/sitemap.ts → served at /sitemap.xml. Point SITE_URL at your domain in
|
|
4
|
-
// production.
|
|
4
|
+
// production. This site is a single public page, so the sitemap is just "/".
|
|
5
5
|
const SITE = process.env.SITE_URL ?? "http://localhost:4321";
|
|
6
6
|
|
|
7
7
|
export default async function sitemap(): Promise<Sitemap> {
|
|
@@ -95,10 +95,8 @@ export function initials(name: string) {
|
|
|
95
95
|
.toUpperCase();
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
// A deliberately-obvious image placeholder
|
|
99
|
-
//
|
|
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.
|
|
98
|
+
// A deliberately-obvious image placeholder — dashed border, a photo glyph, and
|
|
99
|
+
// a one-line "swap this" instruction. Real sites drop a photo here.
|
|
102
100
|
//
|
|
103
101
|
// shape — "landscape" | "portrait" | "square" | "circle"
|
|
104
102
|
// title — what photo belongs here ("A photo of your shop")
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
// Who owns this
|
|
1
|
+
// Who owns this site? A booking site is single-tenant — one business, one
|
|
2
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 `
|
|
3
|
+
// once via the PYLON_OWNER_EMAIL env var. The owner-only `bookingsForOwner`
|
|
4
4
|
// function reads that env (via `ctx.env`) and compares it here.
|
|
5
5
|
//
|
|
6
6
|
// Fail closed: if PYLON_OWNER_EMAIL is unset, NOBODY is the owner and the
|
|
7
7
|
// dashboard stays locked. That's deliberate — an unset owner on a public site
|
|
8
|
-
// must not mean "everyone can read the
|
|
8
|
+
// must not mean "everyone can read the bookings". Set it in .env (see
|
|
9
9
|
// .env.example) before signing in.
|
|
10
10
|
|
|
11
11
|
export function normalizeOwner(raw: string | null | undefined): string | null {
|
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
// THE single source of truth for everything business-specific.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// create-pylon scaffolder and Mast target this file: a whole appointment site
|
|
6
|
-
// is themed + configured by producing one typed object.
|
|
1
|
+
// THE single source of truth for everything business-specific. The landing
|
|
2
|
+
// page, layout, AND the createBooking server function all read from here, so
|
|
3
|
+
// services, prices, weekly hours, and lead time stay in lockstep — rebrand and
|
|
4
|
+
// reconfigure the whole site by editing this ONE file.
|
|
7
5
|
//
|
|
8
6
|
// Colors live here (applied as CSS variables on <html> in app/layout.tsx).
|
|
9
7
|
//
|
|
@@ -18,9 +18,6 @@ import {
|
|
|
18
18
|
type Listing,
|
|
19
19
|
} from "../../../client/market";
|
|
20
20
|
|
|
21
|
-
// Data-driven SEO: the title + description come from the listing itself,
|
|
22
|
-
// fetched on the server. `generateMetadata` is handed the same PageProps as
|
|
23
|
-
// the page (params + serverData), so it reads the row directly.
|
|
24
21
|
// Resolve a listing from the URL segment, which is its slug
|
|
25
22
|
// ("herman-miller-aeron-a1f3"). Falls back to a raw id lookup so older
|
|
26
23
|
// id-shaped links keep working.
|
|
@@ -34,6 +31,9 @@ async function resolveListing(
|
|
|
34
31
|
);
|
|
35
32
|
}
|
|
36
33
|
|
|
34
|
+
// Data-driven SEO: the title + description come from the listing itself,
|
|
35
|
+
// fetched on the server. `generateMetadata` is handed the same PageProps as
|
|
36
|
+
// the page (params + serverData), so it reads the row directly.
|
|
37
37
|
export const generateMetadata: GenerateMetadata = async ({
|
|
38
38
|
params,
|
|
39
39
|
serverData,
|
|
@@ -48,7 +48,6 @@ export default mutation<MakeOfferArgs, MakeOfferResult>({
|
|
|
48
48
|
throw ctx.error("INVALID_ARGS", "offer must be greater than zero");
|
|
49
49
|
|
|
50
50
|
const id = await ctx.db.insert("Offer", {
|
|
51
|
-
// Reuse the optimistic ghost's id so the broadcast merges in place.
|
|
52
51
|
id: args._optimisticId,
|
|
53
52
|
listingId: args.listingId,
|
|
54
53
|
listingTitle: listing.title,
|
|
@@ -13,15 +13,15 @@ import {
|
|
|
13
13
|
// `/api/auth/password/*`), then `persistSession` writes the freshly-minted
|
|
14
14
|
// token to local storage so the sync engine + `callFn` authenticate AS THE
|
|
15
15
|
// OWNER on the next load. This step matters here specifically: the landing page
|
|
16
|
-
// mints an anonymous guest session (for the live
|
|
16
|
+
// mints an anonymous guest session (for the live table picker), and without
|
|
17
17
|
// persisting the real session that stale guest token would shadow the owner's
|
|
18
|
-
// — so the owner-only `
|
|
19
|
-
// rejected. We then do a full navigation to /dashboard so the SSR runtime
|
|
18
|
+
// — so the owner-only `reservationsForOwner` call would come back as a guest and
|
|
19
|
+
// get rejected. We then do a full navigation to /dashboard so the SSR runtime
|
|
20
20
|
// re-resolves auth from the HttpOnly cookie and renders server-side.
|
|
21
21
|
//
|
|
22
|
-
//
|
|
22
|
+
// This site is single-tenant: there's no public signup funnel, just the owner
|
|
23
23
|
// creating their one account. Whoever signs in only sees data if their email
|
|
24
|
-
// matches PYLON_OWNER_EMAIL — enforced by the
|
|
24
|
+
// matches PYLON_OWNER_EMAIL — enforced by the reservationsForOwner function.
|
|
25
25
|
export function AuthForm() {
|
|
26
26
|
const [mode, setMode] = useState<"login" | "signup">("login");
|
|
27
27
|
const [email, setEmail] = useState("");
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Sitemap } from "@pylonsync/react";
|
|
2
2
|
|
|
3
3
|
// app/sitemap.ts → served at /sitemap.xml. Point SITE_URL at your domain in
|
|
4
|
-
// production. The
|
|
4
|
+
// production. The site is a single public page, so the sitemap is just "/".
|
|
5
5
|
const SITE = process.env.SITE_URL ?? "http://localhost:4321";
|
|
6
6
|
|
|
7
7
|
export default async function sitemap(): Promise<Sitemap> {
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
// Who owns this
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// Who owns this restaurant site? It's single-tenant — one venue, one owner —
|
|
2
|
+
// so ownership is just "the email the owner signs in with", configured once via
|
|
3
|
+
// the PYLON_OWNER_EMAIL env var. The owner-only `reservationsForOwner` function
|
|
4
|
+
// reads that env (via `ctx.env`) and compares it here.
|
|
5
5
|
//
|
|
6
6
|
// Fail closed: if PYLON_OWNER_EMAIL is unset, NOBODY is the owner and the
|
|
7
7
|
// dashboard stays locked. That's deliberate — an unset owner on a public site
|
|
8
|
-
// must not mean "everyone can read the
|
|
8
|
+
// must not mean "everyone can read the reservations". Set it in .env (see
|
|
9
9
|
// .env.example) before signing in.
|
|
10
10
|
|
|
11
11
|
export function normalizeOwner(raw: string | null | undefined): string | null {
|
|
@@ -12,16 +12,16 @@ import {
|
|
|
12
12
|
// auth API directly (`passwordLogin` / `passwordRegister` POST to
|
|
13
13
|
// `/api/auth/password/*`), then `persistSession` writes the freshly-minted
|
|
14
14
|
// token to local storage so the sync engine + `callFn` authenticate AS THE
|
|
15
|
-
// OWNER on the next load. This step matters here specifically: the
|
|
16
|
-
// mints an anonymous guest session (for the live
|
|
15
|
+
// OWNER on the next load. This step matters here specifically: the storefront
|
|
16
|
+
// mints an anonymous guest session (for the live stock grid), and without
|
|
17
17
|
// persisting the real session that stale guest token would shadow the owner's
|
|
18
|
-
// — so the owner-only `
|
|
18
|
+
// — so the owner-only `ordersForOwner` call would come back as a guest and get
|
|
19
19
|
// rejected. We then do a full navigation to /dashboard so the SSR runtime
|
|
20
20
|
// re-resolves auth from the HttpOnly cookie and renders server-side.
|
|
21
21
|
//
|
|
22
|
-
// A
|
|
22
|
+
// A shop is single-tenant: there's no public signup funnel, just the owner
|
|
23
23
|
// creating their one account. Whoever signs in only sees data if their email
|
|
24
|
-
// matches PYLON_OWNER_EMAIL — enforced by the
|
|
24
|
+
// matches PYLON_OWNER_EMAIL — enforced by the ordersForOwner function.
|
|
25
25
|
export function AuthForm() {
|
|
26
26
|
const [mode, setMode] = useState<"login" | "signup">("login");
|
|
27
27
|
const [email, setEmail] = useState("");
|
|
@@ -15,7 +15,7 @@ interface LayoutProps {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
export default function RootLayout({ children, url, auth }: LayoutProps) {
|
|
18
|
-
// A guest session (minted by <EnsureGuest> for the live
|
|
18
|
+
// A guest session (minted by <EnsureGuest> for the live stock grid) has a
|
|
19
19
|
// `guest_…` user id — that's an anonymous visitor, NOT the signed-in owner,
|
|
20
20
|
// so it shouldn't flip the nav to "Dashboard".
|
|
21
21
|
const signedIn = Boolean(auth?.user_id && !auth.user_id.startsWith("guest_"));
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Sitemap } from "@pylonsync/react";
|
|
2
2
|
|
|
3
3
|
// app/sitemap.ts → served at /sitemap.xml. Point SITE_URL at your domain in
|
|
4
|
-
// production. The
|
|
4
|
+
// production. The storefront is a single public page, so the sitemap is just "/".
|
|
5
5
|
const SITE = process.env.SITE_URL ?? "http://localhost:4321";
|
|
6
6
|
|
|
7
7
|
export default async function sitemap(): Promise<Sitemap> {
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
// Who owns this
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// Who owns this shop? A shop is single-tenant — one business, one owner — so
|
|
2
|
+
// ownership is just "the email the owner signs in with", configured once via the
|
|
3
|
+
// PYLON_OWNER_EMAIL env var. The owner-only functions (ordersForOwner,
|
|
4
|
+
// fulfillOrder, cancelOrder, restockProduct) read that env (via `ctx.env`) and
|
|
5
|
+
// compare it here.
|
|
5
6
|
//
|
|
6
7
|
// Fail closed: if PYLON_OWNER_EMAIL is unset, NOBODY is the owner and the
|
|
7
8
|
// dashboard stays locked. That's deliberate — an unset owner on a public site
|
|
8
|
-
// must not mean "everyone can read the
|
|
9
|
+
// must not mean "everyone can read the orders". Set it in .env (see
|
|
9
10
|
// .env.example) before signing in.
|
|
10
11
|
|
|
11
12
|
export function normalizeOwner(raw: string | null | undefined): string | null {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
|
|
3
|
-
// A layout wraps every page. This one is intentionally minimal — a
|
|
4
|
-
//
|
|
3
|
+
// A layout wraps every page. This one is intentionally minimal — just a
|
|
4
|
+
// centered column. The page below it is server-rendered first (so the
|
|
5
5
|
// shell and copy are in the HTML), then hydrates into the live todo UI.
|
|
6
6
|
interface LayoutProps {
|
|
7
7
|
children: React.ReactNode;
|
|
@@ -14,7 +14,7 @@ export default async function sitemap(): Promise<Sitemap> {
|
|
|
14
14
|
{ url: `${SITE}/signup`, changeFrequency: "yearly", priority: 0.5 },
|
|
15
15
|
];
|
|
16
16
|
|
|
17
|
-
//
|
|
17
|
+
// Example — enumerate dynamic pages from a DB read:
|
|
18
18
|
//
|
|
19
19
|
// const posts = await fetchPublishedPosts();
|
|
20
20
|
// const postRoutes: Sitemap = posts.map((p) => ({
|
package/templates/todo/app.ts
CHANGED
|
@@ -7,11 +7,10 @@ import {
|
|
|
7
7
|
discoverAppRoutes,
|
|
8
8
|
} from "@pylonsync/sdk";
|
|
9
9
|
|
|
10
|
-
// A todo that belongs to one person. `userId: field.owner()`
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
// no round-trip) while ownership stays unspoofable. No createTodo function to
|
|
10
|
+
// A todo that belongs to one person. `userId: field.owner()` stamps the
|
|
11
|
+
// signed-in (here: guest) user's id server-side on insert and rejects any
|
|
12
|
+
// forged value — so the UI can do a plain, optimistic `db.insert("Todo",
|
|
13
|
+
// { title })` and never send (or spoof) userId. No createTodo function to
|
|
15
14
|
// write — every verb is a direct, policy-checked entity call.
|
|
16
15
|
const Todo = entity(
|
|
17
16
|
"Todo",
|
|
@@ -4,18 +4,14 @@ import tailwindcss from "@tailwindcss/vite";
|
|
|
4
4
|
|
|
5
5
|
// Pylon dev exposes TWO ports:
|
|
6
6
|
// :4321 → HTTP (functions, entity CRUD, /api/sync/pull, /api/auth/*)
|
|
7
|
-
// :4322 → dedicated WebSocket listener
|
|
8
|
-
//
|
|
9
|
-
// the reader thread's mutex is released every 200ms by the
|
|
10
|
-
// kernel-level read timeout — no client keepalive ping
|
|
11
|
-
// needed to break the wedge.
|
|
7
|
+
// :4322 → dedicated WebSocket listener that pushes broadcasts with
|
|
8
|
+
// lower latency than the HTTP-multiplexed path.
|
|
12
9
|
//
|
|
13
10
|
// We route the WS upgrade to :4322 and everything else to :4321. The
|
|
14
|
-
// HTTP-multiplexed `/api/sync/ws` on :4321 also works
|
|
11
|
+
// HTTP-multiplexed `/api/sync/ws` on :4321 also works and is the
|
|
15
12
|
// production fallback for proxies that can't forward to a secondary
|
|
16
|
-
// port
|
|
17
|
-
//
|
|
18
|
-
// are latency-bounded by the client SDK's 200ms keepalive ping.
|
|
13
|
+
// port, but its broadcasts are latency-bounded by the client SDK's
|
|
14
|
+
// 200ms keepalive ping.
|
|
19
15
|
const PYLON_HTTP_TARGET = process.env.PYLON_TARGET ?? "http://localhost:4321";
|
|
20
16
|
const PYLON_WS_TARGET = process.env.PYLON_WS_TARGET ?? "ws://localhost:4322";
|
|
21
17
|
|
|
@@ -6,11 +6,10 @@ import { EnsureGuest } from "@pylonsync/client";
|
|
|
6
6
|
import type { WaitlistConfig } from "@/lib/site.config";
|
|
7
7
|
|
|
8
8
|
// The interactive top of the landing page: the email-capture form and the LIVE
|
|
9
|
-
// signup counter.
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// refresh, no polling.
|
|
9
|
+
// signup counter. The counter is a live `db.useQuery("WaitlistStat")` over the
|
|
10
|
+
// public, PII-free aggregate row, so the moment anyone (this tab or another)
|
|
11
|
+
// submits an email, joinWaitlist updates that row and the new count syncs to
|
|
12
|
+
// every open tab through the replica. No refresh, no polling.
|
|
14
13
|
//
|
|
15
14
|
// The signup form (joinWaitlist) is a public mutation, so it works for any
|
|
16
15
|
// anonymous visitor. The counter needs a live sync connection, so it's wrapped
|
|
@@ -10,16 +10,18 @@ import {
|
|
|
10
10
|
|
|
11
11
|
// ---------------------------------------------------------------------------
|
|
12
12
|
// waitlist — a pre-launch / coming-soon landing page with a LIVE signup
|
|
13
|
-
// counter. The
|
|
14
|
-
//
|
|
15
|
-
// refresh. That's the proof it's a real live app and not a static page.
|
|
13
|
+
// counter. The realtime hook: open the page in two tabs, submit an email in
|
|
14
|
+
// one, and the counter on the other ticks up with no refresh.
|
|
16
15
|
//
|
|
17
|
-
// The data model is deliberately tiny —
|
|
18
|
-
// • Signup
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
16
|
+
// The data model is deliberately tiny — three entities:
|
|
17
|
+
// • Signup — one row per email. Holds visitor PII, so it denies ALL
|
|
18
|
+
// client reads/writes (writes go through the joinWaitlist
|
|
19
|
+
// mutation; the public page only ever sees an aggregate
|
|
20
|
+
// count, never an email).
|
|
21
|
+
// • WaitlistStat — a single-row, PII-free aggregate (just the count) the
|
|
22
|
+
// public page reads live for the counter.
|
|
23
|
+
// • User — the business owner's account (email/password is built in),
|
|
24
|
+
// so the owner can sign in to the dashboard and see signups.
|
|
23
25
|
// ---------------------------------------------------------------------------
|
|
24
26
|
|
|
25
27
|
// One waitlist signup. `email` is the only PII; `createdAt` powers the
|
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
// THE single source of truth for everything business-specific on this waitlist.
|
|
2
2
|
// Rebrand the whole page by editing this ONE file — the landing page and layout
|
|
3
|
-
// read from here and stay generic.
|
|
4
|
-
// generators (Mast) target this file too, so a whole site can be themed by
|
|
5
|
-
// producing one typed object.
|
|
3
|
+
// read from here and stay generic.
|
|
6
4
|
//
|
|
7
|
-
// Colors live here (applied as CSS variables on <html> in app/layout.tsx),
|
|
8
|
-
// you don't touch globals.css to re-theme.
|
|
5
|
+
// Colors live here too (applied as CSS variables on <html> in app/layout.tsx),
|
|
6
|
+
// so you don't touch globals.css to re-theme.
|
|
9
7
|
//
|
|
10
8
|
// Fictional demo copy — replace the values, keep the shape.
|
|
11
9
|
|
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
@import "tailwindcss";
|
|
2
2
|
@source "../../../../packages/ui/src/**/*.{ts,tsx}";
|
|
3
3
|
|
|
4
|
-
:root {
|
|
5
|
-
color-scheme: light dark;
|
|
6
|
-
}
|
|
7
|
-
|
|
4
|
+
:root { color-scheme: light dark; }
|
|
8
5
|
html, body { height: 100%; }
|
|
9
6
|
body { font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; }
|