@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,304 @@
1
+ "use client";
2
+
3
+ import React, { useEffect, useMemo, useState } from "react";
4
+ import { db, callFn } from "@pylonsync/react";
5
+ import { useAuth } from "@pylonsync/client";
6
+ import { siteConfig } from "@/lib/site.config";
7
+ import type { BookingRow, OwnerBookingsResult } from "@/lib/booking";
8
+
9
+ // The owner's live bookings dashboard. Liveness rides the SAME public
10
+ // BookedSlot projection the picker uses: `db.useQuery("BookedSlot")` re-renders
11
+ // the instant a slot is taken or freed (cross-tab, via the replica), which
12
+ // triggers a re-fetch of the owner-gated `bookingsForOwner` — so new bookings
13
+ // appear and cancellations drop off without a refresh. Customer PII only ever
14
+ // travels through that gated call.
15
+ export function BookingsDashboard({ userEmail }: { userEmail: string }) {
16
+ const { data: slotRows } = db.useQuery<{ id: string }>("BookedSlot");
17
+ const liveKey = slotRows.length;
18
+
19
+ const [bookings, setBookings] = useState<BookingRow[] | null>(null);
20
+ const [denied, setDenied] = useState(false);
21
+ const [error, setError] = useState<string | null>(null);
22
+ const [busyId, setBusyId] = useState<string | null>(null);
23
+
24
+ async function load() {
25
+ try {
26
+ const r = await callFn<OwnerBookingsResult>("bookingsForOwner", {});
27
+ if (!r.authorized) {
28
+ setDenied(true);
29
+ } else {
30
+ setBookings(r.bookings);
31
+ setDenied(false);
32
+ setError(null);
33
+ }
34
+ } catch (e) {
35
+ setError(e instanceof Error ? e.message : String(e));
36
+ }
37
+ }
38
+
39
+ useEffect(() => {
40
+ void load();
41
+ // Re-fetch whenever the live booked-slot set changes.
42
+ // eslint-disable-next-line react-hooks/exhaustive-deps
43
+ }, [liveKey]);
44
+
45
+ async function act(id: string, fn: "confirmBooking" | "cancelBooking") {
46
+ setBusyId(id);
47
+ try {
48
+ await callFn(fn, { bookingId: id });
49
+ await load();
50
+ } finally {
51
+ setBusyId(null);
52
+ }
53
+ }
54
+
55
+ if (denied) return <OwnerOnly email={userEmail} />;
56
+ if (error) {
57
+ return (
58
+ <div className="rounded-xl border border-red-200 bg-red-50 px-5 py-4 text-sm text-red-700">
59
+ {error}
60
+ </div>
61
+ );
62
+ }
63
+ if (!bookings) return <Skeleton />;
64
+
65
+ // Upcoming = not cancelled and ending in the future. Group by day.
66
+ const nowMs = Date.now();
67
+ const active = bookings.filter((b) => b.status !== "cancelled");
68
+ const upcoming = active.filter((b) => Date.parse(b.endsAt) >= nowMs);
69
+ const todayKey = new Date().toLocaleDateString();
70
+ const todayCount = upcoming.filter(
71
+ (b) => new Date(b.startsAt).toLocaleDateString() === todayKey,
72
+ ).length;
73
+ const pendingCount = upcoming.filter((b) => b.status === "pending").length;
74
+
75
+ return (
76
+ <div className="space-y-8">
77
+ <div>
78
+ <h1 className="text-xl font-semibold tracking-tight">Bookings</h1>
79
+ <p className="mt-1 text-sm text-zinc-500">
80
+ Live — new bookings appear the moment they happen; cancelling frees the
81
+ slot on the site instantly.
82
+ </p>
83
+ </div>
84
+
85
+ <div className="grid gap-4 sm:grid-cols-3">
86
+ <Stat label="Upcoming" value={upcoming.length} />
87
+ <Stat label="Today" value={todayCount} />
88
+ <Stat label="Awaiting confirm" value={pendingCount} />
89
+ </div>
90
+
91
+ <UpcomingList bookings={upcoming} busyId={busyId} onAct={act} />
92
+ </div>
93
+ );
94
+ }
95
+
96
+ function Stat({ label, value }: { label: string; value: number }) {
97
+ return (
98
+ <div className="rounded-xl border border-zinc-200 bg-white p-4">
99
+ <div className="text-[11px] font-medium uppercase tracking-wide text-zinc-400">
100
+ {label}
101
+ </div>
102
+ <div className="mt-1 text-2xl font-semibold tabular-nums text-zinc-900">{value}</div>
103
+ </div>
104
+ );
105
+ }
106
+
107
+ function UpcomingList({
108
+ bookings,
109
+ busyId,
110
+ onAct,
111
+ }: {
112
+ bookings: BookingRow[];
113
+ busyId: string | null;
114
+ onAct: (id: string, fn: "confirmBooking" | "cancelBooking") => void;
115
+ }) {
116
+ // Group chronologically by calendar day.
117
+ const groups = useMemo(() => {
118
+ const m = new Map<string, BookingRow[]>();
119
+ for (const b of bookings) {
120
+ const key = new Date(b.startsAt).toLocaleDateString(undefined, {
121
+ weekday: "long",
122
+ month: "short",
123
+ day: "numeric",
124
+ });
125
+ const arr = m.get(key) ?? [];
126
+ arr.push(b);
127
+ m.set(key, arr);
128
+ }
129
+ return Array.from(m.entries());
130
+ }, [bookings]);
131
+
132
+ if (bookings.length === 0) {
133
+ return (
134
+ <div className="rounded-xl border border-dashed border-zinc-300 p-10 text-center text-sm text-zinc-500">
135
+ No upcoming bookings yet. Share your site — new bookings land here live.
136
+ </div>
137
+ );
138
+ }
139
+
140
+ return (
141
+ <div className="space-y-6">
142
+ {groups.map(([day, items]) => (
143
+ <div key={day}>
144
+ <h2 className="mb-2 text-[13px] font-semibold text-zinc-900">{day}</h2>
145
+ <div className="overflow-hidden rounded-xl border border-zinc-200">
146
+ {items.map((b, i) => (
147
+ <BookingItem
148
+ key={b.id}
149
+ booking={b}
150
+ busy={busyId === b.id}
151
+ onAct={onAct}
152
+ last={i === items.length - 1}
153
+ />
154
+ ))}
155
+ </div>
156
+ </div>
157
+ ))}
158
+ </div>
159
+ );
160
+ }
161
+
162
+ function BookingItem({
163
+ booking,
164
+ busy,
165
+ onAct,
166
+ last,
167
+ }: {
168
+ booking: BookingRow;
169
+ busy: boolean;
170
+ onAct: (id: string, fn: "confirmBooking" | "cancelBooking") => void;
171
+ last: boolean;
172
+ }) {
173
+ const service = siteConfig.services.items.find((s) => s.slug === booking.serviceSlug);
174
+ return (
175
+ <div
176
+ className={
177
+ "flex flex-wrap items-center gap-x-4 gap-y-2 px-4 py-3 " +
178
+ (last ? "" : "border-b border-zinc-100")
179
+ }
180
+ >
181
+ <div className="w-20 shrink-0 text-[14px] font-semibold tabular-nums text-zinc-900">
182
+ {new Date(booking.startsAt).toLocaleTimeString(undefined, {
183
+ hour: "numeric",
184
+ minute: "2-digit",
185
+ })}
186
+ </div>
187
+ <div className="min-w-0 flex-1">
188
+ <div className="flex items-center gap-2">
189
+ <span className="text-[14px] font-medium text-zinc-900">
190
+ {booking.customerName}
191
+ </span>
192
+ <StatusBadge status={booking.status} />
193
+ </div>
194
+ <div className="truncate text-[12.5px] text-zinc-500">
195
+ {service?.name ?? booking.serviceSlug} · {booking.customerEmail}
196
+ {booking.customerPhone ? ` · ${booking.customerPhone}` : ""}
197
+ </div>
198
+ </div>
199
+ <div className="flex items-center gap-2">
200
+ {booking.status === "pending" ? (
201
+ <button
202
+ type="button"
203
+ disabled={busy}
204
+ onClick={() => onAct(booking.id, "confirmBooking")}
205
+ className="rounded-md bg-zinc-900 px-3 py-1.5 text-[12.5px] font-medium text-white transition-colors hover:bg-zinc-700 disabled:opacity-50"
206
+ >
207
+ {busy ? "…" : "Confirm"}
208
+ </button>
209
+ ) : null}
210
+ <button
211
+ type="button"
212
+ disabled={busy}
213
+ onClick={() => onAct(booking.id, "cancelBooking")}
214
+ className="rounded-md border border-zinc-300 px-3 py-1.5 text-[12.5px] font-medium text-zinc-600 transition-colors hover:border-red-300 hover:text-red-600 disabled:opacity-50"
215
+ >
216
+ Cancel
217
+ </button>
218
+ </div>
219
+ </div>
220
+ );
221
+ }
222
+
223
+ function StatusBadge({ status }: { status: string }) {
224
+ const tone =
225
+ status === "confirmed"
226
+ ? "bg-green-50 text-green-700"
227
+ : status === "cancelled"
228
+ ? "bg-zinc-100 text-zinc-500"
229
+ : "bg-amber-50 text-amber-700";
230
+ return (
231
+ <span className={"rounded-full px-2 py-0.5 text-[10px] font-medium capitalize " + tone}>
232
+ {status}
233
+ </span>
234
+ );
235
+ }
236
+
237
+ /* --------------------------- owner-only gate -------------------------- */
238
+
239
+ function OwnerOnly({ email }: { email: string }) {
240
+ return (
241
+ <div className="rounded-xl border border-dashed border-zinc-300 px-6 py-12 text-center">
242
+ <h1 className="text-lg font-semibold">This dashboard is owner-only</h1>
243
+ <p className="mx-auto mt-2 max-w-md text-sm text-zinc-500">
244
+ You&apos;re signed in as{" "}
245
+ <span className="font-medium text-zinc-700">{email || "this account"}</span>.
246
+ Only the shop owner can see bookings. Set{" "}
247
+ <code className="rounded bg-zinc-100 px-1.5 py-0.5 text-[12px]">
248
+ PYLON_OWNER_EMAIL={email || "you@yourshop.com"}
249
+ </code>{" "}
250
+ in your{" "}
251
+ <code className="rounded bg-zinc-100 px-1.5 py-0.5 text-[12px]">.env</code>,
252
+ restart, and reload — or sign in with the owner account.
253
+ </p>
254
+ </div>
255
+ );
256
+ }
257
+
258
+ /* ----------------------------- user menu ------------------------------ */
259
+
260
+ export function UserMenu({ email }: { email: string }) {
261
+ const { signOut } = useAuth();
262
+ const initial = (email.trim()[0] || "?").toUpperCase();
263
+ async function onSignOut() {
264
+ await signOut();
265
+ window.location.assign("/");
266
+ }
267
+ return (
268
+ <details className="group relative">
269
+ <summary className="flex size-8 cursor-pointer select-none list-none items-center justify-center rounded-full bg-zinc-900 text-[12px] font-semibold text-white marker:hidden [&::-webkit-details-marker]:hidden">
270
+ {initial}
271
+ </summary>
272
+ <div className="absolute right-0 top-full z-40 mt-2 w-56 overflow-hidden rounded-xl border border-zinc-200 bg-white py-1 shadow-[0_16px_48px_-16px_rgba(0,0,0,0.25)]">
273
+ <div className="border-b border-zinc-100 px-3 py-2">
274
+ <div className="truncate text-[13px] font-medium text-zinc-900">
275
+ {email || "Signed in"}
276
+ </div>
277
+ </div>
278
+ <button
279
+ type="button"
280
+ onClick={onSignOut}
281
+ className="flex w-full items-center px-3 py-2 text-left text-[13px] text-zinc-700 transition-colors hover:bg-zinc-50"
282
+ >
283
+ Sign out
284
+ </button>
285
+ </div>
286
+ </details>
287
+ );
288
+ }
289
+
290
+ /* ------------------------------ skeleton ------------------------------ */
291
+
292
+ function Skeleton() {
293
+ return (
294
+ <div className="space-y-8">
295
+ <div className="h-6 w-32 animate-pulse rounded bg-zinc-100" />
296
+ <div className="grid gap-4 sm:grid-cols-3">
297
+ {[0, 1, 2].map((i) => (
298
+ <div key={i} className="h-20 animate-pulse rounded-xl bg-zinc-100" />
299
+ ))}
300
+ </div>
301
+ <div className="h-48 animate-pulse rounded-xl bg-zinc-100" />
302
+ </div>
303
+ );
304
+ }
@@ -0,0 +1,63 @@
1
+ import React, { use } from "react";
2
+ import { Link, type Metadata, type PageProps } from "@pylonsync/react";
3
+ import { siteConfig } from "@/lib/site.config";
4
+ import { UserMenu, BookingsDashboard } from "./dashboard-client";
5
+
6
+ export const metadata: Metadata = {
7
+ title: `Dashboard — ${siteConfig.brand.name}`,
8
+ robots: "noindex",
9
+ };
10
+
11
+ // `app/dashboard/page.tsx` → `/dashboard`. Server-side auth gate only; the OWNER
12
+ // gate (only PYLON_OWNER_EMAIL sees the bookings + customer PII) lives in the
13
+ // `bookingsForOwner` function via `ctx.env`. A non-owner gets a clean
14
+ // "owner-only" card from the client. Reading `auth` here opts the render out of
15
+ // caching, which is correct — the dashboard is private + noindex.
16
+ export default function DashboardPage({ auth, response, serverData }: PageProps) {
17
+ // Anonymous visitors and guest sessions (guest_… ids) get bounced to login —
18
+ // the dashboard is for the real, signed-in owner only.
19
+ if (!auth.user_id || auth.user_id.startsWith("guest_")) {
20
+ response.redirect("/login");
21
+ return null;
22
+ }
23
+ const me = use(serverData.get<{ email?: string }>("User", auth.user_id));
24
+ const email = me?.email ?? "";
25
+
26
+ return (
27
+ <Shell email={email}>
28
+ <BookingsDashboard userEmail={email} />
29
+ </Shell>
30
+ );
31
+ }
32
+
33
+ function Shell({ email, children }: { email: string; children: React.ReactNode }) {
34
+ const { brand } = siteConfig;
35
+ return (
36
+ <div className="flex min-h-screen flex-col bg-white text-zinc-900">
37
+ <header className="border-b border-zinc-200">
38
+ <div className="mx-auto flex h-14 max-w-4xl items-center justify-between px-6">
39
+ <div className="flex items-center gap-2">
40
+ <span className="flex size-6 items-center justify-center rounded-[7px] bg-zinc-900 text-[13px] font-bold text-white">
41
+ {brand.letter}
42
+ </span>
43
+ <span className="text-[15px] font-semibold tracking-tight">
44
+ {brand.name} <span className="text-zinc-400">/ bookings</span>
45
+ </span>
46
+ </div>
47
+ <div className="flex items-center gap-4">
48
+ <Link
49
+ href="/"
50
+ className="text-[13px] text-zinc-500 transition-colors hover:text-zinc-900"
51
+ >
52
+ View site ↗
53
+ </Link>
54
+ <UserMenu email={email} />
55
+ </div>
56
+ </div>
57
+ </header>
58
+ <main className="flex-1">
59
+ <div className="mx-auto max-w-4xl px-6 py-8">{children}</div>
60
+ </main>
61
+ </div>
62
+ );
63
+ }
@@ -0,0 +1,26 @@
1
+ import React from "react";
2
+ import { type ErrorBoundaryProps } from "@pylonsync/react";
3
+
4
+ // `app/error.tsx` → the error boundary for this segment. Hydrated + interactive:
5
+ // `reset()` re-attempts the route. The thrown error reaches the client as
6
+ // `{ message, digest }` only — the stack stays in the dev overlay / server logs.
7
+ export default function Error({ error, reset }: ErrorBoundaryProps) {
8
+ return (
9
+ <div className="mx-auto flex min-h-[60vh] max-w-3xl flex-col items-center justify-center px-6 text-center">
10
+ <h1 className="text-2xl font-semibold tracking-tight">Something went wrong</h1>
11
+ <p className="mt-2 text-zinc-500">{error.message}</p>
12
+ {error.digest ? (
13
+ <p className="mt-1 text-xs text-zinc-400">
14
+ Reference: <code>{error.digest}</code>
15
+ </p>
16
+ ) : null}
17
+ <button
18
+ type="button"
19
+ onClick={reset}
20
+ className="mt-6 inline-flex h-10 items-center rounded-full bg-zinc-900 px-5 text-sm font-medium text-white transition-colors hover:bg-zinc-700"
21
+ >
22
+ Try again
23
+ </button>
24
+ </div>
25
+ );
26
+ }
@@ -0,0 +1,148 @@
1
+ @import "tailwindcss";
2
+ @import "tw-animate-css";
3
+
4
+ /* Tailwind v4 scans these globs for class names. Add more @source lines if you
5
+ put markup elsewhere. The @pylonsync/client line lets its components
6
+ (EnsureGuest, auth helpers) keep any classes they ship. */
7
+ @source "../app/**/*.{tsx,ts,jsx,js}";
8
+ @source "../components/**/*.{tsx,ts,jsx,js}";
9
+ @source "../lib/**/*.{tsx,ts,jsx,js}";
10
+ @source "../node_modules/@pylonsync/client/**/*.{tsx,ts,jsx,js}";
11
+
12
+ @custom-variant dark (&:where(.dark, .dark *));
13
+
14
+ /* shadcn/ui design tokens (new-york / zinc) + the marketing brand accent. The
15
+ three brand vars are defaults — app/layout.tsx overrides them from
16
+ lib/site.config.ts on <html>, so re-theming the whole page is one edit there. */
17
+ :root {
18
+ --radius: 0.625rem;
19
+ --brand: #4f46e5;
20
+ --brand-soft: #eef2ff;
21
+ --paper: #fafafa;
22
+ --background: oklch(1 0 0);
23
+ --foreground: oklch(0.141 0.005 285.823);
24
+ --card: oklch(1 0 0);
25
+ --card-foreground: oklch(0.141 0.005 285.823);
26
+ --popover: oklch(1 0 0);
27
+ --popover-foreground: oklch(0.141 0.005 285.823);
28
+ --primary: oklch(0.21 0.006 285.885);
29
+ --primary-foreground: oklch(0.985 0 0);
30
+ --secondary: oklch(0.967 0.001 286.375);
31
+ --secondary-foreground: oklch(0.21 0.006 285.885);
32
+ --muted: oklch(0.967 0.001 286.375);
33
+ --muted-foreground: oklch(0.552 0.016 285.938);
34
+ --accent: oklch(0.967 0.001 286.375);
35
+ --accent-foreground: oklch(0.21 0.006 285.885);
36
+ --destructive: oklch(0.577 0.245 27.325);
37
+ --border: oklch(0.92 0.004 286.32);
38
+ --input: oklch(0.92 0.004 286.32);
39
+ --ring: oklch(0.705 0.015 286.067);
40
+ --chart-1: oklch(0.646 0.222 41.116);
41
+ --chart-2: oklch(0.6 0.118 184.704);
42
+ --chart-3: oklch(0.398 0.07 227.392);
43
+ --chart-4: oklch(0.828 0.189 84.429);
44
+ --chart-5: oklch(0.769 0.188 70.08);
45
+ --sidebar: oklch(0.985 0 0);
46
+ --sidebar-foreground: oklch(0.141 0.005 285.823);
47
+ --sidebar-primary: oklch(0.21 0.006 285.885);
48
+ --sidebar-primary-foreground: oklch(0.985 0 0);
49
+ --sidebar-accent: oklch(0.967 0.001 286.375);
50
+ --sidebar-accent-foreground: oklch(0.21 0.006 285.885);
51
+ --sidebar-border: oklch(0.92 0.004 286.32);
52
+ --sidebar-ring: oklch(0.705 0.015 286.067);
53
+ }
54
+
55
+ .dark {
56
+ --background: oklch(0.141 0.005 285.823);
57
+ --foreground: oklch(0.985 0 0);
58
+ --card: oklch(0.21 0.006 285.885);
59
+ --card-foreground: oklch(0.985 0 0);
60
+ --popover: oklch(0.21 0.006 285.885);
61
+ --popover-foreground: oklch(0.985 0 0);
62
+ --primary: oklch(0.92 0.004 286.32);
63
+ --primary-foreground: oklch(0.21 0.006 285.885);
64
+ --secondary: oklch(0.274 0.006 286.033);
65
+ --secondary-foreground: oklch(0.985 0 0);
66
+ --muted: oklch(0.274 0.006 286.033);
67
+ --muted-foreground: oklch(0.705 0.015 286.067);
68
+ --accent: oklch(0.274 0.006 286.033);
69
+ --accent-foreground: oklch(0.985 0 0);
70
+ --destructive: oklch(0.704 0.191 22.216);
71
+ --border: oklch(1 0 0 / 10%);
72
+ --input: oklch(1 0 0 / 15%);
73
+ --ring: oklch(0.552 0.016 285.938);
74
+ --chart-1: oklch(0.488 0.243 264.376);
75
+ --chart-2: oklch(0.696 0.17 162.48);
76
+ --chart-3: oklch(0.769 0.188 70.08);
77
+ --chart-4: oklch(0.627 0.265 303.9);
78
+ --chart-5: oklch(0.645 0.246 16.439);
79
+ --sidebar: oklch(0.21 0.006 285.885);
80
+ --sidebar-foreground: oklch(0.985 0 0);
81
+ --sidebar-primary: oklch(0.488 0.243 264.376);
82
+ --sidebar-primary-foreground: oklch(0.985 0 0);
83
+ --sidebar-accent: oklch(0.274 0.006 286.033);
84
+ --sidebar-accent-foreground: oklch(0.985 0 0);
85
+ --sidebar-border: oklch(1 0 0 / 10%);
86
+ --sidebar-ring: oklch(0.552 0.016 285.938);
87
+ }
88
+
89
+ @theme inline {
90
+ --radius-sm: calc(var(--radius) - 4px);
91
+ --radius-md: calc(var(--radius) - 2px);
92
+ --radius-lg: var(--radius);
93
+ --radius-xl: calc(var(--radius) + 4px);
94
+ --color-background: var(--background);
95
+ --color-foreground: var(--foreground);
96
+ --color-card: var(--card);
97
+ --color-card-foreground: var(--card-foreground);
98
+ --color-popover: var(--popover);
99
+ --color-popover-foreground: var(--popover-foreground);
100
+ --color-primary: var(--primary);
101
+ --color-primary-foreground: var(--primary-foreground);
102
+ --color-secondary: var(--secondary);
103
+ --color-secondary-foreground: var(--secondary-foreground);
104
+ --color-muted: var(--muted);
105
+ --color-muted-foreground: var(--muted-foreground);
106
+ --color-accent: var(--accent);
107
+ --color-accent-foreground: var(--accent-foreground);
108
+ --color-destructive: var(--destructive);
109
+ --color-border: var(--border);
110
+ --color-input: var(--input);
111
+ --color-ring: var(--ring);
112
+ --color-brand: var(--brand);
113
+ --color-brand-soft: var(--brand-soft);
114
+ --color-paper: var(--paper);
115
+ --color-chart-1: var(--chart-1);
116
+ --color-chart-2: var(--chart-2);
117
+ --color-chart-3: var(--chart-3);
118
+ --color-chart-4: var(--chart-4);
119
+ --color-chart-5: var(--chart-5);
120
+ --color-sidebar: var(--sidebar);
121
+ --color-sidebar-foreground: var(--sidebar-foreground);
122
+ --color-sidebar-primary: var(--sidebar-primary);
123
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
124
+ --color-sidebar-accent: var(--sidebar-accent);
125
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
126
+ --color-sidebar-border: var(--sidebar-border);
127
+ --color-sidebar-ring: var(--sidebar-ring);
128
+ }
129
+
130
+ @layer base {
131
+ *,
132
+ ::after,
133
+ ::before,
134
+ ::backdrop,
135
+ ::file-selector-button {
136
+ border-color: var(--color-border, currentColor);
137
+ outline-color: var(--color-ring);
138
+ }
139
+ body {
140
+ background-color: var(--color-background);
141
+ color: var(--color-foreground);
142
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, sans-serif;
143
+ -webkit-font-smoothing: antialiased;
144
+ }
145
+ button {
146
+ cursor: pointer;
147
+ }
148
+ }
@@ -0,0 +1,151 @@
1
+ import React from "react";
2
+ import { Link, type PageAuth } from "@pylonsync/react";
3
+ import { siteConfig } from "@/lib/site.config";
4
+ import { SectionScroller } from "@/components/section-scroller";
5
+
6
+ // A layout wraps every page. This marketing layout renders a slim nav and a
7
+ // footer, both driven by lib/site.config.ts. `auth.user_id` is resolved
8
+ // server-side from the session cookie before any HTML is sent, so the owner
9
+ // sees "Dashboard" and everyone else sees "Sign in" — no flash, no client fetch.
10
+ interface LayoutProps {
11
+ children: React.ReactNode;
12
+ url: string;
13
+ auth: PageAuth;
14
+ }
15
+
16
+ export default function RootLayout({ children, url, auth }: LayoutProps) {
17
+ // A guest session (minted by <EnsureGuest> for the live picker) has a
18
+ // `guest_…` user id — that's an anonymous visitor, NOT the signed-in owner,
19
+ // so it shouldn't flip the nav to "Dashboard".
20
+ const signedIn = Boolean(auth?.user_id && !auth.user_id.startsWith("guest_"));
21
+ const { brand, colors } = siteConfig;
22
+
23
+ // Auth + dashboard render bare (no marketing chrome). Match the path PREFIX.
24
+ const path = (url ?? "").split("?")[0];
25
+ const BARE_PREFIXES = ["/login", "/dashboard"];
26
+ const isBare = BARE_PREFIXES.some((p) => path === p || path.startsWith(p + "/"));
27
+
28
+ return (
29
+ <html
30
+ lang="en"
31
+ style={
32
+ {
33
+ "--brand": colors.brand,
34
+ "--brand-soft": colors.brandSoft,
35
+ "--paper": colors.paper,
36
+ } as React.CSSProperties
37
+ }
38
+ >
39
+ <head>
40
+ <meta charSet="utf-8" />
41
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
42
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
43
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
44
+ <link
45
+ rel="stylesheet"
46
+ href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
47
+ />
48
+ </head>
49
+ <body className="flex min-h-screen flex-col bg-background text-foreground antialiased">
50
+ <SectionScroller />
51
+ {isBare ? (
52
+ children
53
+ ) : (
54
+ <>
55
+ <header className="sticky top-0 z-30 border-b border-zinc-200/70 bg-white/85 backdrop-blur">
56
+ <div className="mx-auto flex h-14 max-w-5xl items-center justify-between px-6">
57
+ <Link href="/" className="flex items-center gap-2">
58
+ <span className="flex size-6 items-center justify-center rounded-[7px] bg-zinc-900 text-[13px] font-bold text-white">
59
+ {brand.letter}
60
+ </span>
61
+ <span className="text-[15px] font-semibold tracking-tight text-zinc-900">
62
+ {brand.name}
63
+ </span>
64
+ </Link>
65
+ <nav className="flex items-center gap-2">
66
+ <a
67
+ href="/#services"
68
+ className="hidden rounded-full px-3 py-1.5 text-[13px] font-medium text-zinc-600 transition-colors hover:text-zinc-900 sm:inline-flex"
69
+ >
70
+ Services
71
+ </a>
72
+ {signedIn ? (
73
+ <Link
74
+ href="/dashboard"
75
+ className="inline-flex items-center rounded-full bg-zinc-900 px-3.5 py-1.5 text-[13px] font-medium text-white transition-colors hover:bg-zinc-700"
76
+ >
77
+ Dashboard
78
+ </Link>
79
+ ) : (
80
+ <>
81
+ <Link
82
+ href="/login"
83
+ className="hidden rounded-full px-3 py-1.5 text-[13px] font-medium text-zinc-600 transition-colors hover:text-zinc-900 sm:inline-flex"
84
+ >
85
+ Sign in
86
+ </Link>
87
+ <a
88
+ href="/#book"
89
+ className="inline-flex items-center rounded-full bg-brand px-3.5 py-1.5 text-[13px] font-medium text-white transition-colors hover:opacity-90"
90
+ >
91
+ {siteConfig.hero.ctaLabel}
92
+ </a>
93
+ </>
94
+ )}
95
+ </nav>
96
+ </div>
97
+ </header>
98
+
99
+ <main className="flex-1">{children}</main>
100
+
101
+ <SiteFooter />
102
+ </>
103
+ )}
104
+ </body>
105
+ </html>
106
+ );
107
+ }
108
+
109
+ function SiteFooter() {
110
+ const { brand, location } = siteConfig;
111
+ return (
112
+ <footer className="border-t border-zinc-200/70 bg-white">
113
+ <div className="mx-auto max-w-5xl px-6 py-12">
114
+ <div className="flex flex-col items-start justify-between gap-6 sm:flex-row">
115
+ <div className="max-w-sm">
116
+ <Link href="/" className="inline-flex items-center gap-2">
117
+ <span className="flex size-6 items-center justify-center rounded-[7px] bg-zinc-900 text-[13px] font-bold text-white">
118
+ {brand.letter}
119
+ </span>
120
+ <span className="text-[15px] font-semibold tracking-tight text-zinc-900">
121
+ {brand.name}
122
+ </span>
123
+ </Link>
124
+ <p className="mt-3 text-[13px] leading-relaxed text-zinc-500">
125
+ {brand.footerBlurb}
126
+ </p>
127
+ </div>
128
+ <div className="text-[13px] leading-relaxed text-zinc-500">
129
+ <div className="font-medium text-zinc-900">Visit</div>
130
+ <p className="mt-2 max-w-[14rem]">{location.address}</p>
131
+ <p className="mt-2">{location.phone}</p>
132
+ <a href={`mailto:${brand.email}`} className="mt-1 inline-block hover:text-zinc-900">
133
+ {brand.email}
134
+ </a>
135
+ </div>
136
+ </div>
137
+ <div className="mt-10 flex flex-col items-start justify-between gap-3 border-t border-zinc-200/70 pt-6 text-[12px] text-zinc-400 sm:flex-row sm:items-center">
138
+ <span>
139
+ © {new Date().getFullYear()} {brand.copyrightName}
140
+ </span>
141
+ <span>
142
+ Built with{" "}
143
+ <a href="https://pylonsync.com" className="font-medium text-zinc-600 hover:text-zinc-900">
144
+ Pylon
145
+ </a>
146
+ </span>
147
+ </div>
148
+ </div>
149
+ </footer>
150
+ );
151
+ }