@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.
Files changed (323) hide show
  1. package/bin/create-pylon.js +80 -0
  2. package/package.json +1 -1
  3. package/templates/ARCHETYPES.md +339 -0
  4. package/templates/agency/.env.example +12 -0
  5. package/templates/agency/AGENTS.md +61 -0
  6. package/templates/agency/README.md +90 -0
  7. package/templates/agency/app/auth-form.tsx +129 -0
  8. package/templates/agency/app/contact-form.tsx +258 -0
  9. package/templates/agency/app/dashboard/dashboard-client.tsx +286 -0
  10. package/templates/agency/app/dashboard/page.tsx +70 -0
  11. package/templates/agency/app/error.tsx +26 -0
  12. package/templates/agency/app/globals.css +148 -0
  13. package/templates/agency/app/layout.tsx +174 -0
  14. package/templates/agency/app/login/page.tsx +39 -0
  15. package/templates/agency/app/not-found.tsx +19 -0
  16. package/templates/agency/app/page.tsx +207 -0
  17. package/templates/agency/app/robots.ts +12 -0
  18. package/templates/agency/app/sitemap.ts +9 -0
  19. package/templates/agency/app.ts +135 -0
  20. package/templates/agency/components/marketing.tsx +148 -0
  21. package/templates/agency/components/section-scroller.tsx +35 -0
  22. package/templates/agency/components/ui/button.tsx +56 -0
  23. package/templates/agency/components/ui/card.tsx +90 -0
  24. package/templates/agency/components.json +20 -0
  25. package/templates/agency/functions/bookInquiry.ts +42 -0
  26. package/templates/agency/functions/declineInquiry.ts +41 -0
  27. package/templates/agency/functions/inquiriesForOwner.ts +31 -0
  28. package/templates/agency/functions/seedCapacity.ts +26 -0
  29. package/templates/agency/functions/setCapacity.ts +32 -0
  30. package/templates/agency/functions/submitInquiry.ts +55 -0
  31. package/templates/agency/gitignore +10 -0
  32. package/templates/agency/lib/agency.ts +27 -0
  33. package/templates/agency/lib/owner.ts +26 -0
  34. package/templates/agency/lib/site.config.ts +239 -0
  35. package/templates/agency/lib/utils.ts +10 -0
  36. package/templates/agency/package.json +34 -0
  37. package/templates/agency/tsconfig.json +18 -0
  38. package/templates/ai-chat/.env.example +33 -0
  39. package/templates/ai-chat/AGENTS.md +61 -0
  40. package/templates/ai-chat/README.md +99 -0
  41. package/templates/ai-chat/app/auth-form.tsx +124 -0
  42. package/templates/ai-chat/app/chat-client.tsx +414 -0
  43. package/templates/ai-chat/app/error.tsx +26 -0
  44. package/templates/ai-chat/app/globals.css +148 -0
  45. package/templates/ai-chat/app/layout.tsx +75 -0
  46. package/templates/ai-chat/app/login/page.tsx +39 -0
  47. package/templates/ai-chat/app/not-found.tsx +19 -0
  48. package/templates/ai-chat/app/page.tsx +23 -0
  49. package/templates/ai-chat/app.ts +121 -0
  50. package/templates/ai-chat/components.json +20 -0
  51. package/templates/ai-chat/gitignore +10 -0
  52. package/templates/ai-chat/lib/site.config.ts +103 -0
  53. package/templates/ai-chat/lib/utils.ts +10 -0
  54. package/templates/ai-chat/package.json +34 -0
  55. package/templates/ai-chat/tsconfig.json +18 -0
  56. package/templates/ai-studio/.env.example +19 -0
  57. package/templates/ai-studio/AGENTS.md +61 -0
  58. package/templates/ai-studio/README.md +83 -0
  59. package/templates/ai-studio/app/auth-form.tsx +124 -0
  60. package/templates/ai-studio/app/error.tsx +26 -0
  61. package/templates/ai-studio/app/globals.css +148 -0
  62. package/templates/ai-studio/app/layout.tsx +75 -0
  63. package/templates/ai-studio/app/login/page.tsx +39 -0
  64. package/templates/ai-studio/app/not-found.tsx +19 -0
  65. package/templates/ai-studio/app/page.tsx +34 -0
  66. package/templates/ai-studio/app/studio-client.tsx +214 -0
  67. package/templates/ai-studio/app.ts +108 -0
  68. package/templates/ai-studio/components.json +20 -0
  69. package/templates/ai-studio/functions/_getGeneration.ts +25 -0
  70. package/templates/ai-studio/functions/_updateGeneration.ts +37 -0
  71. package/templates/ai-studio/functions/generate.ts +42 -0
  72. package/templates/ai-studio/functions/pollGeneration.ts +134 -0
  73. package/templates/ai-studio/gitignore +10 -0
  74. package/templates/ai-studio/lib/site.config.ts +80 -0
  75. package/templates/ai-studio/lib/studio.ts +52 -0
  76. package/templates/ai-studio/lib/utils.ts +10 -0
  77. package/templates/ai-studio/package.json +34 -0
  78. package/templates/ai-studio/tsconfig.json +18 -0
  79. package/templates/creator/.env.example +12 -0
  80. package/templates/creator/AGENTS.md +61 -0
  81. package/templates/creator/README.md +67 -0
  82. package/templates/creator/app/auth-form.tsx +129 -0
  83. package/templates/creator/app/dashboard/dashboard-client.tsx +297 -0
  84. package/templates/creator/app/dashboard/page.tsx +70 -0
  85. package/templates/creator/app/error.tsx +26 -0
  86. package/templates/creator/app/globals.css +148 -0
  87. package/templates/creator/app/layout.tsx +160 -0
  88. package/templates/creator/app/login/page.tsx +39 -0
  89. package/templates/creator/app/newsletter-signup.tsx +162 -0
  90. package/templates/creator/app/not-found.tsx +19 -0
  91. package/templates/creator/app/page.tsx +160 -0
  92. package/templates/creator/app/robots.ts +12 -0
  93. package/templates/creator/app/sitemap.ts +9 -0
  94. package/templates/creator/app.ts +134 -0
  95. package/templates/creator/components/marketing.tsx +148 -0
  96. package/templates/creator/components/section-scroller.tsx +35 -0
  97. package/templates/creator/components/ui/button.tsx +56 -0
  98. package/templates/creator/components/ui/card.tsx +90 -0
  99. package/templates/creator/components.json +20 -0
  100. package/templates/creator/functions/subscribe.ts +82 -0
  101. package/templates/creator/functions/subscriberStats.ts +75 -0
  102. package/templates/creator/gitignore +10 -0
  103. package/templates/creator/lib/owner.ts +26 -0
  104. package/templates/creator/lib/site.config.ts +173 -0
  105. package/templates/creator/lib/stats.ts +30 -0
  106. package/templates/creator/lib/utils.ts +10 -0
  107. package/templates/creator/package.json +34 -0
  108. package/templates/creator/tsconfig.json +18 -0
  109. package/templates/default/app/layout.tsx +26 -27
  110. package/templates/default/app/page.tsx +90 -274
  111. package/templates/default/lib/products.ts +9 -122
  112. package/templates/default/lib/site.config.ts +739 -0
  113. package/templates/default/lib/site.ts +14 -261
  114. package/templates/directory/.env.example +12 -0
  115. package/templates/directory/AGENTS.md +61 -0
  116. package/templates/directory/README.md +80 -0
  117. package/templates/directory/app/auth-form.tsx +129 -0
  118. package/templates/directory/app/dashboard/dashboard-client.tsx +205 -0
  119. package/templates/directory/app/dashboard/page.tsx +70 -0
  120. package/templates/directory/app/directory-browse.tsx +328 -0
  121. package/templates/directory/app/error.tsx +26 -0
  122. package/templates/directory/app/globals.css +148 -0
  123. package/templates/directory/app/layout.tsx +171 -0
  124. package/templates/directory/app/login/page.tsx +39 -0
  125. package/templates/directory/app/not-found.tsx +19 -0
  126. package/templates/directory/app/page.tsx +50 -0
  127. package/templates/directory/app/robots.ts +12 -0
  128. package/templates/directory/app/sitemap.ts +9 -0
  129. package/templates/directory/app/submit/page.tsx +30 -0
  130. package/templates/directory/app/submit-form.tsx +151 -0
  131. package/templates/directory/app.ts +146 -0
  132. package/templates/directory/components/marketing.tsx +148 -0
  133. package/templates/directory/components/section-scroller.tsx +35 -0
  134. package/templates/directory/components/ui/button.tsx +56 -0
  135. package/templates/directory/components/ui/card.tsx +90 -0
  136. package/templates/directory/components.json +20 -0
  137. package/templates/directory/functions/approveSubmission.ts +45 -0
  138. package/templates/directory/functions/rejectSubmission.ts +20 -0
  139. package/templates/directory/functions/seedListings.ts +33 -0
  140. package/templates/directory/functions/submissionsForOwner.ts +29 -0
  141. package/templates/directory/functions/submitListing.ts +63 -0
  142. package/templates/directory/functions/upvote.ts +24 -0
  143. package/templates/directory/gitignore +10 -0
  144. package/templates/directory/lib/directory.ts +45 -0
  145. package/templates/directory/lib/owner.ts +26 -0
  146. package/templates/directory/lib/site.config.ts +130 -0
  147. package/templates/directory/lib/utils.ts +10 -0
  148. package/templates/directory/package.json +34 -0
  149. package/templates/directory/tsconfig.json +18 -0
  150. package/templates/local-service/.env.example +12 -0
  151. package/templates/local-service/AGENTS.md +61 -0
  152. package/templates/local-service/README.md +82 -0
  153. package/templates/local-service/app/auth-form.tsx +129 -0
  154. package/templates/local-service/app/booking-widget.tsx +399 -0
  155. package/templates/local-service/app/dashboard/dashboard-client.tsx +304 -0
  156. package/templates/local-service/app/dashboard/page.tsx +63 -0
  157. package/templates/local-service/app/error.tsx +26 -0
  158. package/templates/local-service/app/globals.css +148 -0
  159. package/templates/local-service/app/layout.tsx +151 -0
  160. package/templates/local-service/app/login/page.tsx +39 -0
  161. package/templates/local-service/app/not-found.tsx +19 -0
  162. package/templates/local-service/app/page.tsx +233 -0
  163. package/templates/local-service/app/robots.ts +12 -0
  164. package/templates/local-service/app/sitemap.ts +9 -0
  165. package/templates/local-service/app.ts +131 -0
  166. package/templates/local-service/components/marketing.tsx +162 -0
  167. package/templates/local-service/components/section-scroller.tsx +35 -0
  168. package/templates/local-service/components/ui/button.tsx +56 -0
  169. package/templates/local-service/components/ui/card.tsx +90 -0
  170. package/templates/local-service/components.json +20 -0
  171. package/templates/local-service/functions/bookingsForOwner.ts +30 -0
  172. package/templates/local-service/functions/cancelBooking.ts +27 -0
  173. package/templates/local-service/functions/confirmBooking.ts +18 -0
  174. package/templates/local-service/functions/createBooking.ts +98 -0
  175. package/templates/local-service/gitignore +10 -0
  176. package/templates/local-service/lib/booking.ts +24 -0
  177. package/templates/local-service/lib/owner.ts +26 -0
  178. package/templates/local-service/lib/site.config.ts +232 -0
  179. package/templates/local-service/lib/slots.ts +97 -0
  180. package/templates/local-service/lib/utils.ts +10 -0
  181. package/templates/local-service/package.json +34 -0
  182. package/templates/local-service/tsconfig.json +18 -0
  183. package/templates/marketplace/.env.example +9 -0
  184. package/templates/marketplace/AGENTS.md +61 -0
  185. package/templates/marketplace/README.md +78 -0
  186. package/templates/marketplace/app/_components/CategoryIcon.tsx +40 -0
  187. package/templates/marketplace/app/error.tsx +26 -0
  188. package/templates/marketplace/app/globals.css +64 -0
  189. package/templates/marketplace/app/layout.tsx +60 -0
  190. package/templates/marketplace/app/listing/[id]/page.tsx +163 -0
  191. package/templates/marketplace/app/me/page.tsx +15 -0
  192. package/templates/marketplace/app/not-found.tsx +20 -0
  193. package/templates/marketplace/app/page.tsx +159 -0
  194. package/templates/marketplace/app/robots.ts +12 -0
  195. package/templates/marketplace/app/sell/page.tsx +26 -0
  196. package/templates/marketplace/app/sitemap.ts +14 -0
  197. package/templates/marketplace/app.ts +190 -0
  198. package/templates/marketplace/client/AuthNav.tsx +46 -0
  199. package/templates/marketplace/client/LiveTicker.tsx +104 -0
  200. package/templates/marketplace/client/LoginCard.tsx +130 -0
  201. package/templates/marketplace/client/MarketProvider.tsx +148 -0
  202. package/templates/marketplace/client/MyMarket.tsx +180 -0
  203. package/templates/marketplace/client/OfferPanel.tsx +355 -0
  204. package/templates/marketplace/client/SeedOnEmpty.tsx +26 -0
  205. package/templates/marketplace/client/SellForm.tsx +160 -0
  206. package/templates/marketplace/client/WatchButton.tsx +88 -0
  207. package/templates/marketplace/client/market.ts +341 -0
  208. package/templates/marketplace/functions/buyNow.ts +78 -0
  209. package/templates/marketplace/functions/makeOffer.ts +65 -0
  210. package/templates/marketplace/functions/respondToOffer.ts +62 -0
  211. package/templates/marketplace/functions/seedMarket.ts +90 -0
  212. package/templates/marketplace/gitignore +10 -0
  213. package/templates/marketplace/package.json +35 -0
  214. package/templates/marketplace/tsconfig.json +14 -0
  215. package/templates/marketplace/ui/badge.tsx +30 -0
  216. package/templates/marketplace/ui/button.tsx +49 -0
  217. package/templates/marketplace/ui/card.tsx +48 -0
  218. package/templates/marketplace/ui/input.tsx +17 -0
  219. package/templates/marketplace/ui/label.tsx +18 -0
  220. package/templates/marketplace/ui/textarea.tsx +17 -0
  221. package/templates/marketplace/ui/tokens.css +32 -0
  222. package/templates/marketplace/ui/utils.ts +6 -0
  223. package/templates/restaurant/.env.example +12 -0
  224. package/templates/restaurant/AGENTS.md +61 -0
  225. package/templates/restaurant/README.md +77 -0
  226. package/templates/restaurant/app/auth-form.tsx +129 -0
  227. package/templates/restaurant/app/dashboard/dashboard-client.tsx +263 -0
  228. package/templates/restaurant/app/dashboard/page.tsx +59 -0
  229. package/templates/restaurant/app/error.tsx +26 -0
  230. package/templates/restaurant/app/globals.css +148 -0
  231. package/templates/restaurant/app/layout.tsx +151 -0
  232. package/templates/restaurant/app/login/page.tsx +39 -0
  233. package/templates/restaurant/app/not-found.tsx +19 -0
  234. package/templates/restaurant/app/page.tsx +194 -0
  235. package/templates/restaurant/app/reservation-widget.tsx +359 -0
  236. package/templates/restaurant/app/robots.ts +12 -0
  237. package/templates/restaurant/app/sitemap.ts +9 -0
  238. package/templates/restaurant/app.ts +115 -0
  239. package/templates/restaurant/components/marketing.tsx +162 -0
  240. package/templates/restaurant/components/section-scroller.tsx +35 -0
  241. package/templates/restaurant/components/ui/button.tsx +56 -0
  242. package/templates/restaurant/components/ui/card.tsx +90 -0
  243. package/templates/restaurant/components.json +20 -0
  244. package/templates/restaurant/functions/cancelReservation.ts +26 -0
  245. package/templates/restaurant/functions/confirmReservation.ts +17 -0
  246. package/templates/restaurant/functions/createReservation.ts +92 -0
  247. package/templates/restaurant/functions/reservationsForOwner.ts +28 -0
  248. package/templates/restaurant/gitignore +10 -0
  249. package/templates/restaurant/lib/owner.ts +26 -0
  250. package/templates/restaurant/lib/reservation.ts +22 -0
  251. package/templates/restaurant/lib/site.config.ts +218 -0
  252. package/templates/restaurant/lib/slots.ts +55 -0
  253. package/templates/restaurant/lib/utils.ts +10 -0
  254. package/templates/restaurant/package.json +34 -0
  255. package/templates/restaurant/tsconfig.json +18 -0
  256. package/templates/shop/.env.example +32 -0
  257. package/templates/shop/AGENTS.md +61 -0
  258. package/templates/shop/README.md +102 -0
  259. package/templates/shop/app/auth-form.tsx +129 -0
  260. package/templates/shop/app/dashboard/dashboard-client.tsx +264 -0
  261. package/templates/shop/app/dashboard/page.tsx +59 -0
  262. package/templates/shop/app/error.tsx +26 -0
  263. package/templates/shop/app/globals.css +148 -0
  264. package/templates/shop/app/layout.tsx +160 -0
  265. package/templates/shop/app/login/page.tsx +39 -0
  266. package/templates/shop/app/not-found.tsx +19 -0
  267. package/templates/shop/app/page.tsx +95 -0
  268. package/templates/shop/app/robots.ts +12 -0
  269. package/templates/shop/app/shop-client.tsx +436 -0
  270. package/templates/shop/app/sitemap.ts +9 -0
  271. package/templates/shop/app/success/page.tsx +33 -0
  272. package/templates/shop/app.ts +134 -0
  273. package/templates/shop/components/marketing.tsx +96 -0
  274. package/templates/shop/components/section-scroller.tsx +35 -0
  275. package/templates/shop/components/ui/button.tsx +56 -0
  276. package/templates/shop/components/ui/card.tsx +90 -0
  277. package/templates/shop/components.json +20 -0
  278. package/templates/shop/functions/cancelOrder.ts +33 -0
  279. package/templates/shop/functions/checkout.ts +130 -0
  280. package/templates/shop/functions/fulfillOrder.ts +17 -0
  281. package/templates/shop/functions/markGroupPaid.ts +26 -0
  282. package/templates/shop/functions/ordersForOwner.ts +28 -0
  283. package/templates/shop/functions/releaseGroup.ts +36 -0
  284. package/templates/shop/functions/reserveCart.ts +87 -0
  285. package/templates/shop/functions/restockProduct.ts +23 -0
  286. package/templates/shop/functions/seedProducts.ts +30 -0
  287. package/templates/shop/functions/stripeWebhook.ts +72 -0
  288. package/templates/shop/gitignore +10 -0
  289. package/templates/shop/lib/owner.ts +26 -0
  290. package/templates/shop/lib/shop.ts +45 -0
  291. package/templates/shop/lib/site.config.ts +198 -0
  292. package/templates/shop/lib/utils.ts +10 -0
  293. package/templates/shop/package.json +35 -0
  294. package/templates/shop/tsconfig.json +18 -0
  295. package/templates/waitlist/.env.example +12 -0
  296. package/templates/waitlist/AGENTS.md +61 -0
  297. package/templates/waitlist/README.md +81 -0
  298. package/templates/waitlist/app/auth-form.tsx +129 -0
  299. package/templates/waitlist/app/dashboard/dashboard-client.tsx +297 -0
  300. package/templates/waitlist/app/dashboard/page.tsx +70 -0
  301. package/templates/waitlist/app/error.tsx +26 -0
  302. package/templates/waitlist/app/globals.css +148 -0
  303. package/templates/waitlist/app/layout.tsx +158 -0
  304. package/templates/waitlist/app/login/page.tsx +39 -0
  305. package/templates/waitlist/app/not-found.tsx +19 -0
  306. package/templates/waitlist/app/page.tsx +119 -0
  307. package/templates/waitlist/app/robots.ts +12 -0
  308. package/templates/waitlist/app/sitemap.ts +9 -0
  309. package/templates/waitlist/app/waitlist-hero.tsx +219 -0
  310. package/templates/waitlist/app.ts +134 -0
  311. package/templates/waitlist/components/marketing.tsx +96 -0
  312. package/templates/waitlist/components/ui/button.tsx +56 -0
  313. package/templates/waitlist/components/ui/card.tsx +90 -0
  314. package/templates/waitlist/components.json +20 -0
  315. package/templates/waitlist/functions/joinWaitlist.ts +82 -0
  316. package/templates/waitlist/functions/waitlistStats.ts +75 -0
  317. package/templates/waitlist/gitignore +10 -0
  318. package/templates/waitlist/lib/owner.ts +26 -0
  319. package/templates/waitlist/lib/site.config.ts +178 -0
  320. package/templates/waitlist/lib/stats.ts +30 -0
  321. package/templates/waitlist/lib/utils.ts +10 -0
  322. package/templates/waitlist/package.json +34 -0
  323. package/templates/waitlist/tsconfig.json +18 -0
@@ -0,0 +1,129 @@
1
+ "use client";
2
+
3
+ import React, { useState } from "react";
4
+ import {
5
+ passwordLogin,
6
+ passwordRegister,
7
+ persistSession,
8
+ ApiError,
9
+ } from "@pylonsync/client";
10
+
11
+ // The owner's email/password form — one form, two modes. It calls the built-in
12
+ // auth API directly (`passwordLogin` / `passwordRegister` POST to
13
+ // `/api/auth/password/*`), then `persistSession` writes the freshly-minted
14
+ // token to local storage so the sync engine + `callFn` authenticate AS THE
15
+ // OWNER on the next load. This step matters here specifically: the landing page
16
+ // mints an anonymous guest session (for the live counter), and without
17
+ // persisting the real session that stale guest token would shadow the owner's
18
+ // — so the owner-only `waitlistStats` call would come back as a guest and get
19
+ // rejected. We then do a full navigation to /dashboard so the SSR runtime
20
+ // re-resolves auth from the HttpOnly cookie and renders server-side.
21
+ //
22
+ // A waitlist is single-tenant: there's no public signup funnel, just the owner
23
+ // creating their one account. Whoever signs in only sees data if their email
24
+ // matches PYLON_OWNER_EMAIL — enforced by the waitlistStats function.
25
+ export function AuthForm() {
26
+ const [mode, setMode] = useState<"login" | "signup">("login");
27
+ const [email, setEmail] = useState("");
28
+ const [password, setPassword] = useState("");
29
+ const [error, setError] = useState<string | null>(null);
30
+ const [pending, setPending] = useState(false);
31
+
32
+ async function onSubmit(e: React.FormEvent) {
33
+ e.preventDefault();
34
+ setError(null);
35
+ setPending(true);
36
+ try {
37
+ const session =
38
+ mode === "login"
39
+ ? await passwordLogin({ email, password })
40
+ : await passwordRegister({ email, password });
41
+ // Make this session authoritative, replacing any anonymous guest token.
42
+ persistSession(session);
43
+ window.location.assign("/dashboard");
44
+ } catch (err) {
45
+ setError(messageFor(err));
46
+ setPending(false);
47
+ }
48
+ }
49
+
50
+ return (
51
+ <div className="space-y-5">
52
+ <form onSubmit={onSubmit} className="space-y-4">
53
+ <label className="block">
54
+ <span className="mb-1.5 block text-[13px] font-medium text-zinc-700">Email</span>
55
+ <input
56
+ type="email"
57
+ value={email}
58
+ onChange={(e) => setEmail(e.target.value)}
59
+ required
60
+ autoComplete="email"
61
+ placeholder="you@yourbusiness.com"
62
+ className="h-10 w-full rounded-lg border border-zinc-300 bg-white px-3 text-sm text-zinc-900 outline-none transition placeholder:text-zinc-400 focus:border-brand focus:ring-2 focus:ring-brand/20"
63
+ />
64
+ </label>
65
+ <label className="block">
66
+ <span className="mb-1.5 block text-[13px] font-medium text-zinc-700">Password</span>
67
+ <input
68
+ type="password"
69
+ value={password}
70
+ onChange={(e) => setPassword(e.target.value)}
71
+ required
72
+ autoComplete={mode === "login" ? "current-password" : "new-password"}
73
+ placeholder={mode === "login" ? "Your password" : "At least 10 characters"}
74
+ className="h-10 w-full rounded-lg border border-zinc-300 bg-white px-3 text-sm text-zinc-900 outline-none transition placeholder:text-zinc-400 focus:border-brand focus:ring-2 focus:ring-brand/20"
75
+ />
76
+ </label>
77
+ {error ? (
78
+ <p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-[13px] leading-snug text-red-700">
79
+ {error}
80
+ </p>
81
+ ) : null}
82
+ <button
83
+ type="submit"
84
+ disabled={pending}
85
+ className="inline-flex h-10 w-full items-center justify-center rounded-lg bg-zinc-900 text-sm font-medium text-white transition-colors hover:bg-zinc-700 disabled:opacity-60"
86
+ >
87
+ {pending ? "…" : mode === "login" ? "Sign in" : "Create account"}
88
+ </button>
89
+ </form>
90
+
91
+ <p className="text-center text-[13px] text-zinc-500">
92
+ {mode === "login" ? "First time here?" : "Already have an account?"}{" "}
93
+ <button
94
+ type="button"
95
+ onClick={() => {
96
+ setMode(mode === "login" ? "signup" : "login");
97
+ setError(null);
98
+ }}
99
+ className="font-medium text-zinc-900 underline underline-offset-2"
100
+ >
101
+ {mode === "login" ? "Create the owner account" : "Sign in"}
102
+ </button>
103
+ </p>
104
+ </div>
105
+ );
106
+ }
107
+
108
+ // Map the framework's auth error codes to friendly copy. `ApiError` carries a
109
+ // stable `.code` so you branch on the code, not the message.
110
+ function messageFor(err: unknown): string {
111
+ if (err instanceof ApiError) {
112
+ switch (err.code) {
113
+ case "INVALID_CREDENTIALS":
114
+ return "Wrong email or password.";
115
+ case "USER_EXISTS":
116
+ return "That email is already registered — sign in instead.";
117
+ case "WEAK_PASSWORD":
118
+ return "Pick a longer password — at least 10 characters.";
119
+ case "PWNED_PASSWORD":
120
+ return "That password has appeared in a known data breach. Choose a different one.";
121
+ case "RATE_LIMITED":
122
+ return "Too many attempts — try again in a minute.";
123
+ default:
124
+ return err.message;
125
+ }
126
+ }
127
+ if (err instanceof Error) return err.message;
128
+ return "Something went wrong. Try again.";
129
+ }
@@ -0,0 +1,399 @@
1
+ "use client";
2
+
3
+ import React, { useMemo, useState } from "react";
4
+ import { db, callFn } from "@pylonsync/react";
5
+ import { EnsureGuest } from "@pylonsync/client";
6
+ import { siteConfig } from "@/lib/site.config";
7
+ import { slotsForDay, weekdayOf, localDateKey, type Slot } from "@/lib/slots";
8
+
9
+ // The live booking picker — the realtime heart of this template. It subscribes
10
+ // to the public, PII-free `BookedSlot` projection with `db.useQuery`, so the
11
+ // instant anyone books a time (this tab or another), that slot greys out for
12
+ // everyone with the page open. The server independently re-checks at insert
13
+ // time, so even a dead-heat double-click can't double-book.
14
+ //
15
+ // Wrapped in <EnsureGuest> so the sync connection (which the live query rides)
16
+ // is established for anonymous visitors. The guest session holds no PII, and
17
+ // the Booking table stays unreadable to it — the picker only ever reads busy
18
+ // time ranges, never a customer's name or email.
19
+
20
+ interface BookedSlotRow {
21
+ id: string;
22
+ serviceSlug: string;
23
+ startsAt: string;
24
+ endsAt: string;
25
+ }
26
+
27
+ export function BookingWidget() {
28
+ return (
29
+ <EnsureGuest fallback={<PickerSkeleton />}>
30
+ <Picker />
31
+ </EnsureGuest>
32
+ );
33
+ }
34
+
35
+ function Picker() {
36
+ const { services, booking } = siteConfig;
37
+ const { data: busyRows, loading } = db.useQuery<BookedSlotRow>("BookedSlot");
38
+
39
+ // Stamp "now" once so slot availability + the day list are stable across
40
+ // re-renders (and only computed client-side, post-hydration).
41
+ const [nowMs] = useState(() => Date.now());
42
+
43
+ const openDays = useMemo(() => {
44
+ const days: string[] = [];
45
+ for (let i = 0; i < booking.daysAhead; i++) {
46
+ const key = localDateKey(i, nowMs);
47
+ if (booking.hours[weekdayOf(key)]) days.push(key);
48
+ }
49
+ return days;
50
+ }, [booking.daysAhead, booking.hours, nowMs]);
51
+
52
+ const [serviceSlug, setServiceSlug] = useState(services.items[0]?.slug ?? "");
53
+ const [day, setDay] = useState(openDays[0] ?? "");
54
+ const [selected, setSelected] = useState<Slot | null>(null);
55
+ // The slot we just successfully booked — kept separate from `selected` so the
56
+ // confirmation card persists even after the live update marks the slot taken.
57
+ const [confirmed, setConfirmed] = useState<{ slot: Slot; serviceName: string } | null>(null);
58
+
59
+ const service = services.items.find((s) => s.slug === serviceSlug) ?? services.items[0];
60
+
61
+ const slots = useMemo(() => {
62
+ if (!service || !day) return [];
63
+ const hrs = booking.hours[weekdayOf(day)];
64
+ if (!hrs) return [];
65
+ return slotsForDay({
66
+ dayISODate: day,
67
+ open: hrs.open,
68
+ close: hrs.close,
69
+ slotMinutes: booking.slotMinutes,
70
+ durationMin: service.durationMin,
71
+ leadTimeHours: booking.leadTimeHours,
72
+ busy: busyRows.map((b) => ({ startsAt: b.startsAt, endsAt: b.endsAt })),
73
+ nowMs,
74
+ });
75
+ // busyRows identity changes on every sync push → slots recompute live.
76
+ }, [service, day, booking, busyRows, nowMs]);
77
+
78
+ // Clear an IN-PROGRESS selection if its slot just got taken out from under us
79
+ // (someone else booked it while this visitor was filling the form). A slot we
80
+ // successfully booked ourselves lives in `confirmed`, not `selected`, so this
81
+ // never wipes our own confirmation.
82
+ const selectedStillFree =
83
+ selected && slots.find((s) => s.startsAt === selected.startsAt)?.available;
84
+ if (selected && !selectedStillFree) {
85
+ queueMicrotask(() => setSelected(null));
86
+ }
87
+
88
+ function pick(slot: Slot) {
89
+ setConfirmed(null);
90
+ setSelected(slot);
91
+ }
92
+
93
+ return (
94
+ <div className="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm sm:p-7">
95
+ {/* Service picker */}
96
+ <div className="flex flex-wrap gap-2">
97
+ {services.items.map((s) => {
98
+ const active = s.slug === serviceSlug;
99
+ return (
100
+ <button
101
+ key={s.slug}
102
+ type="button"
103
+ onClick={() => {
104
+ setServiceSlug(s.slug);
105
+ setSelected(null);
106
+ setConfirmed(null);
107
+ }}
108
+ className={
109
+ "rounded-full border px-3.5 py-1.5 text-[13px] font-medium transition-colors " +
110
+ (active
111
+ ? "border-zinc-900 bg-zinc-900 text-white"
112
+ : "border-zinc-300 text-zinc-700 hover:border-zinc-400")
113
+ }
114
+ >
115
+ {s.name}
116
+ <span className={active ? "ml-1.5 text-white/60" : "ml-1.5 text-zinc-400"}>
117
+ {s.price} · {s.durationMin}m
118
+ </span>
119
+ </button>
120
+ );
121
+ })}
122
+ </div>
123
+
124
+ {/* Day picker */}
125
+ <div className="mt-5 flex gap-2 overflow-x-auto pb-1">
126
+ {openDays.map((key) => {
127
+ const active = key === day;
128
+ return (
129
+ <button
130
+ key={key}
131
+ type="button"
132
+ onClick={() => {
133
+ setDay(key);
134
+ setSelected(null);
135
+ setConfirmed(null);
136
+ }}
137
+ className={
138
+ "flex shrink-0 flex-col items-center rounded-xl border px-3 py-2 transition-colors " +
139
+ (active
140
+ ? "border-brand bg-brand-soft"
141
+ : "border-zinc-200 hover:border-zinc-300")
142
+ }
143
+ >
144
+ <span className="text-[11px] font-medium uppercase tracking-wide text-zinc-400">
145
+ {dowOfKey(key)}
146
+ </span>
147
+ <span
148
+ className={
149
+ "text-[15px] font-semibold " + (active ? "text-brand" : "text-zinc-900")
150
+ }
151
+ >
152
+ {dayOfKey(key)}
153
+ </span>
154
+ </button>
155
+ );
156
+ })}
157
+ </div>
158
+
159
+ {/* Slots */}
160
+ <div className="mt-5 border-t border-zinc-100 pt-5">
161
+ {loading ? (
162
+ <SlotsSkeleton />
163
+ ) : slots.length === 0 ? (
164
+ <p className="py-6 text-center text-sm text-zinc-500">
165
+ Closed that day — pick another.
166
+ </p>
167
+ ) : (
168
+ <div className="grid grid-cols-3 gap-2 sm:grid-cols-4">
169
+ {slots.map((slot) => {
170
+ const isSelected = selected?.startsAt === slot.startsAt;
171
+ return (
172
+ <button
173
+ key={slot.startsAt}
174
+ type="button"
175
+ disabled={!slot.available}
176
+ onClick={() => pick(slot)}
177
+ aria-pressed={isSelected}
178
+ className={
179
+ "rounded-lg border py-2 text-[13px] font-medium tabular-nums transition-colors " +
180
+ (!slot.available
181
+ ? "cursor-not-allowed border-zinc-100 bg-zinc-50 text-zinc-300 line-through"
182
+ : isSelected
183
+ ? "border-brand bg-brand text-white"
184
+ : "border-zinc-200 text-zinc-800 hover:border-brand hover:text-brand")
185
+ }
186
+ >
187
+ {labelTime(slot.startsAt)}
188
+ </button>
189
+ );
190
+ })}
191
+ </div>
192
+ )}
193
+ <p className="mt-3 text-[12px] text-zinc-400">
194
+ Greyed-out times are already booked — this updates live as others book.
195
+ </p>
196
+ </div>
197
+
198
+ {/* Confirmation persists after a successful booking… */}
199
+ {confirmed ? (
200
+ <div className="mt-6 rounded-xl border border-brand/30 bg-brand-soft/60 p-5 text-center">
201
+ <p className="text-[15px] font-semibold text-zinc-900">
202
+ {confirmed.serviceName} · {labelDow(confirmed.slot.startsAt)}{" "}
203
+ {labelDay(confirmed.slot.startsAt)} at {labelTime(confirmed.slot.startsAt)}
204
+ </p>
205
+ <p className="mt-2 text-[14px] text-zinc-600">
206
+ {siteConfig.booking.confirmationMessage}
207
+ </p>
208
+ </div>
209
+ ) : selected && service ? (
210
+ /* …otherwise the form for the chosen slot. */
211
+ <BookingForm
212
+ service={service}
213
+ slot={selected}
214
+ onClear={() => setSelected(null)}
215
+ onBooked={() => {
216
+ setConfirmed({ slot: selected, serviceName: service.name });
217
+ setSelected(null);
218
+ }}
219
+ />
220
+ ) : null}
221
+ </div>
222
+ );
223
+ }
224
+
225
+ function BookingForm({
226
+ service,
227
+ slot,
228
+ onClear,
229
+ onBooked,
230
+ }: {
231
+ service: { slug: string; name: string; price: string };
232
+ slot: Slot;
233
+ onClear: () => void;
234
+ onBooked: () => void;
235
+ }) {
236
+ const [name, setName] = useState("");
237
+ const [email, setEmail] = useState("");
238
+ const [phone, setPhone] = useState("");
239
+ const [status, setStatus] = useState<"idle" | "booking">("idle");
240
+ const [error, setError] = useState<string | null>(null);
241
+
242
+ async function submit(e: React.FormEvent) {
243
+ e.preventDefault();
244
+ if (status === "booking") return;
245
+ if (!name.trim() || !email.trim()) {
246
+ setError("Name and email are required.");
247
+ return;
248
+ }
249
+ setStatus("booking");
250
+ setError(null);
251
+ try {
252
+ const res = await callFn<{ ok: boolean; reason?: string }>("createBooking", {
253
+ serviceSlug: service.slug,
254
+ startsAt: slot.startsAt,
255
+ customerName: name.trim(),
256
+ customerEmail: email.trim(),
257
+ customerPhone: phone.trim() || undefined,
258
+ });
259
+ if (res.ok) {
260
+ onBooked();
261
+ } else {
262
+ setStatus("idle");
263
+ setError(
264
+ res.reason === "taken"
265
+ ? "Someone just grabbed that time — pick another."
266
+ : res.reason === "past"
267
+ ? "That's too soon — choose a later time."
268
+ : "Couldn't book that slot. Try another.",
269
+ );
270
+ if (res.reason === "taken") onClear();
271
+ }
272
+ } catch (err) {
273
+ setStatus("idle");
274
+ setError(
275
+ /valid email|name/i.test(err instanceof Error ? err.message : "")
276
+ ? "Check your name and email."
277
+ : "Something went wrong — try again.",
278
+ );
279
+ }
280
+ }
281
+
282
+ return (
283
+ <form onSubmit={submit} className="mt-6 rounded-xl border border-zinc-200 bg-paper p-5">
284
+ <div className="flex items-center justify-between">
285
+ <div className="text-[14px] font-medium text-zinc-900">
286
+ {service.name} · {labelDow(slot.startsAt)} {labelDay(slot.startsAt)} at{" "}
287
+ {labelTime(slot.startsAt)}
288
+ </div>
289
+ <button
290
+ type="button"
291
+ onClick={onClear}
292
+ className="text-[13px] text-zinc-400 transition-colors hover:text-zinc-700"
293
+ >
294
+ Change
295
+ </button>
296
+ </div>
297
+ <div className="mt-4 grid gap-3 sm:grid-cols-2">
298
+ <input
299
+ value={name}
300
+ onChange={(e) => setName(e.target.value)}
301
+ placeholder="Your name"
302
+ aria-label="Your name"
303
+ autoComplete="name"
304
+ className={inputCls}
305
+ />
306
+ <input
307
+ type="email"
308
+ value={email}
309
+ onChange={(e) => setEmail(e.target.value)}
310
+ placeholder="you@email.com"
311
+ aria-label="Email"
312
+ autoComplete="email"
313
+ className={inputCls}
314
+ />
315
+ <input
316
+ type="tel"
317
+ value={phone}
318
+ onChange={(e) => setPhone(e.target.value)}
319
+ placeholder="Phone (optional)"
320
+ aria-label="Phone"
321
+ autoComplete="tel"
322
+ className={inputCls + " sm:col-span-2"}
323
+ />
324
+ </div>
325
+ {error ? <p className="mt-3 text-[13px] text-red-600">{error}</p> : null}
326
+ <button
327
+ type="submit"
328
+ disabled={status === "booking"}
329
+ className="mt-4 inline-flex h-10 w-full items-center justify-center rounded-lg bg-brand text-sm font-medium text-white transition-opacity hover:opacity-90 disabled:opacity-60"
330
+ >
331
+ {status === "booking" ? "Booking…" : "Confirm booking"}
332
+ </button>
333
+ <p className="mt-2 text-center text-[12px] text-zinc-400">
334
+ No payment now — pay at the shop.
335
+ </p>
336
+ </form>
337
+ );
338
+ }
339
+
340
+ const inputCls =
341
+ "h-10 w-full rounded-lg border border-zinc-300 bg-white px-3 text-sm text-zinc-900 outline-none transition placeholder:text-zinc-400 focus:border-brand focus:ring-2 focus:ring-brand/20";
342
+
343
+ /* ------------------------------ labels -------------------------------- */
344
+
345
+ // For full ISO INSTANTS (a slot's startsAt) — `new Date(iso)` is the right
346
+ // instant, shown in the viewer's local time.
347
+ function labelDow(iso: string) {
348
+ return new Date(iso).toLocaleDateString(undefined, { weekday: "short" });
349
+ }
350
+ function labelDay(iso: string) {
351
+ return new Date(iso).toLocaleDateString(undefined, { month: "short", day: "numeric" });
352
+ }
353
+ function labelTime(iso: string) {
354
+ return new Date(iso).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
355
+ }
356
+
357
+ // For a local DAY KEY ("YYYY-MM-DD") — parse the parts LOCALLY. `new Date("2026-06-15")`
358
+ // would parse as UTC midnight and shift back a day in negative-UTC zones, so
359
+ // build the date from its parts instead.
360
+ function keyToLocalDate(key: string): Date {
361
+ const [y, m, d] = key.split("-").map(Number);
362
+ return new Date(y, m - 1, d);
363
+ }
364
+ function dowOfKey(key: string) {
365
+ return keyToLocalDate(key).toLocaleDateString(undefined, { weekday: "short" });
366
+ }
367
+ function dayOfKey(key: string) {
368
+ return keyToLocalDate(key).toLocaleDateString(undefined, { month: "short", day: "numeric" });
369
+ }
370
+
371
+ /* ----------------------------- skeletons ------------------------------ */
372
+
373
+ function PickerSkeleton() {
374
+ return (
375
+ <div className="rounded-2xl border border-zinc-200 bg-white p-7">
376
+ <div className="flex gap-2">
377
+ {[0, 1, 2, 3].map((i) => (
378
+ <div key={i} className="h-8 w-24 animate-pulse rounded-full bg-zinc-100" />
379
+ ))}
380
+ </div>
381
+ <div className="mt-5 flex gap-2">
382
+ {[0, 1, 2, 3, 4].map((i) => (
383
+ <div key={i} className="h-14 w-16 animate-pulse rounded-xl bg-zinc-100" />
384
+ ))}
385
+ </div>
386
+ <SlotsSkeleton />
387
+ </div>
388
+ );
389
+ }
390
+
391
+ function SlotsSkeleton() {
392
+ return (
393
+ <div className="mt-5 grid grid-cols-3 gap-2 sm:grid-cols-4">
394
+ {Array.from({ length: 8 }).map((_, i) => (
395
+ <div key={i} className="h-9 animate-pulse rounded-lg bg-zinc-100" />
396
+ ))}
397
+ </div>
398
+ );
399
+ }