@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 slot counter), and without
17
+ // persisting the real session that stale guest token would shadow the owner's
18
+ // — so the owner-only `inquiriesForOwner` call would come back as a guest and
19
+ // get 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 studio is single-tenant: there's no public sign-up, just the owner creating
23
+ // their one account. Whoever signs in only sees data if their email matches
24
+ // PYLON_OWNER_EMAIL — enforced by the inquiriesForOwner function.
25
+ export function AuthForm() {
26
+ const [mode, setMode] = useState<"login" | "register">("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" ? "register" : "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,258 @@
1
+ "use client";
2
+
3
+ import React, { useEffect, useState } from "react";
4
+ import { db, callFn } from "@pylonsync/react";
5
+ import { EnsureGuest } from "@pylonsync/client";
6
+ import { siteConfig } from "@/lib/site.config";
7
+
8
+ // The realtime pieces of the page, both driven by the public, PII-free Capacity
9
+ // row via `db.useQuery("Capacity")` — so when the owner books a project from the
10
+ // dashboard, the "N slots open" number drops live in every open tab. No refresh.
11
+ //
12
+ // • <LiveSlots> — the hero pill ("3 project slots open · Q3 2026").
13
+ // • <ContactForm> — the "start a project" form. submitInquiry is a public
14
+ // mutation, so it works for anonymous visitors; the Inquiry
15
+ // it writes is pure PII and can never be read back by a
16
+ // client. The form only ever reads the slot count.
17
+ //
18
+ // Both are wrapped in <EnsureGuest>, which mints an anonymous guest session so
19
+ // the sync connection (which powers the live count) is established. That session
20
+ // holds no PII and can't read the Inquiry table.
21
+
22
+ interface CapacityRow {
23
+ id: string;
24
+ label: string;
25
+ openSlots: number;
26
+ updatedAt: string;
27
+ }
28
+
29
+ function useSeedCapacity() {
30
+ // Create the Capacity row from config on first visit (idempotent server-side).
31
+ useEffect(() => {
32
+ void callFn("seedCapacity", {});
33
+ }, []);
34
+ }
35
+
36
+ /* ------------------------------ hero pill ------------------------------ */
37
+
38
+ export function LiveSlots() {
39
+ return (
40
+ <EnsureGuest fallback={<SlotsPill loading />}>
41
+ <LiveSlotsInner />
42
+ </EnsureGuest>
43
+ );
44
+ }
45
+
46
+ function LiveSlotsInner() {
47
+ useSeedCapacity();
48
+ const { data, loading } = db.useQuery<CapacityRow>("Capacity");
49
+ const row = data[0];
50
+ return <SlotsPill openSlots={row?.openSlots} label={row?.label} live={!loading} />;
51
+ }
52
+
53
+ function SlotsPill({
54
+ openSlots,
55
+ label,
56
+ live,
57
+ loading,
58
+ }: {
59
+ openSlots?: number;
60
+ label?: string;
61
+ live?: boolean;
62
+ loading?: boolean;
63
+ }) {
64
+ const open = openSlots ?? siteConfig.capacity.openSlots;
65
+ const period = label || siteConfig.capacity.label;
66
+ const bookedOut = open <= 0;
67
+ return (
68
+ <span className="inline-flex items-center gap-2 rounded-full border border-zinc-200 bg-white px-3.5 py-1.5 text-[13px] font-medium text-zinc-700 shadow-sm">
69
+ {loading ? (
70
+ <span className="inline-flex size-2 rounded-full bg-zinc-300" />
71
+ ) : (
72
+ <span className="relative flex size-2">
73
+ {live && !bookedOut ? (
74
+ <span className="absolute inline-flex size-2 animate-ping rounded-full bg-green-500/60" />
75
+ ) : null}
76
+ <span
77
+ className={"relative inline-flex size-2 rounded-full " + (bookedOut ? "bg-zinc-400" : "bg-green-600")}
78
+ />
79
+ </span>
80
+ )}
81
+ {bookedOut ? (
82
+ <span>Booked through {period}</span>
83
+ ) : (
84
+ <span>
85
+ <span className="tabular-nums text-zinc-900">{open}</span>{" "}
86
+ {open === 1 ? "project slot" : "project slots"} open · {period}
87
+ </span>
88
+ )}
89
+ </span>
90
+ );
91
+ }
92
+
93
+ /* ----------------------------- contact form ---------------------------- */
94
+
95
+ export function ContactForm() {
96
+ return (
97
+ <EnsureGuest fallback={<FormShell disabled />}>
98
+ <ContactFormInner />
99
+ </EnsureGuest>
100
+ );
101
+ }
102
+
103
+ function ContactFormInner() {
104
+ useSeedCapacity();
105
+ const { data } = db.useQuery<CapacityRow>("Capacity");
106
+ const row = data[0];
107
+ const bookedOut = (row?.openSlots ?? siteConfig.capacity.openSlots) <= 0;
108
+ return <FormShell bookedOut={bookedOut} period={row?.label} />;
109
+ }
110
+
111
+ function FormShell({
112
+ disabled,
113
+ bookedOut,
114
+ period,
115
+ }: {
116
+ disabled?: boolean;
117
+ bookedOut?: boolean;
118
+ period?: string;
119
+ }) {
120
+ const { contact } = siteConfig;
121
+ const [form, setForm] = useState({
122
+ name: "",
123
+ email: "",
124
+ company: "",
125
+ projectType: "",
126
+ budget: "",
127
+ message: "",
128
+ });
129
+ const [status, setStatus] = useState<"idle" | "sending" | "done">("idle");
130
+ const [error, setError] = useState<string | null>(null);
131
+
132
+ const set = (k: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) =>
133
+ setForm((f) => ({ ...f, [k]: e.target.value }));
134
+
135
+ async function onSubmit(e: React.FormEvent) {
136
+ e.preventDefault();
137
+ if (status === "sending") return;
138
+ if (!form.name.trim() || !form.email.trim()) {
139
+ setError("Your name and email are required.");
140
+ return;
141
+ }
142
+ setStatus("sending");
143
+ setError(null);
144
+ try {
145
+ await callFn<{ ok: boolean }>("submitInquiry", {
146
+ name: form.name.trim(),
147
+ email: form.email.trim(),
148
+ company: form.company.trim() || undefined,
149
+ projectType: form.projectType || undefined,
150
+ budget: form.budget || undefined,
151
+ message: form.message.trim() || undefined,
152
+ });
153
+ setStatus("done");
154
+ } catch (err) {
155
+ const msg = err instanceof Error ? err.message : String(err);
156
+ setError(
157
+ /valid email|INVALID_ARGS/i.test(msg)
158
+ ? "Please enter a valid email address."
159
+ : "Something went wrong — try again in a moment.",
160
+ );
161
+ setStatus("idle");
162
+ }
163
+ }
164
+
165
+ if (status === "done") {
166
+ return (
167
+ <div className="rounded-2xl border border-brand/30 bg-brand-soft/50 px-6 py-8 text-center">
168
+ <div className="mx-auto flex size-10 items-center justify-center rounded-full bg-brand text-white">
169
+ <CheckIcon />
170
+ </div>
171
+ <p className="mt-3 text-[15px] font-semibold text-zinc-900">{contact.confirmationMessage}</p>
172
+ </div>
173
+ );
174
+ }
175
+
176
+ return (
177
+ <form onSubmit={onSubmit} className="rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm sm:p-7">
178
+ {bookedOut ? (
179
+ <p className="mb-4 rounded-lg bg-amber-50 px-3 py-2 text-[13px] text-amber-700">
180
+ We&apos;re fully booked{period ? ` through ${period}` : ""} — send a note anyway and we&apos;ll reach out when a slot opens.
181
+ </p>
182
+ ) : null}
183
+ <div className="grid gap-3 sm:grid-cols-2">
184
+ <Field label="Name" required>
185
+ <input value={form.name} onChange={set("name")} autoComplete="name" aria-label="Name" className={inputCls} disabled={disabled} />
186
+ </Field>
187
+ <Field label="Email" required>
188
+ <input type="email" value={form.email} onChange={set("email")} autoComplete="email" aria-label="Email" className={inputCls} disabled={disabled} />
189
+ </Field>
190
+ <Field label="Company">
191
+ <input value={form.company} onChange={set("company")} autoComplete="organization" aria-label="Company" className={inputCls} disabled={disabled} />
192
+ </Field>
193
+ <Field label="Project type">
194
+ <select value={form.projectType} onChange={set("projectType")} aria-label="Project type" className={inputCls} disabled={disabled}>
195
+ <option value="">Select…</option>
196
+ {contact.projectTypes.map((t) => (
197
+ <option key={t} value={t}>{t}</option>
198
+ ))}
199
+ </select>
200
+ </Field>
201
+ </div>
202
+ <div className="mt-3">
203
+ <Field label="Budget">
204
+ <select value={form.budget} onChange={set("budget")} aria-label="Budget" className={inputCls} disabled={disabled}>
205
+ <option value="">Select…</option>
206
+ {contact.budgets.map((b) => (
207
+ <option key={b} value={b}>{b}</option>
208
+ ))}
209
+ </select>
210
+ </Field>
211
+ </div>
212
+ <div className="mt-3">
213
+ <Field label="What are you building?">
214
+ <textarea
215
+ value={form.message}
216
+ onChange={set("message")}
217
+ rows={4}
218
+ aria-label="Message"
219
+ placeholder="A sentence or two about the project, timeline, and what success looks like."
220
+ className={inputCls + " resize-none py-2.5"}
221
+ disabled={disabled}
222
+ />
223
+ </Field>
224
+ </div>
225
+ {error ? <p className="mt-3 text-[13px] text-red-600">{error}</p> : null}
226
+ <button
227
+ type="submit"
228
+ disabled={status === "sending" || disabled}
229
+ className="mt-5 inline-flex h-11 w-full items-center justify-center rounded-full bg-brand text-[15px] font-medium text-white transition-opacity hover:opacity-90 disabled:opacity-60 sm:w-auto sm:px-7"
230
+ >
231
+ {status === "sending" ? "Sending…" : "Send inquiry"}
232
+ </button>
233
+ </form>
234
+ );
235
+ }
236
+
237
+ function Field({ label, required, children }: { label: string; required?: boolean; children: React.ReactNode }) {
238
+ return (
239
+ <label className="block">
240
+ <span className="mb-1.5 block text-[12.5px] font-medium text-zinc-600">
241
+ {label}
242
+ {required ? <span className="text-brand"> *</span> : null}
243
+ </span>
244
+ {children}
245
+ </label>
246
+ );
247
+ }
248
+
249
+ const inputCls =
250
+ "h-10 w-full rounded-lg border border-zinc-300 bg-white px-3 text-[14px] text-zinc-900 outline-none transition placeholder:text-zinc-400 focus:border-brand focus:ring-2 focus:ring-brand/20 disabled:opacity-60";
251
+
252
+ function CheckIcon() {
253
+ return (
254
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
255
+ <path d="M20 6 9 17l-5-5" />
256
+ </svg>
257
+ );
258
+ }
@@ -0,0 +1,286 @@
1
+ "use client";
2
+
3
+ import React, { useEffect, useState } from "react";
4
+ import { db, callFn } from "@pylonsync/react";
5
+ import { useAuth } from "@pylonsync/client";
6
+ import type { InquiryRow, OwnerInquiriesResult } from "@/lib/agency";
7
+
8
+ interface CapacityRow {
9
+ id: string;
10
+ label: string;
11
+ openSlots: number;
12
+ updatedAt: string;
13
+ }
14
+
15
+ // The owner's live pipeline. Liveness rides the SAME public Capacity row the
16
+ // landing page uses: `db.useQuery("Capacity")` re-renders the instant slots
17
+ // change (cross-tab, via the replica). The leads themselves never sync — they
18
+ // come from the owner-gated `inquiriesForOwner`, (re)fetched on mount and
19
+ // whenever capacity changes (which is exactly when a lead is booked/released).
20
+ // So the pipeline stays live, but PII only ever travels through the gated call.
21
+ export function AgencyDashboard({ userEmail }: { userEmail: string }) {
22
+ const { data: caps } = db.useQuery<CapacityRow>("Capacity");
23
+ const cap = caps[0];
24
+ const liveKey = `${cap?.openSlots ?? "?"}:${cap?.label ?? ""}`;
25
+
26
+ const [inquiries, setInquiries] = useState<InquiryRow[] | null>(null);
27
+ const [denied, setDenied] = useState(false);
28
+ const [error, setError] = useState<string | null>(null);
29
+ const [busyId, setBusyId] = useState<string | null>(null);
30
+
31
+ async function load() {
32
+ try {
33
+ const r = await callFn<OwnerInquiriesResult>("inquiriesForOwner", {});
34
+ if (!r.authorized) setDenied(true);
35
+ else {
36
+ setInquiries(r.inquiries);
37
+ setDenied(false);
38
+ setError(null);
39
+ }
40
+ } catch (e) {
41
+ setError(e instanceof Error ? e.message : String(e));
42
+ }
43
+ }
44
+
45
+ useEffect(() => {
46
+ void load();
47
+ // eslint-disable-next-line react-hooks/exhaustive-deps
48
+ }, [liveKey]);
49
+
50
+ async function act(id: string, fn: "bookInquiry" | "declineInquiry") {
51
+ setBusyId(id);
52
+ try {
53
+ await callFn(fn, { inquiryId: id });
54
+ await load();
55
+ } finally {
56
+ setBusyId(null);
57
+ }
58
+ }
59
+
60
+ if (denied) return <OwnerOnly email={userEmail} />;
61
+ if (error) {
62
+ return <div className="rounded-xl border border-red-200 bg-red-50 px-5 py-4 text-sm text-red-700">{error}</div>;
63
+ }
64
+ if (!inquiries) return <Skeleton />;
65
+
66
+ const newCount = inquiries.filter((i) => i.status === "new").length;
67
+ const bookedCount = inquiries.filter((i) => i.status === "booked").length;
68
+ const active = inquiries.filter((i) => i.status !== "declined");
69
+
70
+ return (
71
+ <div className="space-y-8">
72
+ <div>
73
+ <h1 className="text-xl font-semibold tracking-tight">Pipeline</h1>
74
+ <p className="mt-1 text-sm text-zinc-500">
75
+ Live — leads land here the moment they&apos;re sent. Booking one drops the open-slot count
76
+ on your site instantly.
77
+ </p>
78
+ </div>
79
+
80
+ <div className="grid gap-4 sm:grid-cols-3">
81
+ <Stat label="Open slots" value={String(cap?.openSlots ?? 0)} hint={cap?.label} />
82
+ <Stat label="New leads" value={String(newCount)} />
83
+ <Stat label="Booked" value={String(bookedCount)} />
84
+ </div>
85
+
86
+ <CapacityCard cap={cap} />
87
+
88
+ {/* Inquiries */}
89
+ <div className="rounded-xl border border-zinc-200 bg-white">
90
+ <div className="border-b border-zinc-100 px-4 py-3 text-sm font-semibold text-zinc-900">
91
+ Inquiries <span className="font-normal text-zinc-400">({active.length})</span>
92
+ </div>
93
+ {inquiries.length === 0 ? (
94
+ <p className="p-8 text-center text-sm text-zinc-500">No inquiries yet — share your site.</p>
95
+ ) : (
96
+ <ul className="divide-y divide-zinc-100">
97
+ {inquiries.map((i) => (
98
+ <li key={i.id} className="px-4 py-3.5">
99
+ <div className="flex flex-wrap items-start justify-between gap-x-4 gap-y-2">
100
+ <div className="min-w-0 flex-1">
101
+ <div className="flex flex-wrap items-center gap-2">
102
+ <span className="text-[14px] font-medium text-zinc-900">{i.name}</span>
103
+ <StatusBadge status={i.status} />
104
+ </div>
105
+ <div className="mt-0.5 truncate text-[12.5px] text-zinc-500">
106
+ {i.email}
107
+ {i.company ? ` · ${i.company}` : ""}
108
+ {i.projectType ? ` · ${i.projectType}` : ""}
109
+ {i.budget ? ` · ${i.budget}` : ""}
110
+ </div>
111
+ {i.message ? (
112
+ <p className="mt-1.5 line-clamp-2 text-[13px] leading-relaxed text-zinc-600">{i.message}</p>
113
+ ) : null}
114
+ </div>
115
+ <div className="flex items-center gap-2">
116
+ {i.status !== "booked" ? (
117
+ <button
118
+ type="button"
119
+ disabled={busyId === i.id}
120
+ onClick={() => act(i.id, "bookInquiry")}
121
+ className="rounded-md bg-brand px-3 py-1.5 text-[12.5px] font-medium text-white transition-opacity hover:opacity-90 disabled:opacity-50"
122
+ >
123
+ {busyId === i.id ? "…" : "Book"}
124
+ </button>
125
+ ) : null}
126
+ {i.status !== "declined" ? (
127
+ <button
128
+ type="button"
129
+ disabled={busyId === i.id}
130
+ onClick={() => act(i.id, "declineInquiry")}
131
+ 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"
132
+ >
133
+ {i.status === "booked" ? "Release" : "Decline"}
134
+ </button>
135
+ ) : null}
136
+ </div>
137
+ </div>
138
+ </li>
139
+ ))}
140
+ </ul>
141
+ )}
142
+ </div>
143
+ </div>
144
+ );
145
+ }
146
+
147
+ // Editable capacity — the number the public hero shows live. Saving calls
148
+ // setCapacity; the change syncs straight to every open landing page.
149
+ function CapacityCard({ cap }: { cap?: CapacityRow }) {
150
+ const [label, setLabel] = useState(cap?.label ?? "");
151
+ const [slots, setSlots] = useState(String(cap?.openSlots ?? 0));
152
+ const [saving, setSaving] = useState(false);
153
+ const [saved, setSaved] = useState(false);
154
+
155
+ // Keep inputs in sync if the live row changes underneath us (e.g. a booking).
156
+ useEffect(() => {
157
+ setLabel(cap?.label ?? "");
158
+ setSlots(String(cap?.openSlots ?? 0));
159
+ }, [cap?.label, cap?.openSlots]);
160
+
161
+ async function save(e: React.FormEvent) {
162
+ e.preventDefault();
163
+ setSaving(true);
164
+ setSaved(false);
165
+ try {
166
+ await callFn("setCapacity", { label: label.trim(), openSlots: Math.max(0, parseInt(slots, 10) || 0) });
167
+ setSaved(true);
168
+ } finally {
169
+ setSaving(false);
170
+ }
171
+ }
172
+
173
+ return (
174
+ <form onSubmit={save} className="rounded-xl border border-zinc-200 bg-white p-4">
175
+ <div className="text-sm font-semibold text-zinc-900">Availability</div>
176
+ <p className="mt-1 text-[13px] text-zinc-500">Shown live on your site as “N project slots open”.</p>
177
+ <div className="mt-4 flex flex-wrap items-end gap-3">
178
+ <label className="block">
179
+ <span className="mb-1 block text-[12px] font-medium text-zinc-600">Booking window</span>
180
+ <input
181
+ value={label}
182
+ onChange={(e) => { setLabel(e.target.value); setSaved(false); }}
183
+ placeholder="Q3 2026"
184
+ className="h-9 w-40 rounded-lg border border-zinc-300 px-3 text-[14px] outline-none focus:border-brand focus:ring-2 focus:ring-brand/20"
185
+ />
186
+ </label>
187
+ <label className="block">
188
+ <span className="mb-1 block text-[12px] font-medium text-zinc-600">Open slots</span>
189
+ <input
190
+ type="number"
191
+ min={0}
192
+ value={slots}
193
+ onChange={(e) => { setSlots(e.target.value); setSaved(false); }}
194
+ className="h-9 w-24 rounded-lg border border-zinc-300 px-3 text-[14px] tabular-nums outline-none focus:border-brand focus:ring-2 focus:ring-brand/20"
195
+ />
196
+ </label>
197
+ <button
198
+ type="submit"
199
+ disabled={saving}
200
+ className="h-9 rounded-lg bg-zinc-900 px-4 text-[13px] font-medium text-white transition-colors hover:bg-zinc-700 disabled:opacity-50"
201
+ >
202
+ {saving ? "Saving…" : saved ? "Saved ✓" : "Save"}
203
+ </button>
204
+ </div>
205
+ </form>
206
+ );
207
+ }
208
+
209
+ function Stat({ label, value, hint }: { label: string; value: string; hint?: string }) {
210
+ return (
211
+ <div className="rounded-xl border border-zinc-200 bg-white p-4">
212
+ <div className="text-[11px] font-medium uppercase tracking-wide text-zinc-400">{label}</div>
213
+ <div className="mt-1 text-2xl font-semibold tabular-nums text-zinc-900">{value}</div>
214
+ {hint ? <div className="mt-0.5 text-[12px] text-zinc-400">{hint}</div> : null}
215
+ </div>
216
+ );
217
+ }
218
+
219
+ function StatusBadge({ status }: { status: string }) {
220
+ const tone =
221
+ status === "booked"
222
+ ? "bg-green-50 text-green-700"
223
+ : status === "declined"
224
+ ? "bg-zinc-100 text-zinc-400"
225
+ : "bg-amber-50 text-amber-700"; // new
226
+ const label = status === "new" ? "new lead" : status;
227
+ return <span className={"rounded-full px-2 py-0.5 text-[10px] font-medium capitalize " + tone}>{label}</span>;
228
+ }
229
+
230
+ function OwnerOnly({ email }: { email: string }) {
231
+ return (
232
+ <div className="rounded-xl border border-dashed border-zinc-300 px-6 py-12 text-center">
233
+ <h1 className="text-lg font-semibold">This dashboard is owner-only</h1>
234
+ <p className="mx-auto mt-2 max-w-md text-sm text-zinc-500">
235
+ You&apos;re signed in as <span className="font-medium text-zinc-700">{email || "this account"}</span>.
236
+ Only the studio owner can see inquiries. Set{" "}
237
+ <code className="rounded bg-zinc-100 px-1.5 py-0.5 text-[12px]">PYLON_OWNER_EMAIL={email || "you@studio.com"}</code>{" "}
238
+ in your <code className="rounded bg-zinc-100 px-1.5 py-0.5 text-[12px]">.env</code>, restart, and reload —
239
+ or sign in with the owner account.
240
+ </p>
241
+ </div>
242
+ );
243
+ }
244
+
245
+ export function UserMenu({ email }: { email: string }) {
246
+ const { signOut } = useAuth();
247
+ const initial = (email.trim()[0] || "?").toUpperCase();
248
+ async function onSignOut() {
249
+ await signOut();
250
+ window.location.assign("/");
251
+ }
252
+ return (
253
+ <details className="group relative">
254
+ <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">
255
+ {initial}
256
+ </summary>
257
+ <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)]">
258
+ <div className="border-b border-zinc-100 px-3 py-2">
259
+ <div className="truncate text-[13px] font-medium text-zinc-900">{email || "Signed in"}</div>
260
+ </div>
261
+ <button
262
+ type="button"
263
+ onClick={onSignOut}
264
+ className="flex w-full items-center px-3 py-2 text-left text-[13px] text-zinc-700 transition-colors hover:bg-zinc-50"
265
+ >
266
+ Sign out
267
+ </button>
268
+ </div>
269
+ </details>
270
+ );
271
+ }
272
+
273
+ function Skeleton() {
274
+ return (
275
+ <div className="space-y-8">
276
+ <div className="h-6 w-28 animate-pulse rounded bg-zinc-100" />
277
+ <div className="grid gap-4 sm:grid-cols-3">
278
+ {[0, 1, 2].map((i) => (
279
+ <div key={i} className="h-20 animate-pulse rounded-xl bg-zinc-100" />
280
+ ))}
281
+ </div>
282
+ <div className="h-28 animate-pulse rounded-xl bg-zinc-100" />
283
+ <div className="h-48 animate-pulse rounded-xl bg-zinc-100" />
284
+ </div>
285
+ );
286
+ }