@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,63 @@
|
|
|
1
|
+
import { mutation, v } from "@pylonsync/functions";
|
|
2
|
+
|
|
3
|
+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
4
|
+
const URL_RE = /^https?:\/\/.+/i;
|
|
5
|
+
|
|
6
|
+
// submitListing — a visitor proposes a new directory entry. A `mutation` (it
|
|
7
|
+
// writes the Submission transactionally). `auth: "public"` — submitters have no
|
|
8
|
+
// account.
|
|
9
|
+
//
|
|
10
|
+
// PRIVACY: returns only `{ ok }`, never a Submission row. The entry is NOT
|
|
11
|
+
// public yet — it's written as a pending Submission (deny-all) for the owner to
|
|
12
|
+
// review; approveSubmission is what turns it into a public Listing.
|
|
13
|
+
export default mutation<
|
|
14
|
+
{
|
|
15
|
+
submitterName: string;
|
|
16
|
+
submitterEmail: string;
|
|
17
|
+
name: string;
|
|
18
|
+
tagline: string;
|
|
19
|
+
url: string;
|
|
20
|
+
category: string;
|
|
21
|
+
tags?: string;
|
|
22
|
+
description?: string;
|
|
23
|
+
},
|
|
24
|
+
{ ok: boolean }
|
|
25
|
+
>({
|
|
26
|
+
auth: "public",
|
|
27
|
+
args: {
|
|
28
|
+
submitterName: v.string(),
|
|
29
|
+
submitterEmail: v.string(),
|
|
30
|
+
name: v.string(),
|
|
31
|
+
tagline: v.string(),
|
|
32
|
+
url: v.string(),
|
|
33
|
+
category: v.string(),
|
|
34
|
+
tags: v.optional(v.string()),
|
|
35
|
+
description: v.optional(v.string()),
|
|
36
|
+
},
|
|
37
|
+
async handler(ctx, args) {
|
|
38
|
+
const submitterName = args.submitterName.trim();
|
|
39
|
+
const submitterEmail = args.submitterEmail.trim().toLowerCase();
|
|
40
|
+
const name = args.name.trim();
|
|
41
|
+
const url = args.url.trim();
|
|
42
|
+
if (!submitterName) throw ctx.error("INVALID_ARGS", "Your name is required.");
|
|
43
|
+
if (!EMAIL_RE.test(submitterEmail)) throw ctx.error("INVALID_ARGS", "Enter a valid email.");
|
|
44
|
+
if (!name) throw ctx.error("INVALID_ARGS", "The listing name is required.");
|
|
45
|
+
if (!URL_RE.test(url)) throw ctx.error("INVALID_ARGS", "Enter a valid URL (https://…).");
|
|
46
|
+
|
|
47
|
+
const clip = (s: string | undefined, n: number) => (s ? s.trim().slice(0, n) : null);
|
|
48
|
+
|
|
49
|
+
await ctx.db.unsafe.insert("Submission", {
|
|
50
|
+
submitterName: submitterName.slice(0, 120),
|
|
51
|
+
submitterEmail: submitterEmail.slice(0, 254),
|
|
52
|
+
name: name.slice(0, 120),
|
|
53
|
+
tagline: clip(args.tagline, 160) ?? "",
|
|
54
|
+
url: url.slice(0, 500),
|
|
55
|
+
category: clip(args.category, 60) ?? "",
|
|
56
|
+
tags: clip(args.tags, 160),
|
|
57
|
+
description: clip(args.description, 2000),
|
|
58
|
+
status: "new",
|
|
59
|
+
createdAt: new Date().toISOString(),
|
|
60
|
+
});
|
|
61
|
+
return { ok: true };
|
|
62
|
+
},
|
|
63
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { mutation, v } from "@pylonsync/functions";
|
|
2
|
+
|
|
3
|
+
// upvote — bump a listing's vote count. Public (anyone browsing can vote) and
|
|
4
|
+
// the realtime heart of the template: the Listing.votes write syncs to every
|
|
5
|
+
// open browse grid, so the count ticks up for everyone live. A per-listing
|
|
6
|
+
// advisory lock makes the read-then-increment race-safe under concurrent votes.
|
|
7
|
+
//
|
|
8
|
+
// Listings are deny-write to clients, so votes can ONLY move through here — a
|
|
9
|
+
// client can't forge a count. (Dedupe is best-effort on the client via
|
|
10
|
+
// localStorage; a production directory would tie votes to a session/Vote row.)
|
|
11
|
+
export default mutation<{ listingId: string }, { ok: boolean; votes: number }>({
|
|
12
|
+
auth: "public",
|
|
13
|
+
args: { listingId: v.id("Listing") },
|
|
14
|
+
async handler(ctx, args) {
|
|
15
|
+
await ctx.db.advisoryLock(`directory_listing:${args.listingId}`);
|
|
16
|
+
const listing = (await ctx.db.get("Listing", args.listingId)) as
|
|
17
|
+
| { votes: number }
|
|
18
|
+
| null;
|
|
19
|
+
if (!listing) throw ctx.error("NOT_FOUND", "Listing not found.");
|
|
20
|
+
const votes = (listing.votes ?? 0) + 1;
|
|
21
|
+
await ctx.db.unsafe.update("Listing", args.listingId, { votes });
|
|
22
|
+
return { ok: true, votes };
|
|
23
|
+
},
|
|
24
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Shared directory types. The Submission row is what the owner dashboard sees
|
|
2
|
+
// (with PII); the client imports only the type, never server code.
|
|
3
|
+
|
|
4
|
+
// A public listing as it syncs to the browse grid (no PII).
|
|
5
|
+
export interface ListingRow {
|
|
6
|
+
id: string;
|
|
7
|
+
name: string;
|
|
8
|
+
tagline: string;
|
|
9
|
+
url: string;
|
|
10
|
+
category: string;
|
|
11
|
+
tags?: string | null;
|
|
12
|
+
description?: string | null;
|
|
13
|
+
votes: number;
|
|
14
|
+
featured: boolean;
|
|
15
|
+
createdAt: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SubmissionRow {
|
|
19
|
+
id: string;
|
|
20
|
+
submitterName: string;
|
|
21
|
+
submitterEmail: string;
|
|
22
|
+
name: string;
|
|
23
|
+
tagline: string;
|
|
24
|
+
url: string;
|
|
25
|
+
category: string;
|
|
26
|
+
tags?: string | null;
|
|
27
|
+
description?: string | null;
|
|
28
|
+
status: string; // "new" | "approved" | "rejected"
|
|
29
|
+
createdAt: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// submissionsForOwner returns a discriminated result rather than throwing on a
|
|
33
|
+
// non-owner (a query has no `ctx.error`; a bare throw becomes a stripped
|
|
34
|
+
// HANDLER_ERROR). A non-owner gets `{ authorized: false }` and NO data.
|
|
35
|
+
export type OwnerSubmissionsResult =
|
|
36
|
+
| { authorized: true; submissions: SubmissionRow[] }
|
|
37
|
+
| { authorized: false };
|
|
38
|
+
|
|
39
|
+
// Split a comma-separated tag string into trimmed chips.
|
|
40
|
+
export function parseTags(tags?: string | null): string[] {
|
|
41
|
+
return (tags ?? "")
|
|
42
|
+
.split(",")
|
|
43
|
+
.map((t) => t.trim())
|
|
44
|
+
.filter(Boolean);
|
|
45
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Who owns this studio site? A studio 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 functions
|
|
4
|
+
// (inquiriesForOwner etc.) read that env (via `ctx.env`) and compare 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 inquiries". 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,130 @@
|
|
|
1
|
+
// THE single source of truth for everything business-specific on this directory
|
|
2
|
+
// site. Rebrand the whole thing by editing this ONE file — the landing page,
|
|
3
|
+
// layout, and the seedListings function all read from here. The create-pylon
|
|
4
|
+
// scaffolder and Mast target this file: a whole directory is themed + seeded
|
|
5
|
+
// from one typed object.
|
|
6
|
+
//
|
|
7
|
+
// Colors live here (applied as CSS variables on <html> in app/layout.tsx).
|
|
8
|
+
// Fictional demo copy — replace the values, keep the shape.
|
|
9
|
+
|
|
10
|
+
/* ----------------------------- types ----------------------------- */
|
|
11
|
+
|
|
12
|
+
export type Social = { label: string; href: string; path: string };
|
|
13
|
+
|
|
14
|
+
export type BaseConfig = {
|
|
15
|
+
brand: {
|
|
16
|
+
name: string;
|
|
17
|
+
letter: string;
|
|
18
|
+
domain: string;
|
|
19
|
+
email: string;
|
|
20
|
+
footerBlurb: string;
|
|
21
|
+
copyrightName: string;
|
|
22
|
+
socials: Social[];
|
|
23
|
+
};
|
|
24
|
+
colors: { brand: string; brandSoft: string; paper: string };
|
|
25
|
+
seo: { title: string; description: string };
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// A starter directory entry (seeds the public Listing table on first visit).
|
|
29
|
+
export type SeedListing = {
|
|
30
|
+
name: string;
|
|
31
|
+
tagline: string;
|
|
32
|
+
url: string;
|
|
33
|
+
category: string;
|
|
34
|
+
tags?: string; // comma-separated
|
|
35
|
+
description?: string;
|
|
36
|
+
votes?: number;
|
|
37
|
+
featured?: boolean;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type DirectoryConfig = BaseConfig & {
|
|
41
|
+
hero: {
|
|
42
|
+
tagline: string;
|
|
43
|
+
headline: string;
|
|
44
|
+
subcopy: string;
|
|
45
|
+
ctaLabel: string;
|
|
46
|
+
searchPlaceholder: string;
|
|
47
|
+
};
|
|
48
|
+
browse: { eyebrow: string; headline: string };
|
|
49
|
+
// The facet values offered in the submit form's category select. The browse
|
|
50
|
+
// facet sidebar is built live from the data, so this only needs to cover what
|
|
51
|
+
// submitters can pick.
|
|
52
|
+
categories: string[];
|
|
53
|
+
seedListings: SeedListing[];
|
|
54
|
+
submit: {
|
|
55
|
+
eyebrow: string;
|
|
56
|
+
headline: string;
|
|
57
|
+
subcopy: string;
|
|
58
|
+
confirmationMessage: string;
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/* ----------------------------- config ---------------------------- */
|
|
63
|
+
|
|
64
|
+
export const siteConfig: DirectoryConfig = {
|
|
65
|
+
brand: {
|
|
66
|
+
name: "Stacked",
|
|
67
|
+
letter: "S",
|
|
68
|
+
domain: "stacked.dev",
|
|
69
|
+
email: "hello@stacked.example",
|
|
70
|
+
footerBlurb:
|
|
71
|
+
"A hand-checked directory of the tools developers actually reach for. Search it, sort by votes, and submit the ones we're missing.",
|
|
72
|
+
copyrightName: "Stacked",
|
|
73
|
+
socials: [
|
|
74
|
+
{
|
|
75
|
+
label: "X",
|
|
76
|
+
href: "https://x.com",
|
|
77
|
+
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",
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
colors: { brand: "#7c3aed", brandSoft: "#ede9fe", paper: "#faf9fc" },
|
|
83
|
+
|
|
84
|
+
seo: {
|
|
85
|
+
title: "Stacked — the developer tools directory.",
|
|
86
|
+
description:
|
|
87
|
+
"A searchable, community-voted directory of developer tools. Full-text search, filter by category, upvote your favorites, and submit the ones we're missing.",
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
hero: {
|
|
91
|
+
tagline: "The dev tools directory",
|
|
92
|
+
headline: "The tools developers actually use.",
|
|
93
|
+
subcopy:
|
|
94
|
+
"Search a hand-checked directory of developer tools, filter by category, and upvote the ones that earn it. Live results, live votes — no signup to browse.",
|
|
95
|
+
ctaLabel: "Submit a tool",
|
|
96
|
+
searchPlaceholder: "Search tools — try “database”, “deploy”, “auth”…",
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
browse: {
|
|
100
|
+
eyebrow: "Browse",
|
|
101
|
+
headline: "Find your next tool.",
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
categories: ["Database", "Hosting", "Auth", "AI", "Analytics", "Design", "Productivity", "DevOps"],
|
|
105
|
+
|
|
106
|
+
// Fictional-but-plausible starter entries across categories. Replace with your
|
|
107
|
+
// own; `votes` seeds the leaderboard so "Top voted" isn't a flat list.
|
|
108
|
+
seedListings: [
|
|
109
|
+
{ name: "Quill", tagline: "Postgres with a realtime sync engine baked in.", url: "https://example.com/quill", category: "Database", tags: "postgres, realtime, sync", votes: 342, featured: true },
|
|
110
|
+
{ name: "Tigris Store", tagline: "S3-compatible object storage at the edge.", url: "https://example.com/tigris", category: "Hosting", tags: "storage, edge, s3", votes: 218 },
|
|
111
|
+
{ name: "Gatekeep", tagline: "Drop-in auth: passkeys, OAuth, and magic links.", url: "https://example.com/gatekeep", category: "Auth", tags: "auth, passkeys, oauth", votes: 287, featured: true },
|
|
112
|
+
{ name: "Loom AI", tagline: "Self-hostable LLM gateway with caching + routing.", url: "https://example.com/loom", category: "AI", tags: "llm, gateway, cache", votes: 401, featured: true },
|
|
113
|
+
{ name: "Pulse", tagline: "Privacy-first product analytics you can self-host.", url: "https://example.com/pulse", category: "Analytics", tags: "analytics, privacy", votes: 174 },
|
|
114
|
+
{ name: "Frame", tagline: "A design-token pipeline from Figma to code.", url: "https://example.com/frame", category: "Design", tags: "design, tokens, figma", votes: 132 },
|
|
115
|
+
{ name: "Cadence", tagline: "Background jobs + cron with a visual dashboard.", url: "https://example.com/cadence", category: "DevOps", tags: "jobs, cron, queue", votes: 209 },
|
|
116
|
+
{ name: "Inbox Zero", tagline: "Transactional email with a real templating story.", url: "https://example.com/inbox", category: "Productivity", tags: "email, templates", votes: 96 },
|
|
117
|
+
{ name: "Shipyard", tagline: "Preview deploys for every PR, in seconds.", url: "https://example.com/shipyard", category: "Hosting", tags: "deploy, ci, preview", votes: 263 },
|
|
118
|
+
{ name: "Vector Vault", tagline: "Managed vector search without the ops.", url: "https://example.com/vectorvault", category: "AI", tags: "vector, search, rag", votes: 188 },
|
|
119
|
+
{ name: "Schema", tagline: "Type-safe migrations that review themselves.", url: "https://example.com/schema", category: "Database", tags: "migrations, types", votes: 151 },
|
|
120
|
+
{ name: "Watchtower", tagline: "Uptime + error tracking in one calm dashboard.", url: "https://example.com/watchtower", category: "DevOps", tags: "monitoring, errors", votes: 144 },
|
|
121
|
+
],
|
|
122
|
+
|
|
123
|
+
submit: {
|
|
124
|
+
eyebrow: "Submit a tool",
|
|
125
|
+
headline: "Know one we're missing?",
|
|
126
|
+
subcopy:
|
|
127
|
+
"Send it over. Submissions land in the curator's queue — once it's approved it shows up in the directory for everyone.",
|
|
128
|
+
confirmationMessage: "Thanks — your submission's in the queue. We review new tools a few times a week.",
|
|
129
|
+
},
|
|
130
|
+
};
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Copy to `.env` and fill in. `pylon dev` loads `.env` automatically.
|
|
2
|
+
|
|
3
|
+
# ── Owner (required to use the dashboard) ────────────────────────────────────
|
|
4
|
+
# An appointment business is single-tenant: one shop, one owner. The /dashboard
|
|
5
|
+
# is unlocked only for the account whose email matches this value, and the
|
|
6
|
+
# owner-only bookings function refuses to return any customer data otherwise.
|
|
7
|
+
# Set this to the email you'll sign in with, then create that account at /login.
|
|
8
|
+
PYLON_OWNER_EMAIL=you@yourshop.com
|
|
9
|
+
|
|
10
|
+
# ── Site URL (optional) ──────────────────────────────────────────────────────
|
|
11
|
+
# Used by robots.txt + sitemap.xml. Point it at your real domain in production.
|
|
12
|
+
# SITE_URL=https://yourshop.com
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# AGENTS.md — working in a Pylon project
|
|
2
|
+
|
|
3
|
+
Operating rules for a coding agent in this Pylon app. Pylon is a Rails-like framework for realtime apps: you declare entities, policies, and server functions in TypeScript, and a single Rust binary (`pylon`) serves the API, auth, sync, WebSocket, SSE, and native React 19 SSR — one process, one port. The full API reference is at **/llms-full.txt** (served at `/llms-full.txt`; in the repo at `apps/web/public/llms-full.txt`). Read it before guessing an API name.
|
|
4
|
+
|
|
5
|
+
## Directory conventions
|
|
6
|
+
|
|
7
|
+
**Unified SSR app:**
|
|
8
|
+
- `app.ts` — data model + manifest (`entity()` + `field.*`, queries/actions/policies, `routes: await discoverAppRoutes()`). Ends with `console.log(JSON.stringify(manifest))`.
|
|
9
|
+
- `app/` — file-based SSR routes. `app/page.tsx` → `/`, `app/about/page.tsx` → `/about`, `app/blog/[slug]/page.tsx` → `/blog/:slug`. `app/layout.tsx` is the shell; `app/error.tsx` / `app/not-found.tsx` are boundaries.
|
|
10
|
+
- `app/globals.css` — Tailwind v4 entrypoint (auto-compiled and injected).
|
|
11
|
+
- `functions/` — server functions, one per file, `default`-exported.
|
|
12
|
+
- `.pylon/` — local dev state (sqlite, jobs, sessions, uploads). Created by `pylon dev`. Do not commit.
|
|
13
|
+
|
|
14
|
+
**Monorepo app:** backend is `apps/api/` (entry `apps/api/schema.ts`, handlers in `apps/api/functions/`); frontend in `apps/web/`. `pylon.manifest.json` / `pylon.client.ts` are generated — do not hand-edit.
|
|
15
|
+
|
|
16
|
+
## The core authoring loop
|
|
17
|
+
|
|
18
|
+
1. **Define an entity** — `entity("Thing", { name: field.string(), done: field.boolean().default(false) })`. Modifiers: `.optional()`, `.unique()`, `.readonly()` (settable on insert, rejected on client update — use for `authorId`/`orgId`), `.serverOnly()` (never in HTTP responses), `.encrypted()` (AEAD at rest, needs `PYLON_ENCRYPTION_KEY`), `.crdt("text")` (collaborative).
|
|
19
|
+
2. **Write a policy** — `policy({ entity: "Thing", allowRead, allowInsert, allowUpdate, allowDelete })` with CEL-like expressions over `auth.*` / `data.*` (e.g. `"auth.userId == data.authorId"`). **Omitted actions DENY by default.** Wide-open dev policies (`allow*: "true"`) are flagged by `pylon lint` — tighten before shipping.
|
|
20
|
+
3. **Author a function** in `functions/<name>.ts` — `query` (read-only), `mutation` (transactional read+write), or `action` (external I/O, no direct `ctx.db`). Import `{ query, mutation, action, v }` from `@pylonsync/functions`. `auth` defaults to `"user"` (secure-by-default); set `"public"` explicitly for unauthenticated access. Use `ctx.db.*`, `ctx.auth.userId`, `ctx.error(code, msg)`.
|
|
21
|
+
4. **Read it on the client** — `db.useQuery("Thing")` (live, re-renders on any write) or `db.useQueryOne("Thing", id)`. Call functions with `db.fn(name, args)` / `callFn`. On SSR pages, read via `use(serverData.list("Thing"))` inside `<Suspense>`.
|
|
22
|
+
|
|
23
|
+
## Key gotchas
|
|
24
|
+
|
|
25
|
+
- **Policies deny by default; server functions BYPASS them.** Direct client CRUD (`/api/entities/*`) and sync are policy-checked. Functions run with full DB access — enforce trust with `ctx.auth` checks inside the handler, not policies.
|
|
26
|
+
- **Type page props from the SDK, don't hand-roll them.** `import type { PageProps, Metadata } from "@pylonsync/react"`. Every page/layout gets `{ url, params, searchParams, auth, response, serverData }`; `PageProps<{ slug: string }>` types a `[slug]` route's params. Request headers/cookies are intentionally NOT on `PageProps` — they're server-only and stripped from hydration, so reading them in the render would mismatch.
|
|
27
|
+
- **Anonymous output caching is opt-in + earned.** `export const revalidate = 60` (seconds) on a page makes it CDN-cacheable (`public, s-maxage=60`) — but ONLY if the render is auth-INDEPENDENT: it must NOT read `props.auth` (reading it at all opts out, even for anonymous), set no cookie, and the app must not run strict per-caller policies (`PYLON_STRICT_FN_POLICIES`). `export const dynamic = "force-static"` caches until the next deploy; `"force-dynamic"` never caches. Fail-closed: without the opt-in (or if any condition fails) the page is `no-cache`. A page that reads `auth` or sets a cookie is never shared. The SAME earned render is also kept in an **origin disk cache** (`.pylon/.cache/ssr`): a cookie-less GET with no query string is served straight off disk for the TTL — skipping the render entirely — then re-rendered live when stale. The disk cache is namespaced per deploy (wiped on each new build) and OFF in `pylon dev` (so an edit is never masked by a stale entry); invalidation is by the `revalidate` TTL or the next deploy.
|
|
28
|
+
- **No-JS forms use `route.ts` + `<Form>`.** Drop `app/.../route.ts` exporting `export const POST: RouteHandler = async ({ form, db, response, auth }) => { await db.insert("X", {...}); response.redirect("/x?ok=1"); }` (303 POST-redirect-GET by default). Render `<Form action="/x">` (from @pylonsync/react) with plain `<input name=...>` — works with JS off (native POST→handler→redirect) and is enhanced to no-reload when JS is on. The handler's `db` is read+write (mutation trust model — gate on `auth`); CSRF is automatic (Origin gate + SameSite=Lax). Multipart/file uploads aren't supported yet — use urlencoded forms + `/api/files`.
|
|
29
|
+
- **`loading.tsx` streams a skeleton while the page's data resolves.** Drop `app/.../loading.tsx` (default export, page props) and the nearest one becomes a route-level Suspense fallback: Pylon flushes the shell + skeleton immediately, then reveals the real page when its top-level `use(serverData…)` resolves (no blank page). It only shows when the PAGE suspends — a page that wraps its own `<Suspense>` around a child (like `/dashboard` in this template) handles that itself. The skeleton is SERVER-ONLY: don't read `serverData` in it. A page with no `loading.tsx` is buffered (unchanged).
|
|
30
|
+
- **`export const streaming = true` streams a page's OWN inner `<Suspense>` boundaries.** Without it (and without a `loading.tsx`), a page is BUFFERED — the whole document, including suspended children, resolves before the first byte. Opt in and the shell + each inner `<Suspense>` fallback flush immediately, then each boundary's real content streams in as its data resolves (multi-boundary progressive streaming). It's opt-in because it changes the response timing contract: a streaming render commits its HTTP head BEFORE suspended subtrees finish, so (a) it's never CDN/disk cacheable — don't combine with `export const revalidate`; (b) `response.setStatus/setCookie/redirect/notFound` only take effect from the SYNCHRONOUS shell render — a call from inside a suspended subtree is dropped (the runtime logs a loud warning naming what was lost); (c) a `throw` from a deep `<Suspense>` child resolves via its nearest `error.tsx` at HTTP 200, not a 5xx. Hydration is clean for any number of boundaries (the data blob ships before hydration runs). Type the config with `import type { RouteSegmentConfig } from "@pylonsync/react"`.
|
|
31
|
+
- **`error.tsx` / `not-found.tsx` boundaries are HYDRATED (interactive).** `app/.../error.tsx` catches a throw below it (HTTP 500) and receives `{ error: { message, digest }, reset }` (`import type { ErrorBoundaryProps }`) — `reset()` re-attempts the route; the stack NEVER reaches the client (dev overlay + logs only). `app/.../not-found.tsx` renders at 404 (also for `response.notFound()`) and gets the page props (`NotFoundProps`), no `reset`. Both run useState/onClick/hooks.
|
|
32
|
+
- **Client navigation hooks live in @pylonsync/react.** `useRouter()` → `{ push, replace, back, forward, refresh, prefetch }`; `useSearchParams()` → reactive `URLSearchParams`; `usePathname()` → reactive pathname. The hooks are CLIENT-reactive — during SSR they return defaults (empty params / "/"); for server-side URL values read the `url` / `searchParams` page props.
|
|
33
|
+
- **Dynamic + catch-all routes follow Next conventions.** `app/blog/[slug]/page.tsx` → `params.slug`. `app/docs/[...path]/page.tsx` is a catch-all (matches `/docs/a/b/c`; `params.path === "a/b/c"` — `.split("/")` for segments). `app/shop/[[...filters]]/page.tsx` is an optional catch-all (also matches the bare `/shop`, with `params.filters === ""`). A catch-all must be the last segment; static beats dynamic beats catch-all on overlap.
|
|
34
|
+
- **`serverData` (SSR) is READ-ONLY.** No write methods; the runtime rejects write frames (`SSR_WRITE_FORBIDDEN`). Mutations belong in actions/functions, never in a page render.
|
|
35
|
+
- **`response.*` / `response.redirect()` / `response.notFound()` must fire in the synchronous shell render**, before any `await` / `<Suspense>`. The HTTP head commits when the shell is ready — status/headers/cookies set from a suspended subtree are lost, and `redirect`/`notFound` thrown below a Suspense boundary are swallowed.
|
|
36
|
+
- **`ctx.llm` and `ctx.connections` are on mutation + action only, NOT query** (reactive purity). `action` has no direct `ctx.db` — use `ctx.runQuery` / `ctx.runMutation`.
|
|
37
|
+
- **It's `db.useQueryOne`, not `useOne`.** Validators and field types have aliases: `v.bool`/`v.boolean`, `v.float`/`v.number`.
|
|
38
|
+
- **There is no `ctx.files` or `defineWorkflow`/`defineJob`.** Files go through `<FileUpload>` + `/api/files/*`; deferred execution is `ctx.scheduler.runAfter/runAt/cancel`.
|
|
39
|
+
|
|
40
|
+
## Use the CLI — don't guess
|
|
41
|
+
|
|
42
|
+
| Need | Command |
|
|
43
|
+
|---|---|
|
|
44
|
+
| Run the app (SSR + API, hot reload, one port `:4321`) | `pylon dev` (or `npm run dev`) |
|
|
45
|
+
| Regenerate manifest + typed client | `pylon codegen` (Swift client: `pylon codegen client --target swift`) |
|
|
46
|
+
| Validate / diff / push schema | `pylon schema check` \| `diff` \| `push` |
|
|
47
|
+
| Migrations | `pylon migrate create <name>` \| `plan` \| `apply` |
|
|
48
|
+
| Lint policies (PYL001–PYL004) | `pylon lint --strict` |
|
|
49
|
+
| Tests | `pylon test` |
|
|
50
|
+
| Adversarial security probe | `pylon test:security` |
|
|
51
|
+
| Inspect cloud request logs (agent-safe) | `pylon logs --json --limit 50` |
|
|
52
|
+
| Inspect data / entities | `pylon data entities` \| `pylon data list <Entity>` |
|
|
53
|
+
| Call a function | `pylon fn <name> key=value` |
|
|
54
|
+
| Health snapshot | `pylon status` |
|
|
55
|
+
| Build for prod | `pylon build` |
|
|
56
|
+
| Deploy (Pylon Cloud by default) | `pylon deploy` |
|
|
57
|
+
| Look up an error code | `pylon explain <CODE>` |
|
|
58
|
+
|
|
59
|
+
`--json` works on every command for machine-readable output. Prefer one-shot/agent-safe flags (`pylon logs --limit N`, not a blocking `--follow`).
|
|
60
|
+
|
|
61
|
+
For full signatures, env vars, the complete CLI, and SSR/client/server-primitive details: **/llms-full.txt**.
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# __APP_NAME__
|
|
2
|
+
|
|
3
|
+
A booking site for an appointment business (salon, barber, trainer, clinic,
|
|
4
|
+
trades…) built with [Pylon](https://pylonsync.com) — a server-rendered
|
|
5
|
+
marketing page with **live slot availability** and a private owner dashboard,
|
|
6
|
+
all served from one binary on one port. No Next.js, no separate API server.
|
|
7
|
+
|
|
8
|
+
The realtime point: the time picker shows what's *actually* free, and a slot
|
|
9
|
+
greys out for everyone the instant someone books it.
|
|
10
|
+
|
|
11
|
+
## Develop
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
__RUN_DEV__
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Open http://localhost:4321. Pick a service and a time. Then **open a second
|
|
18
|
+
tab**, book a slot in one, and watch that slot grey out in the other — with no
|
|
19
|
+
refresh.
|
|
20
|
+
|
|
21
|
+
## How the realtime works
|
|
22
|
+
|
|
23
|
+
- `app/booking-widget.tsx` subscribes to the public, PII-free `BookedSlot`
|
|
24
|
+
projection with `db.useQuery`, so taken times grey out live across every tab.
|
|
25
|
+
- `functions/createBooking.ts` is a public **mutation** that re-checks the slot
|
|
26
|
+
is still free (under a per-day advisory lock) before writing — so even a
|
|
27
|
+
dead-heat double-click can't double-book. It writes the `Booking` (with the
|
|
28
|
+
customer's details) and the `BookedSlot` (just the time range).
|
|
29
|
+
- `functions/cancelBooking.ts` deletes the `BookedSlot`, which frees the time
|
|
30
|
+
on every open picker instantly.
|
|
31
|
+
|
|
32
|
+
## Privacy — read this
|
|
33
|
+
|
|
34
|
+
The `Booking` entity holds the customer's name, email, and phone (PII), so its
|
|
35
|
+
policy in `app.ts` **denies every client read and write**. The public page only
|
|
36
|
+
ever reads `BookedSlot` — a name/email-free `{ startsAt, endsAt }` projection.
|
|
37
|
+
The full bookings, with contact details, are returned only by
|
|
38
|
+
`bookingsForOwner`, gated to the owner server-side. A booking site must never
|
|
39
|
+
leak its customers' contact details, and this is how that's guaranteed.
|
|
40
|
+
|
|
41
|
+
## The owner dashboard
|
|
42
|
+
|
|
43
|
+
`/dashboard` shows upcoming bookings grouped by day, with confirm/cancel and the
|
|
44
|
+
customer's contact details — updating live as bookings land and cancel.
|
|
45
|
+
|
|
46
|
+
Set `PYLON_OWNER_EMAIL` in `.env` (see `.env.example`) to the email you'll sign
|
|
47
|
+
in with, then create that account at `/login`. Only that account can see
|
|
48
|
+
bookings.
|
|
49
|
+
|
|
50
|
+
## Rebrand + reconfigure it
|
|
51
|
+
|
|
52
|
+
Everything lives in **`lib/site.config.ts`** — brand, colors, services (name /
|
|
53
|
+
duration / price), weekly hours, slot length, lead time, reviews, location, FAQ.
|
|
54
|
+
Edit that one file (or have a generator produce it) and the whole site — and the
|
|
55
|
+
booking engine — reconfigures. Services and hours are config, not a database to
|
|
56
|
+
manage.
|
|
57
|
+
|
|
58
|
+
## Layout
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
app.ts data model (Booking, BookedSlot, User) + policies
|
|
62
|
+
lib/site.config.ts ALL copy + brand + services + hours (edit this)
|
|
63
|
+
lib/slots.ts pure slot math, shared by picker + server re-check
|
|
64
|
+
lib/booking.ts shared booking-row types
|
|
65
|
+
lib/owner.ts owner-email gate (PYLON_OWNER_EMAIL)
|
|
66
|
+
functions/createBooking.ts public mutation: re-check + book (race-safe)
|
|
67
|
+
functions/bookingsForOwner.ts owner-only query: bookings + customer PII
|
|
68
|
+
functions/{confirm,cancel}Booking.ts owner-only mutations
|
|
69
|
+
app/page.tsx the landing page (server-rendered)
|
|
70
|
+
app/booking-widget.tsx client island: live slot picker + booking form
|
|
71
|
+
app/login/page.tsx owner sign-in
|
|
72
|
+
app/dashboard/ owner dashboard (auth-gated, live)
|
|
73
|
+
app/globals.css Tailwind entrypoint (compiled by Pylon)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Deploy
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
pylon deploy
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Docs: https://docs.pylonsync.com
|