@pylonsync/create-pylon 0.3.274 → 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,115 @@
|
|
|
1
|
+
import {
|
|
2
|
+
entity,
|
|
3
|
+
field,
|
|
4
|
+
policy,
|
|
5
|
+
auth,
|
|
6
|
+
buildManifest,
|
|
7
|
+
discoverAppRoutes,
|
|
8
|
+
} from "@pylonsync/sdk";
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// restaurant — a food & hospitality site with LIVE reservation availability.
|
|
12
|
+
// Same realtime engine as `local-service`, but capacity-based: each seating
|
|
13
|
+
// time has N tables, so the picker shows "4 left" and a time greys out for
|
|
14
|
+
// EVERYONE the instant the last table goes — no refresh. The server re-checks
|
|
15
|
+
// the count at insert time (under a per-slot lock) so two parties can't claim
|
|
16
|
+
// the last table at once.
|
|
17
|
+
//
|
|
18
|
+
// • Reservation — the real booking, with the guest's name/email/phone +
|
|
19
|
+
// party size. Holds PII → denies ALL client reads/writes.
|
|
20
|
+
// • ReservationSlot — a PII-free `{ startsAt }` marker, one per reservation,
|
|
21
|
+
// PUBLIC-READ so the picker can COUNT how many tables are
|
|
22
|
+
// taken per seating and show what's left, live.
|
|
23
|
+
// • User — the owner's account (email/password) for the dashboard.
|
|
24
|
+
//
|
|
25
|
+
// Menu + seating hours + tables-per-seating are CONFIG (lib/site.config.ts).
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
const Reservation = entity(
|
|
29
|
+
"Reservation",
|
|
30
|
+
{
|
|
31
|
+
startsAt: field.datetime(), // the seating time
|
|
32
|
+
partySize: field.int(),
|
|
33
|
+
customerName: field.string(),
|
|
34
|
+
customerEmail: field.string(),
|
|
35
|
+
customerPhone: field.string().optional(),
|
|
36
|
+
notes: field.string().optional(),
|
|
37
|
+
status: field.string().default("pending"), // "pending" | "confirmed" | "cancelled"
|
|
38
|
+
createdAt: field.datetime().defaultNow(),
|
|
39
|
+
},
|
|
40
|
+
{ indexes: [{ name: "by_start", fields: ["startsAt"], unique: false }] },
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
// PII-free occupancy marker. One row per active reservation; the picker counts
|
|
44
|
+
// rows per `startsAt` to know how many tables are taken. Cancelling a
|
|
45
|
+
// reservation deletes its marker, which frees a table live.
|
|
46
|
+
const ReservationSlot = entity(
|
|
47
|
+
"ReservationSlot",
|
|
48
|
+
{
|
|
49
|
+
startsAt: field.datetime(),
|
|
50
|
+
reservationId: field.id("Reservation"),
|
|
51
|
+
},
|
|
52
|
+
{ indexes: [{ name: "by_start", fields: ["startsAt"], unique: false }] },
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
const User = entity(
|
|
56
|
+
"User",
|
|
57
|
+
{
|
|
58
|
+
email: field.string(),
|
|
59
|
+
displayName: field.string().optional(),
|
|
60
|
+
passwordHash: field.string().serverOnly().optional(),
|
|
61
|
+
avatarColor: field.string().optional(),
|
|
62
|
+
emailVerified: field.datetime().optional(),
|
|
63
|
+
createdAt: field.datetime().defaultNow(),
|
|
64
|
+
},
|
|
65
|
+
{ indexes: [{ name: "by_email", fields: ["email"], unique: true }] },
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
// PRIVACY — Reservation holds the guest's contact details + notes, so it denies
|
|
69
|
+
// EVERY client read and write. The public page only ever reads ReservationSlot
|
|
70
|
+
// (a bare time marker), and the full reservations come back only through the
|
|
71
|
+
// owner-gated `reservationsForOwner`.
|
|
72
|
+
const reservationPolicy = policy({
|
|
73
|
+
name: "reservation_private",
|
|
74
|
+
entity: "Reservation",
|
|
75
|
+
allowRead: "false",
|
|
76
|
+
allowInsert: "false",
|
|
77
|
+
allowUpdate: "false",
|
|
78
|
+
allowDelete: "false",
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Occupancy markers are public to READ (just a timestamp — the point is the
|
|
82
|
+
// live "tables left" count). Clients can't WRITE them; only createReservation /
|
|
83
|
+
// cancelReservation maintain them server-side.
|
|
84
|
+
const reservationSlotPolicy = policy({
|
|
85
|
+
name: "reservation_slot_public_read",
|
|
86
|
+
entity: "ReservationSlot",
|
|
87
|
+
allowRead: "true",
|
|
88
|
+
allowInsert: "false",
|
|
89
|
+
allowUpdate: "false",
|
|
90
|
+
allowDelete: "false",
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const userPolicy = policy({
|
|
94
|
+
name: "user_self",
|
|
95
|
+
entity: "User",
|
|
96
|
+
allowRead: "auth.userId == data.id",
|
|
97
|
+
allowInsert: "false",
|
|
98
|
+
allowUpdate: "false",
|
|
99
|
+
allowDelete: "false",
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const manifest = buildManifest({
|
|
103
|
+
name: "__APP_NAME__",
|
|
104
|
+
version: "0.1.0",
|
|
105
|
+
entities: [Reservation, ReservationSlot, User],
|
|
106
|
+
queries: [],
|
|
107
|
+
actions: [],
|
|
108
|
+
policies: [reservationPolicy, reservationSlotPolicy, userPolicy],
|
|
109
|
+
auth: auth(),
|
|
110
|
+
routes: await discoverAppRoutes(),
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
console.log(JSON.stringify(manifest, null, 2));
|
|
114
|
+
|
|
115
|
+
export default manifest;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
// Reusable presentational pieces for the landing page. All server-rendered —
|
|
4
|
+
// no client JS. Restyle here and the whole page follows. The brand accent
|
|
5
|
+
// (`text-brand`, `bg-brand-soft`) comes from CSS vars set on <html> in
|
|
6
|
+
// app/layout.tsx, which read lib/site.config.ts — so re-theming is one edit.
|
|
7
|
+
|
|
8
|
+
// Shared container: a contained, centered column.
|
|
9
|
+
export const WRAP = "mx-auto w-full max-w-5xl px-6";
|
|
10
|
+
|
|
11
|
+
export function Eyebrow({ children }: { children: React.ReactNode }) {
|
|
12
|
+
return (
|
|
13
|
+
<p className="font-mono text-[11px] font-semibold uppercase tracking-[0.14em] text-brand">
|
|
14
|
+
{children}
|
|
15
|
+
</p>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// "New / Coming soon"-style pill for the hero.
|
|
20
|
+
export function Badge({ children }: { children: React.ReactNode }) {
|
|
21
|
+
return (
|
|
22
|
+
<span className="inline-flex items-center gap-2 rounded-full border border-zinc-200 bg-white py-1 pl-1.5 pr-3 text-[13px] text-zinc-600 shadow-sm">
|
|
23
|
+
<span className="inline-block size-1.5 rounded-full bg-brand" />
|
|
24
|
+
{children}
|
|
25
|
+
</span>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function Divider() {
|
|
30
|
+
return (
|
|
31
|
+
<div className={WRAP}>
|
|
32
|
+
<div className="border-t border-zinc-200/70" />
|
|
33
|
+
</div>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function SectionHead({
|
|
38
|
+
eyebrow,
|
|
39
|
+
title,
|
|
40
|
+
body,
|
|
41
|
+
}: {
|
|
42
|
+
eyebrow: string;
|
|
43
|
+
title: string;
|
|
44
|
+
body?: string;
|
|
45
|
+
}) {
|
|
46
|
+
return (
|
|
47
|
+
<div>
|
|
48
|
+
<Eyebrow>{eyebrow}</Eyebrow>
|
|
49
|
+
<h2 className="mt-4 text-balance text-2xl font-semibold leading-[1.15] tracking-[-0.02em] sm:text-3xl">
|
|
50
|
+
{title}
|
|
51
|
+
</h2>
|
|
52
|
+
{body ? (
|
|
53
|
+
<p className="mt-4 max-w-xl text-[15px] leading-relaxed text-zinc-500">
|
|
54
|
+
{body}
|
|
55
|
+
</p>
|
|
56
|
+
) : null}
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// A grid of value props — icon + title + body.
|
|
62
|
+
export function FeatureGrid({
|
|
63
|
+
items,
|
|
64
|
+
}: {
|
|
65
|
+
items: { title: string; body: string; icon?: string }[];
|
|
66
|
+
}) {
|
|
67
|
+
return (
|
|
68
|
+
<div className="grid gap-6 sm:grid-cols-3">
|
|
69
|
+
{items.map((f) => (
|
|
70
|
+
<div key={f.title}>
|
|
71
|
+
{f.icon ? (
|
|
72
|
+
<span className="flex size-9 items-center justify-center rounded-lg bg-brand-soft text-brand">
|
|
73
|
+
{f.icon}
|
|
74
|
+
</span>
|
|
75
|
+
) : null}
|
|
76
|
+
<h3 className="mt-4 text-[15px] font-semibold text-zinc-900">
|
|
77
|
+
{f.title}
|
|
78
|
+
</h3>
|
|
79
|
+
<p className="mt-2 text-[14px] leading-relaxed text-zinc-500">
|
|
80
|
+
{f.body}
|
|
81
|
+
</p>
|
|
82
|
+
</div>
|
|
83
|
+
))}
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Initials for testimonial avatars, so the cards look finished without a photo.
|
|
89
|
+
export function initials(name: string) {
|
|
90
|
+
return name
|
|
91
|
+
.split(/\s+/)
|
|
92
|
+
.map((w) => w[0])
|
|
93
|
+
.join("")
|
|
94
|
+
.slice(0, 2)
|
|
95
|
+
.toUpperCase();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// A deliberately-obvious image placeholder. Real sites drop a photo here; this
|
|
99
|
+
// makes the spot unmistakable — dashed border, a photo glyph, and a one-line
|
|
100
|
+
// "swap this" instruction telling you exactly what to replace and where. Looks
|
|
101
|
+
// tidy enough to demo, but no one will mistake it for a finished design.
|
|
102
|
+
//
|
|
103
|
+
// shape — "landscape" | "portrait" | "square" | "circle"
|
|
104
|
+
// title — what photo belongs here ("A photo of your dining room")
|
|
105
|
+
// hint — how to replace it ("Swap for an <img> in app/page.tsx")
|
|
106
|
+
export function ImagePlaceholder({
|
|
107
|
+
shape = "landscape",
|
|
108
|
+
title,
|
|
109
|
+
hint,
|
|
110
|
+
className = "",
|
|
111
|
+
}: {
|
|
112
|
+
shape?: "landscape" | "portrait" | "square" | "circle";
|
|
113
|
+
title: string;
|
|
114
|
+
hint?: string;
|
|
115
|
+
className?: string;
|
|
116
|
+
}) {
|
|
117
|
+
const aspect =
|
|
118
|
+
shape === "portrait"
|
|
119
|
+
? "aspect-[4/5]"
|
|
120
|
+
: shape === "square" || shape === "circle"
|
|
121
|
+
? "aspect-square"
|
|
122
|
+
: "aspect-[4/3]";
|
|
123
|
+
const radius = shape === "circle" ? "rounded-full" : "rounded-2xl";
|
|
124
|
+
return (
|
|
125
|
+
<div
|
|
126
|
+
className={`relative grid place-items-center overflow-hidden border-2 border-dashed border-zinc-300 bg-zinc-50 ${aspect} ${radius} ${className}`}
|
|
127
|
+
>
|
|
128
|
+
<div className="px-6 text-center">
|
|
129
|
+
<svg
|
|
130
|
+
className="mx-auto size-7 text-zinc-300"
|
|
131
|
+
viewBox="0 0 24 24"
|
|
132
|
+
fill="none"
|
|
133
|
+
stroke="currentColor"
|
|
134
|
+
strokeWidth="1.5"
|
|
135
|
+
strokeLinecap="round"
|
|
136
|
+
strokeLinejoin="round"
|
|
137
|
+
aria-hidden
|
|
138
|
+
>
|
|
139
|
+
<rect x="3" y="3" width="18" height="18" rx="2" />
|
|
140
|
+
<circle cx="9" cy="9" r="1.6" />
|
|
141
|
+
<path d="m21 15-4.5-4.5L7 20" />
|
|
142
|
+
</svg>
|
|
143
|
+
<p className="mt-3 text-[13px] font-medium text-zinc-500">{title}</p>
|
|
144
|
+
{hint ? <p className="mt-1 text-[11.5px] leading-snug text-zinc-400">{hint}</p> : null}
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// A small "live" pill — float it over a hero image to keep the realtime hook
|
|
151
|
+
// visible (e.g. "Tables update live"). Pure decoration; no client JS.
|
|
152
|
+
export function LiveBadge({ children }: { children: React.ReactNode }) {
|
|
153
|
+
return (
|
|
154
|
+
<span className="inline-flex items-center gap-2 rounded-full border border-zinc-200 bg-white/95 px-3 py-1.5 text-[12.5px] font-medium text-zinc-700 shadow-sm backdrop-blur">
|
|
155
|
+
<span className="relative flex size-2">
|
|
156
|
+
<span className="absolute inline-flex size-2 animate-ping rounded-full bg-green-500/60" />
|
|
157
|
+
<span className="relative inline-flex size-2 rounded-full bg-green-600" />
|
|
158
|
+
</span>
|
|
159
|
+
{children}
|
|
160
|
+
</span>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect } from "react";
|
|
4
|
+
|
|
5
|
+
// Makes in-page section links work. A hydrated Pylon page updates the URL for a
|
|
6
|
+
// plain `<a href="#section">` click but doesn't perform the browser's native
|
|
7
|
+
// fragment scroll, so the page jumps nowhere. This installs ONE delegated click
|
|
8
|
+
// handler that catches any same-page `#`/`/#` anchor and scrolls to it smoothly.
|
|
9
|
+
//
|
|
10
|
+
// Render it once (in the root layout). Renders nothing. Real route links should
|
|
11
|
+
// still use `<Link>` from @pylonsync/react — this only handles `#` anchors.
|
|
12
|
+
export function SectionScroller() {
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
function onClick(e: MouseEvent) {
|
|
15
|
+
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const target = e.target as Element | null;
|
|
19
|
+
const link = target?.closest?.('a[href^="#"], a[href^="/#"]') as HTMLAnchorElement | null;
|
|
20
|
+
if (!link) return;
|
|
21
|
+
const href = link.getAttribute("href") || "";
|
|
22
|
+
const id = href.slice(href.indexOf("#") + 1);
|
|
23
|
+
if (!id) return;
|
|
24
|
+
const el = document.getElementById(id);
|
|
25
|
+
if (!el) return; // target not on this page — leave it to the browser
|
|
26
|
+
e.preventDefault();
|
|
27
|
+
el.scrollIntoView({ block: "start" });
|
|
28
|
+
history.replaceState(null, "", "#" + id);
|
|
29
|
+
}
|
|
30
|
+
document.addEventListener("click", onClick);
|
|
31
|
+
return () => document.removeEventListener("click", onClick);
|
|
32
|
+
}, []);
|
|
33
|
+
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { Slot } from "@radix-ui/react-slot";
|
|
3
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
4
|
+
|
|
5
|
+
import { cn } from "@/lib/utils";
|
|
6
|
+
|
|
7
|
+
const buttonVariants = cva(
|
|
8
|
+
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
|
9
|
+
{
|
|
10
|
+
variants: {
|
|
11
|
+
variant: {
|
|
12
|
+
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
|
13
|
+
destructive:
|
|
14
|
+
"bg-destructive text-white hover:bg-destructive/90",
|
|
15
|
+
outline:
|
|
16
|
+
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
|
17
|
+
secondary:
|
|
18
|
+
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
|
19
|
+
ghost: "hover:bg-accent hover:text-accent-foreground",
|
|
20
|
+
link: "text-primary underline-offset-4 hover:underline",
|
|
21
|
+
},
|
|
22
|
+
size: {
|
|
23
|
+
default: "h-9 px-4 py-2",
|
|
24
|
+
sm: "h-8 rounded-md px-3 text-xs",
|
|
25
|
+
lg: "h-10 rounded-md px-8",
|
|
26
|
+
icon: "h-9 w-9",
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
defaultVariants: {
|
|
30
|
+
variant: "default",
|
|
31
|
+
size: "default",
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
export interface ButtonProps
|
|
37
|
+
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
|
38
|
+
VariantProps<typeof buttonVariants> {
|
|
39
|
+
asChild?: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
43
|
+
({ className, variant, size, asChild = false, ...props }, ref) => {
|
|
44
|
+
const Comp = asChild ? Slot : "button";
|
|
45
|
+
return (
|
|
46
|
+
<Comp
|
|
47
|
+
className={cn(buttonVariants({ variant, size, className }))}
|
|
48
|
+
ref={ref}
|
|
49
|
+
{...props}
|
|
50
|
+
/>
|
|
51
|
+
);
|
|
52
|
+
},
|
|
53
|
+
);
|
|
54
|
+
Button.displayName = "Button";
|
|
55
|
+
|
|
56
|
+
export { Button, buttonVariants };
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
import { cn } from "@/lib/utils";
|
|
4
|
+
|
|
5
|
+
function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
|
6
|
+
return (
|
|
7
|
+
<div
|
|
8
|
+
data-slot="card"
|
|
9
|
+
className={cn(
|
|
10
|
+
"rounded-xl border bg-card text-card-foreground shadow-sm",
|
|
11
|
+
className,
|
|
12
|
+
)}
|
|
13
|
+
{...props}
|
|
14
|
+
/>
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function CardHeader({
|
|
19
|
+
className,
|
|
20
|
+
...props
|
|
21
|
+
}: React.HTMLAttributes<HTMLDivElement>) {
|
|
22
|
+
return (
|
|
23
|
+
<div
|
|
24
|
+
data-slot="card-header"
|
|
25
|
+
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
|
26
|
+
{...props}
|
|
27
|
+
/>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function CardTitle({
|
|
32
|
+
className,
|
|
33
|
+
...props
|
|
34
|
+
}: React.HTMLAttributes<HTMLDivElement>) {
|
|
35
|
+
return (
|
|
36
|
+
<div
|
|
37
|
+
data-slot="card-title"
|
|
38
|
+
className={cn("font-semibold leading-none tracking-tight", className)}
|
|
39
|
+
{...props}
|
|
40
|
+
/>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function CardDescription({
|
|
45
|
+
className,
|
|
46
|
+
...props
|
|
47
|
+
}: React.HTMLAttributes<HTMLDivElement>) {
|
|
48
|
+
return (
|
|
49
|
+
<div
|
|
50
|
+
data-slot="card-description"
|
|
51
|
+
className={cn("text-sm text-muted-foreground", className)}
|
|
52
|
+
{...props}
|
|
53
|
+
/>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function CardContent({
|
|
58
|
+
className,
|
|
59
|
+
...props
|
|
60
|
+
}: React.HTMLAttributes<HTMLDivElement>) {
|
|
61
|
+
return (
|
|
62
|
+
<div
|
|
63
|
+
data-slot="card-content"
|
|
64
|
+
className={cn("p-6 pt-0", className)}
|
|
65
|
+
{...props}
|
|
66
|
+
/>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function CardFooter({
|
|
71
|
+
className,
|
|
72
|
+
...props
|
|
73
|
+
}: React.HTMLAttributes<HTMLDivElement>) {
|
|
74
|
+
return (
|
|
75
|
+
<div
|
|
76
|
+
data-slot="card-footer"
|
|
77
|
+
className={cn("flex items-center p-6 pt-0", className)}
|
|
78
|
+
{...props}
|
|
79
|
+
/>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export {
|
|
84
|
+
Card,
|
|
85
|
+
CardHeader,
|
|
86
|
+
CardFooter,
|
|
87
|
+
CardTitle,
|
|
88
|
+
CardDescription,
|
|
89
|
+
CardContent,
|
|
90
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema.json",
|
|
3
|
+
"style": "new-york",
|
|
4
|
+
"rsc": false,
|
|
5
|
+
"tsx": true,
|
|
6
|
+
"tailwind": {
|
|
7
|
+
"config": "",
|
|
8
|
+
"css": "app/globals.css",
|
|
9
|
+
"baseColor": "zinc",
|
|
10
|
+
"cssVariables": true
|
|
11
|
+
},
|
|
12
|
+
"aliases": {
|
|
13
|
+
"components": "@/components",
|
|
14
|
+
"utils": "@/lib/utils",
|
|
15
|
+
"ui": "@/components/ui",
|
|
16
|
+
"lib": "@/lib",
|
|
17
|
+
"hooks": "@/hooks"
|
|
18
|
+
},
|
|
19
|
+
"iconLibrary": "lucide"
|
|
20
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { mutation, v } from "@pylonsync/functions";
|
|
2
|
+
import { emailMatchesOwner } from "../lib/owner";
|
|
3
|
+
|
|
4
|
+
// cancelReservation — owner-only. Marks the reservation cancelled AND deletes
|
|
5
|
+
// its ReservationSlot marker, which FREES a table at that seating: the deletion
|
|
6
|
+
// syncs to every open picker, so "tables left" ticks back up live.
|
|
7
|
+
export default mutation<{ reservationId: string }, { ok: boolean }>({
|
|
8
|
+
auth: "user",
|
|
9
|
+
args: { reservationId: v.id("Reservation") },
|
|
10
|
+
async handler(ctx, args) {
|
|
11
|
+
const me = await ctx.db.get("User", ctx.auth.userId);
|
|
12
|
+
if (!emailMatchesOwner(me?.email as string | undefined, ctx.env.PYLON_OWNER_EMAIL)) {
|
|
13
|
+
throw ctx.error("POLICY_DENIED", "Only the owner can manage reservations.");
|
|
14
|
+
}
|
|
15
|
+
await ctx.db.unsafe.update("Reservation", args.reservationId, { status: "cancelled" });
|
|
16
|
+
|
|
17
|
+
const markers = (await ctx.db.unsafe.list("ReservationSlot")) as unknown as {
|
|
18
|
+
id: string;
|
|
19
|
+
reservationId: string;
|
|
20
|
+
}[];
|
|
21
|
+
const marker = markers.find((m) => m.reservationId === args.reservationId);
|
|
22
|
+
if (marker) await ctx.db.unsafe.delete("ReservationSlot", marker.id);
|
|
23
|
+
|
|
24
|
+
return { ok: true };
|
|
25
|
+
},
|
|
26
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { mutation, v } from "@pylonsync/functions";
|
|
2
|
+
import { emailMatchesOwner } from "../lib/owner";
|
|
3
|
+
|
|
4
|
+
// confirmReservation — owner-only. Marks a pending reservation confirmed; the
|
|
5
|
+
// ReservationSlot marker stays, so the table remains held.
|
|
6
|
+
export default mutation<{ reservationId: string }, { ok: boolean }>({
|
|
7
|
+
auth: "user",
|
|
8
|
+
args: { reservationId: v.id("Reservation") },
|
|
9
|
+
async handler(ctx, args) {
|
|
10
|
+
const me = await ctx.db.get("User", ctx.auth.userId);
|
|
11
|
+
if (!emailMatchesOwner(me?.email as string | undefined, ctx.env.PYLON_OWNER_EMAIL)) {
|
|
12
|
+
throw ctx.error("POLICY_DENIED", "Only the owner can manage reservations.");
|
|
13
|
+
}
|
|
14
|
+
await ctx.db.unsafe.update("Reservation", args.reservationId, { status: "confirmed" });
|
|
15
|
+
return { ok: true };
|
|
16
|
+
},
|
|
17
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { mutation, v } from "@pylonsync/functions";
|
|
2
|
+
import { siteConfig } from "../lib/site.config";
|
|
3
|
+
|
|
4
|
+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
5
|
+
|
|
6
|
+
// createReservation — the ONLY writer of Reservation + its ReservationSlot
|
|
7
|
+
// marker. A transactional `mutation` (atomic + `ctx.db`); the marker insert
|
|
8
|
+
// fires a change event so every open picker recomputes "tables left" live.
|
|
9
|
+
//
|
|
10
|
+
// `auth: "public"` — a guest has no account. The server RE-CHECKS that the
|
|
11
|
+
// seating is still under capacity before inserting, under a per-seating advisory
|
|
12
|
+
// lock so two parties can't both grab the last table. Capacity is per seating
|
|
13
|
+
// time: count the markers sharing this `startsAt`, compare to tablesPerSlot.
|
|
14
|
+
//
|
|
15
|
+
// PRIVACY: returns only `{ ok, reason? }` — never a reservation row or any
|
|
16
|
+
// guest's contact details.
|
|
17
|
+
export default mutation<
|
|
18
|
+
{
|
|
19
|
+
startsAt: string;
|
|
20
|
+
partySize: number;
|
|
21
|
+
customerName: string;
|
|
22
|
+
customerEmail: string;
|
|
23
|
+
customerPhone?: string;
|
|
24
|
+
notes?: string;
|
|
25
|
+
},
|
|
26
|
+
{ ok: boolean; reason?: "past" | "full" | "party" | "invalid" }
|
|
27
|
+
>({
|
|
28
|
+
auth: "public",
|
|
29
|
+
args: {
|
|
30
|
+
startsAt: v.string(),
|
|
31
|
+
partySize: v.int(),
|
|
32
|
+
customerName: v.string(),
|
|
33
|
+
customerEmail: v.string(),
|
|
34
|
+
customerPhone: v.optional(v.string()),
|
|
35
|
+
notes: v.optional(v.string()),
|
|
36
|
+
},
|
|
37
|
+
async handler(ctx, args) {
|
|
38
|
+
const cfg = siteConfig.reservations;
|
|
39
|
+
const name = args.customerName.trim();
|
|
40
|
+
const email = args.customerEmail.trim().toLowerCase();
|
|
41
|
+
const phone = args.customerPhone?.trim() || null;
|
|
42
|
+
const notes = args.notes?.trim() || null;
|
|
43
|
+
|
|
44
|
+
if (name.length < 1 || name.length > 120) {
|
|
45
|
+
throw ctx.error("INVALID_ARGS", "Enter your name.");
|
|
46
|
+
}
|
|
47
|
+
if (!EMAIL_RE.test(email) || email.length > 254) {
|
|
48
|
+
throw ctx.error("INVALID_ARGS", "Enter a valid email address.");
|
|
49
|
+
}
|
|
50
|
+
if (!Number.isInteger(args.partySize) || args.partySize < 1) {
|
|
51
|
+
return { ok: false, reason: "party" };
|
|
52
|
+
}
|
|
53
|
+
if (args.partySize > cfg.maxPartySize) {
|
|
54
|
+
return { ok: false, reason: "party" };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const startMs = Date.parse(args.startsAt);
|
|
58
|
+
if (Number.isNaN(startMs)) return { ok: false, reason: "invalid" };
|
|
59
|
+
const startsAt = new Date(startMs).toISOString();
|
|
60
|
+
if (startMs < Date.now() + cfg.leadTimeHours * 3_600_000) {
|
|
61
|
+
return { ok: false, reason: "past" };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Serialize the count-then-insert for THIS seating so two parties can't
|
|
65
|
+
// both claim the last table. Held until the tx commits.
|
|
66
|
+
await ctx.db.advisoryLock(`reservation_slot:${startsAt}`);
|
|
67
|
+
|
|
68
|
+
// Capacity check: how many tables are already taken at this seating?
|
|
69
|
+
// Cross-user read of the deny-all-projection → `unsafe`.
|
|
70
|
+
const markers = (await ctx.db.unsafe.list("ReservationSlot")) as unknown as {
|
|
71
|
+
startsAt: string;
|
|
72
|
+
}[];
|
|
73
|
+
const taken = markers.filter((m) => m.startsAt === startsAt).length;
|
|
74
|
+
if (taken >= cfg.tablesPerSlot) {
|
|
75
|
+
return { ok: false, reason: "full" };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const reservationId = await ctx.db.unsafe.insert("Reservation", {
|
|
79
|
+
startsAt,
|
|
80
|
+
partySize: args.partySize,
|
|
81
|
+
customerName: name,
|
|
82
|
+
customerEmail: email,
|
|
83
|
+
customerPhone: phone,
|
|
84
|
+
notes,
|
|
85
|
+
status: "pending",
|
|
86
|
+
createdAt: new Date().toISOString(),
|
|
87
|
+
});
|
|
88
|
+
await ctx.db.unsafe.insert("ReservationSlot", { startsAt, reservationId });
|
|
89
|
+
|
|
90
|
+
return { ok: true };
|
|
91
|
+
},
|
|
92
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { query } from "@pylonsync/functions";
|
|
2
|
+
import { emailMatchesOwner } from "../lib/owner";
|
|
3
|
+
import type { ReservationRow, OwnerReservationsResult } from "../lib/reservation";
|
|
4
|
+
|
|
5
|
+
// reservationsForOwner — the owner's view of every reservation, INCLUDING the
|
|
6
|
+
// guest's name/email/phone + notes. The one function allowed to return that
|
|
7
|
+
// PII, gated to the configured owner (PYLON_OWNER_EMAIL via ctx.env).
|
|
8
|
+
//
|
|
9
|
+
// The dashboard calls it with `callFn` and re-fetches whenever the live, public
|
|
10
|
+
// ReservationSlot set changes — so new reservations + cancellations show up
|
|
11
|
+
// without a refresh, while contact details never travel over entity sync.
|
|
12
|
+
export default query({
|
|
13
|
+
auth: "user",
|
|
14
|
+
async handler(ctx): Promise<OwnerReservationsResult> {
|
|
15
|
+
const me = await ctx.db.get("User", ctx.auth.userId);
|
|
16
|
+
const email = (me?.email as string | undefined) ?? null;
|
|
17
|
+
if (!emailMatchesOwner(email, ctx.env.PYLON_OWNER_EMAIL)) {
|
|
18
|
+
return { authorized: false };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const rows = (await ctx.db.unsafe.list("Reservation")) as unknown as ReservationRow[];
|
|
22
|
+
const reservations = rows
|
|
23
|
+
.map((r) => ({ ...r }))
|
|
24
|
+
.sort((a, b) => (a.startsAt < b.startsAt ? -1 : a.startsAt > b.startsAt ? 1 : 0));
|
|
25
|
+
|
|
26
|
+
return { authorized: true, reservations };
|
|
27
|
+
},
|
|
28
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Who owns this waitlist? A waitlist is single-tenant — one business, one
|
|
2
|
+
// owner — so ownership is just "the email the owner signs in with", configured
|
|
3
|
+
// once via the PYLON_OWNER_EMAIL env var. The owner-only `waitlistStats`
|
|
4
|
+
// function reads that env (via `ctx.env`) and compares it here.
|
|
5
|
+
//
|
|
6
|
+
// Fail closed: if PYLON_OWNER_EMAIL is unset, NOBODY is the owner and the
|
|
7
|
+
// dashboard stays locked. That's deliberate — an unset owner on a public site
|
|
8
|
+
// must not mean "everyone can read the signups". Set it in .env (see
|
|
9
|
+
// .env.example) before signing in.
|
|
10
|
+
|
|
11
|
+
export function normalizeOwner(raw: string | null | undefined): string | null {
|
|
12
|
+
const v = raw?.trim().toLowerCase();
|
|
13
|
+
return v && v.length > 0 ? v : null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Pure comparator — the caller supplies the configured owner value (from
|
|
17
|
+
// `ctx.env.PYLON_OWNER_EMAIL`), so the rule lives in one place and stays
|
|
18
|
+
// testable without reaching for the environment here.
|
|
19
|
+
export function emailMatchesOwner(
|
|
20
|
+
email: string | null | undefined,
|
|
21
|
+
ownerRaw: string | null | undefined,
|
|
22
|
+
): boolean {
|
|
23
|
+
const owner = normalizeOwner(ownerRaw);
|
|
24
|
+
if (!owner) return false;
|
|
25
|
+
return (email ?? "").trim().toLowerCase() === owner;
|
|
26
|
+
}
|