@pylonsync/create-pylon 0.3.274 → 0.3.276

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 (340) 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 +1440 -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 +249 -0
  17. package/templates/agency/app/robots.ts +12 -0
  18. package/templates/agency/app/seeder.tsx +26 -0
  19. package/templates/agency/app/sitemap.ts +9 -0
  20. package/templates/agency/app/work/[slug]/page.tsx +182 -0
  21. package/templates/agency/app/work/page.tsx +83 -0
  22. package/templates/agency/app.ts +284 -0
  23. package/templates/agency/components/marketing.tsx +187 -0
  24. package/templates/agency/components/section-scroller.tsx +35 -0
  25. package/templates/agency/components/ui/button.tsx +56 -0
  26. package/templates/agency/components/ui/card.tsx +90 -0
  27. package/templates/agency/components.json +20 -0
  28. package/templates/agency/functions/bookInquiry.ts +42 -0
  29. package/templates/agency/functions/clientsForOwner.ts +27 -0
  30. package/templates/agency/functions/declineInquiry.ts +41 -0
  31. package/templates/agency/functions/deleteClient.ts +27 -0
  32. package/templates/agency/functions/deleteInvoice.ts +19 -0
  33. package/templates/agency/functions/deleteProject.ts +20 -0
  34. package/templates/agency/functions/inquiriesForOwner.ts +31 -0
  35. package/templates/agency/functions/invoicesForOwner.ts +27 -0
  36. package/templates/agency/functions/seedCapacity.ts +26 -0
  37. package/templates/agency/functions/seedProjects.ts +41 -0
  38. package/templates/agency/functions/seedStudioBackoffice.ts +74 -0
  39. package/templates/agency/functions/setCapacity.ts +32 -0
  40. package/templates/agency/functions/setInvoiceStatus.ts +27 -0
  41. package/templates/agency/functions/setProjectFlags.ts +35 -0
  42. package/templates/agency/functions/submitInquiry.ts +55 -0
  43. package/templates/agency/functions/upsertClient.ts +73 -0
  44. package/templates/agency/functions/upsertInvoice.ts +113 -0
  45. package/templates/agency/functions/upsertProject.ts +97 -0
  46. package/templates/agency/gitignore +10 -0
  47. package/templates/agency/lib/agency.ts +189 -0
  48. package/templates/agency/lib/invoice-pdf.tsx +174 -0
  49. package/templates/agency/lib/owner.ts +26 -0
  50. package/templates/agency/lib/site.config.ts +418 -0
  51. package/templates/agency/lib/utils.ts +10 -0
  52. package/templates/agency/package.json +35 -0
  53. package/templates/agency/tsconfig.json +18 -0
  54. package/templates/ai-chat/.env.example +33 -0
  55. package/templates/ai-chat/AGENTS.md +61 -0
  56. package/templates/ai-chat/README.md +99 -0
  57. package/templates/ai-chat/app/auth-form.tsx +124 -0
  58. package/templates/ai-chat/app/chat-client.tsx +727 -0
  59. package/templates/ai-chat/app/error.tsx +26 -0
  60. package/templates/ai-chat/app/globals.css +148 -0
  61. package/templates/ai-chat/app/layout.tsx +75 -0
  62. package/templates/ai-chat/app/login/page.tsx +39 -0
  63. package/templates/ai-chat/app/not-found.tsx +19 -0
  64. package/templates/ai-chat/app/page.tsx +23 -0
  65. package/templates/ai-chat/app.ts +121 -0
  66. package/templates/ai-chat/components.json +20 -0
  67. package/templates/ai-chat/functions/deleteConversation.ts +33 -0
  68. package/templates/ai-chat/gitignore +10 -0
  69. package/templates/ai-chat/lib/site.config.ts +103 -0
  70. package/templates/ai-chat/lib/utils.ts +10 -0
  71. package/templates/ai-chat/package.json +34 -0
  72. package/templates/ai-chat/tsconfig.json +18 -0
  73. package/templates/ai-studio/.env.example +19 -0
  74. package/templates/ai-studio/AGENTS.md +61 -0
  75. package/templates/ai-studio/README.md +83 -0
  76. package/templates/ai-studio/app/auth-form.tsx +124 -0
  77. package/templates/ai-studio/app/error.tsx +26 -0
  78. package/templates/ai-studio/app/globals.css +148 -0
  79. package/templates/ai-studio/app/layout.tsx +75 -0
  80. package/templates/ai-studio/app/login/page.tsx +39 -0
  81. package/templates/ai-studio/app/not-found.tsx +19 -0
  82. package/templates/ai-studio/app/page.tsx +34 -0
  83. package/templates/ai-studio/app/studio-client.tsx +357 -0
  84. package/templates/ai-studio/app.ts +108 -0
  85. package/templates/ai-studio/components.json +20 -0
  86. package/templates/ai-studio/functions/_getGeneration.ts +25 -0
  87. package/templates/ai-studio/functions/_updateGeneration.ts +37 -0
  88. package/templates/ai-studio/functions/generate.ts +42 -0
  89. package/templates/ai-studio/functions/pollGeneration.ts +134 -0
  90. package/templates/ai-studio/gitignore +10 -0
  91. package/templates/ai-studio/lib/site.config.ts +80 -0
  92. package/templates/ai-studio/lib/studio.ts +52 -0
  93. package/templates/ai-studio/lib/utils.ts +10 -0
  94. package/templates/ai-studio/package.json +34 -0
  95. package/templates/ai-studio/tsconfig.json +18 -0
  96. package/templates/creator/.env.example +12 -0
  97. package/templates/creator/AGENTS.md +61 -0
  98. package/templates/creator/README.md +67 -0
  99. package/templates/creator/app/auth-form.tsx +129 -0
  100. package/templates/creator/app/dashboard/dashboard-client.tsx +297 -0
  101. package/templates/creator/app/dashboard/page.tsx +70 -0
  102. package/templates/creator/app/error.tsx +26 -0
  103. package/templates/creator/app/globals.css +148 -0
  104. package/templates/creator/app/layout.tsx +160 -0
  105. package/templates/creator/app/login/page.tsx +39 -0
  106. package/templates/creator/app/newsletter-signup.tsx +162 -0
  107. package/templates/creator/app/not-found.tsx +19 -0
  108. package/templates/creator/app/page.tsx +160 -0
  109. package/templates/creator/app/robots.ts +12 -0
  110. package/templates/creator/app/sitemap.ts +9 -0
  111. package/templates/creator/app.ts +134 -0
  112. package/templates/creator/components/marketing.tsx +148 -0
  113. package/templates/creator/components/section-scroller.tsx +35 -0
  114. package/templates/creator/components/ui/button.tsx +56 -0
  115. package/templates/creator/components/ui/card.tsx +90 -0
  116. package/templates/creator/components.json +20 -0
  117. package/templates/creator/functions/subscribe.ts +82 -0
  118. package/templates/creator/functions/subscriberStats.ts +75 -0
  119. package/templates/creator/gitignore +10 -0
  120. package/templates/creator/lib/owner.ts +26 -0
  121. package/templates/creator/lib/site.config.ts +173 -0
  122. package/templates/creator/lib/stats.ts +30 -0
  123. package/templates/creator/lib/utils.ts +10 -0
  124. package/templates/creator/package.json +34 -0
  125. package/templates/creator/tsconfig.json +18 -0
  126. package/templates/default/app/layout.tsx +26 -27
  127. package/templates/default/app/page.tsx +90 -274
  128. package/templates/default/lib/products.ts +9 -122
  129. package/templates/default/lib/site.config.ts +739 -0
  130. package/templates/default/lib/site.ts +14 -261
  131. package/templates/directory/.env.example +12 -0
  132. package/templates/directory/AGENTS.md +61 -0
  133. package/templates/directory/README.md +80 -0
  134. package/templates/directory/app/auth-form.tsx +129 -0
  135. package/templates/directory/app/dashboard/dashboard-client.tsx +205 -0
  136. package/templates/directory/app/dashboard/page.tsx +70 -0
  137. package/templates/directory/app/directory-browse.tsx +328 -0
  138. package/templates/directory/app/error.tsx +26 -0
  139. package/templates/directory/app/globals.css +148 -0
  140. package/templates/directory/app/layout.tsx +171 -0
  141. package/templates/directory/app/login/page.tsx +39 -0
  142. package/templates/directory/app/not-found.tsx +19 -0
  143. package/templates/directory/app/page.tsx +50 -0
  144. package/templates/directory/app/robots.ts +12 -0
  145. package/templates/directory/app/sitemap.ts +9 -0
  146. package/templates/directory/app/submit/page.tsx +30 -0
  147. package/templates/directory/app/submit-form.tsx +151 -0
  148. package/templates/directory/app.ts +146 -0
  149. package/templates/directory/components/marketing.tsx +148 -0
  150. package/templates/directory/components/section-scroller.tsx +35 -0
  151. package/templates/directory/components/ui/button.tsx +56 -0
  152. package/templates/directory/components/ui/card.tsx +90 -0
  153. package/templates/directory/components.json +20 -0
  154. package/templates/directory/functions/approveSubmission.ts +45 -0
  155. package/templates/directory/functions/rejectSubmission.ts +20 -0
  156. package/templates/directory/functions/seedListings.ts +33 -0
  157. package/templates/directory/functions/submissionsForOwner.ts +29 -0
  158. package/templates/directory/functions/submitListing.ts +63 -0
  159. package/templates/directory/functions/upvote.ts +24 -0
  160. package/templates/directory/gitignore +10 -0
  161. package/templates/directory/lib/directory.ts +45 -0
  162. package/templates/directory/lib/owner.ts +26 -0
  163. package/templates/directory/lib/site.config.ts +130 -0
  164. package/templates/directory/lib/utils.ts +10 -0
  165. package/templates/directory/package.json +34 -0
  166. package/templates/directory/tsconfig.json +18 -0
  167. package/templates/local-service/.env.example +12 -0
  168. package/templates/local-service/AGENTS.md +61 -0
  169. package/templates/local-service/README.md +82 -0
  170. package/templates/local-service/app/auth-form.tsx +129 -0
  171. package/templates/local-service/app/booking-widget.tsx +399 -0
  172. package/templates/local-service/app/dashboard/dashboard-client.tsx +304 -0
  173. package/templates/local-service/app/dashboard/page.tsx +63 -0
  174. package/templates/local-service/app/error.tsx +26 -0
  175. package/templates/local-service/app/globals.css +148 -0
  176. package/templates/local-service/app/layout.tsx +151 -0
  177. package/templates/local-service/app/login/page.tsx +39 -0
  178. package/templates/local-service/app/not-found.tsx +19 -0
  179. package/templates/local-service/app/page.tsx +233 -0
  180. package/templates/local-service/app/robots.ts +12 -0
  181. package/templates/local-service/app/sitemap.ts +9 -0
  182. package/templates/local-service/app.ts +131 -0
  183. package/templates/local-service/components/marketing.tsx +162 -0
  184. package/templates/local-service/components/section-scroller.tsx +35 -0
  185. package/templates/local-service/components/ui/button.tsx +56 -0
  186. package/templates/local-service/components/ui/card.tsx +90 -0
  187. package/templates/local-service/components.json +20 -0
  188. package/templates/local-service/functions/bookingsForOwner.ts +30 -0
  189. package/templates/local-service/functions/cancelBooking.ts +27 -0
  190. package/templates/local-service/functions/confirmBooking.ts +18 -0
  191. package/templates/local-service/functions/createBooking.ts +98 -0
  192. package/templates/local-service/gitignore +10 -0
  193. package/templates/local-service/lib/booking.ts +24 -0
  194. package/templates/local-service/lib/owner.ts +26 -0
  195. package/templates/local-service/lib/site.config.ts +232 -0
  196. package/templates/local-service/lib/slots.ts +97 -0
  197. package/templates/local-service/lib/utils.ts +10 -0
  198. package/templates/local-service/package.json +34 -0
  199. package/templates/local-service/tsconfig.json +18 -0
  200. package/templates/marketplace/.env.example +9 -0
  201. package/templates/marketplace/AGENTS.md +61 -0
  202. package/templates/marketplace/README.md +78 -0
  203. package/templates/marketplace/app/_components/CategoryIcon.tsx +40 -0
  204. package/templates/marketplace/app/error.tsx +26 -0
  205. package/templates/marketplace/app/globals.css +64 -0
  206. package/templates/marketplace/app/layout.tsx +60 -0
  207. package/templates/marketplace/app/listing/[id]/page.tsx +163 -0
  208. package/templates/marketplace/app/me/page.tsx +15 -0
  209. package/templates/marketplace/app/not-found.tsx +20 -0
  210. package/templates/marketplace/app/page.tsx +159 -0
  211. package/templates/marketplace/app/robots.ts +12 -0
  212. package/templates/marketplace/app/sell/page.tsx +26 -0
  213. package/templates/marketplace/app/sitemap.ts +14 -0
  214. package/templates/marketplace/app.ts +190 -0
  215. package/templates/marketplace/client/AuthNav.tsx +46 -0
  216. package/templates/marketplace/client/LiveTicker.tsx +104 -0
  217. package/templates/marketplace/client/LoginCard.tsx +130 -0
  218. package/templates/marketplace/client/MarketProvider.tsx +148 -0
  219. package/templates/marketplace/client/MyMarket.tsx +180 -0
  220. package/templates/marketplace/client/OfferPanel.tsx +355 -0
  221. package/templates/marketplace/client/SeedOnEmpty.tsx +26 -0
  222. package/templates/marketplace/client/SellForm.tsx +160 -0
  223. package/templates/marketplace/client/WatchButton.tsx +88 -0
  224. package/templates/marketplace/client/market.ts +341 -0
  225. package/templates/marketplace/functions/buyNow.ts +78 -0
  226. package/templates/marketplace/functions/makeOffer.ts +65 -0
  227. package/templates/marketplace/functions/respondToOffer.ts +62 -0
  228. package/templates/marketplace/functions/seedMarket.ts +90 -0
  229. package/templates/marketplace/gitignore +10 -0
  230. package/templates/marketplace/package.json +35 -0
  231. package/templates/marketplace/tsconfig.json +14 -0
  232. package/templates/marketplace/ui/badge.tsx +30 -0
  233. package/templates/marketplace/ui/button.tsx +49 -0
  234. package/templates/marketplace/ui/card.tsx +48 -0
  235. package/templates/marketplace/ui/input.tsx +17 -0
  236. package/templates/marketplace/ui/label.tsx +18 -0
  237. package/templates/marketplace/ui/textarea.tsx +17 -0
  238. package/templates/marketplace/ui/tokens.css +32 -0
  239. package/templates/marketplace/ui/utils.ts +6 -0
  240. package/templates/restaurant/.env.example +12 -0
  241. package/templates/restaurant/AGENTS.md +61 -0
  242. package/templates/restaurant/README.md +77 -0
  243. package/templates/restaurant/app/auth-form.tsx +129 -0
  244. package/templates/restaurant/app/dashboard/dashboard-client.tsx +263 -0
  245. package/templates/restaurant/app/dashboard/page.tsx +59 -0
  246. package/templates/restaurant/app/error.tsx +26 -0
  247. package/templates/restaurant/app/globals.css +148 -0
  248. package/templates/restaurant/app/layout.tsx +151 -0
  249. package/templates/restaurant/app/login/page.tsx +39 -0
  250. package/templates/restaurant/app/not-found.tsx +19 -0
  251. package/templates/restaurant/app/page.tsx +194 -0
  252. package/templates/restaurant/app/reservation-widget.tsx +359 -0
  253. package/templates/restaurant/app/robots.ts +12 -0
  254. package/templates/restaurant/app/sitemap.ts +9 -0
  255. package/templates/restaurant/app.ts +115 -0
  256. package/templates/restaurant/components/marketing.tsx +162 -0
  257. package/templates/restaurant/components/section-scroller.tsx +35 -0
  258. package/templates/restaurant/components/ui/button.tsx +56 -0
  259. package/templates/restaurant/components/ui/card.tsx +90 -0
  260. package/templates/restaurant/components.json +20 -0
  261. package/templates/restaurant/functions/cancelReservation.ts +26 -0
  262. package/templates/restaurant/functions/confirmReservation.ts +17 -0
  263. package/templates/restaurant/functions/createReservation.ts +92 -0
  264. package/templates/restaurant/functions/reservationsForOwner.ts +28 -0
  265. package/templates/restaurant/gitignore +10 -0
  266. package/templates/restaurant/lib/owner.ts +26 -0
  267. package/templates/restaurant/lib/reservation.ts +22 -0
  268. package/templates/restaurant/lib/site.config.ts +218 -0
  269. package/templates/restaurant/lib/slots.ts +55 -0
  270. package/templates/restaurant/lib/utils.ts +10 -0
  271. package/templates/restaurant/package.json +34 -0
  272. package/templates/restaurant/tsconfig.json +18 -0
  273. package/templates/shop/.env.example +32 -0
  274. package/templates/shop/AGENTS.md +61 -0
  275. package/templates/shop/README.md +102 -0
  276. package/templates/shop/app/auth-form.tsx +129 -0
  277. package/templates/shop/app/dashboard/dashboard-client.tsx +264 -0
  278. package/templates/shop/app/dashboard/page.tsx +59 -0
  279. package/templates/shop/app/error.tsx +26 -0
  280. package/templates/shop/app/globals.css +148 -0
  281. package/templates/shop/app/layout.tsx +160 -0
  282. package/templates/shop/app/login/page.tsx +39 -0
  283. package/templates/shop/app/not-found.tsx +19 -0
  284. package/templates/shop/app/page.tsx +95 -0
  285. package/templates/shop/app/robots.ts +12 -0
  286. package/templates/shop/app/shop-client.tsx +436 -0
  287. package/templates/shop/app/sitemap.ts +9 -0
  288. package/templates/shop/app/success/page.tsx +33 -0
  289. package/templates/shop/app.ts +134 -0
  290. package/templates/shop/components/marketing.tsx +96 -0
  291. package/templates/shop/components/section-scroller.tsx +35 -0
  292. package/templates/shop/components/ui/button.tsx +56 -0
  293. package/templates/shop/components/ui/card.tsx +90 -0
  294. package/templates/shop/components.json +20 -0
  295. package/templates/shop/functions/cancelOrder.ts +33 -0
  296. package/templates/shop/functions/checkout.ts +130 -0
  297. package/templates/shop/functions/fulfillOrder.ts +17 -0
  298. package/templates/shop/functions/markGroupPaid.ts +26 -0
  299. package/templates/shop/functions/ordersForOwner.ts +28 -0
  300. package/templates/shop/functions/releaseGroup.ts +36 -0
  301. package/templates/shop/functions/reserveCart.ts +87 -0
  302. package/templates/shop/functions/restockProduct.ts +23 -0
  303. package/templates/shop/functions/seedProducts.ts +30 -0
  304. package/templates/shop/functions/stripeWebhook.ts +72 -0
  305. package/templates/shop/gitignore +10 -0
  306. package/templates/shop/lib/owner.ts +26 -0
  307. package/templates/shop/lib/shop.ts +45 -0
  308. package/templates/shop/lib/site.config.ts +198 -0
  309. package/templates/shop/lib/utils.ts +10 -0
  310. package/templates/shop/package.json +35 -0
  311. package/templates/shop/tsconfig.json +18 -0
  312. package/templates/waitlist/.env.example +12 -0
  313. package/templates/waitlist/AGENTS.md +61 -0
  314. package/templates/waitlist/README.md +81 -0
  315. package/templates/waitlist/app/auth-form.tsx +129 -0
  316. package/templates/waitlist/app/dashboard/dashboard-client.tsx +297 -0
  317. package/templates/waitlist/app/dashboard/page.tsx +70 -0
  318. package/templates/waitlist/app/error.tsx +26 -0
  319. package/templates/waitlist/app/globals.css +148 -0
  320. package/templates/waitlist/app/layout.tsx +158 -0
  321. package/templates/waitlist/app/login/page.tsx +39 -0
  322. package/templates/waitlist/app/not-found.tsx +19 -0
  323. package/templates/waitlist/app/page.tsx +119 -0
  324. package/templates/waitlist/app/robots.ts +12 -0
  325. package/templates/waitlist/app/sitemap.ts +9 -0
  326. package/templates/waitlist/app/waitlist-hero.tsx +219 -0
  327. package/templates/waitlist/app.ts +134 -0
  328. package/templates/waitlist/components/marketing.tsx +96 -0
  329. package/templates/waitlist/components/ui/button.tsx +56 -0
  330. package/templates/waitlist/components/ui/card.tsx +90 -0
  331. package/templates/waitlist/components.json +20 -0
  332. package/templates/waitlist/functions/joinWaitlist.ts +82 -0
  333. package/templates/waitlist/functions/waitlistStats.ts +75 -0
  334. package/templates/waitlist/gitignore +10 -0
  335. package/templates/waitlist/lib/owner.ts +26 -0
  336. package/templates/waitlist/lib/site.config.ts +178 -0
  337. package/templates/waitlist/lib/stats.ts +30 -0
  338. package/templates/waitlist/lib/utils.ts +10 -0
  339. package/templates/waitlist/package.json +34 -0
  340. package/templates/waitlist/tsconfig.json +18 -0
@@ -0,0 +1,355 @@
1
+ "use client";
2
+
3
+ import React, { useState } from "react";
4
+ import { db } from "@pylonsync/react";
5
+ import { Button } from "../ui/button";
6
+ import { Input } from "../ui/input";
7
+ import { Textarea } from "../ui/textarea";
8
+ import { Badge } from "../ui/badge";
9
+ import { AuthGate, MarketProvider, useIdentity } from "./MarketProvider";
10
+ import { money, timeAgo, type Offer } from "./market";
11
+
12
+ interface Props {
13
+ listingId: string;
14
+ sellerId: string;
15
+ sellerName: string;
16
+ title: string;
17
+ price: number;
18
+ status: "active" | "sold";
19
+ }
20
+
21
+ type BadgeVariant = "default" | "secondary" | "destructive" | "outline" | "success" | "warning";
22
+
23
+ const statusVariant: Record<string, BadgeVariant> = {
24
+ pending: "warning",
25
+ accepted: "success",
26
+ declined: "outline",
27
+ };
28
+
29
+ function Panel(props: Props) {
30
+ const { listingId, sellerId, price } = props;
31
+ const identity = useIdentity();
32
+ const isSeller = !!identity && identity.userId === sellerId;
33
+ const isSold = props.status === "sold";
34
+
35
+ // The live query — every offer on this listing, newest first. Reads are
36
+ // public, so this runs for signed-out visitors too; it just lights up the
37
+ // moment a buyer in another tab makes an offer.
38
+ const { data } = db.useQuery<Offer>("Offer", {
39
+ where: { listingId },
40
+ orderBy: { createdAt: "desc" },
41
+ });
42
+ const offers = data ?? [];
43
+ const myOffer = identity
44
+ ? offers.find((o) => o.buyerId === identity.userId)
45
+ : undefined;
46
+
47
+ if (isSeller) {
48
+ return <SellerView offers={offers} />;
49
+ }
50
+ // Making an offer needs a real account — gate it (prefilled demo login).
51
+ return (
52
+ <AuthGate
53
+ title="Sign in to make an offer"
54
+ blurb="Offers are tied to a real account so the seller knows who's bidding. The demo account is prefilled — just hit Log in."
55
+ >
56
+ <BuyerView
57
+ {...props}
58
+ myOffer={myOffer}
59
+ isSold={isSold}
60
+ suggestedPrice={price}
61
+ />
62
+ </AuthGate>
63
+ );
64
+ }
65
+
66
+ function SellerView({ offers }: { offers: Offer[] }) {
67
+ const [busy, setBusy] = useState<string | null>(null);
68
+ const [err, setErr] = useState<string | null>(null);
69
+ const pending = offers.filter((o) => o.status === "pending");
70
+
71
+ // Optimistic accept/decline: flip the offer's status in the local store
72
+ // immediately so the seller's list updates the instant they click. The
73
+ // server (respondToOffer) reconciles the rest — marking the listing sold and
74
+ // declining the sibling offers — when its broadcast lands.
75
+ const respondMutation = db.useMutation<{ offerId: string; accept: boolean }>(
76
+ "respondToOffer",
77
+ {
78
+ optimistic: (args) => {
79
+ const o = offers.find((x) => x.id === args.offerId);
80
+ return o
81
+ ? [
82
+ {
83
+ entity: "Offer",
84
+ data: { ...o, status: args.accept ? "accepted" : "declined" },
85
+ },
86
+ ]
87
+ : [];
88
+ },
89
+ },
90
+ );
91
+
92
+ async function respond(offerId: string, accept: boolean) {
93
+ setBusy(offerId);
94
+ setErr(null);
95
+ try {
96
+ await respondMutation.mutate({ offerId, accept });
97
+ } catch (e) {
98
+ setErr((e as Error).message ?? "Could not respond to offer.");
99
+ } finally {
100
+ setBusy(null);
101
+ }
102
+ }
103
+
104
+ return (
105
+ <div className="space-y-3">
106
+ <div className="flex items-center justify-between">
107
+ <h2 className="font-semibold">Offers on your listing</h2>
108
+ <Badge variant="outline">{pending.length} pending</Badge>
109
+ </div>
110
+ {err ? <p className="text-sm text-destructive">{err}</p> : null}
111
+ {offers.length === 0 ? (
112
+ <p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
113
+ No offers yet. They'll show up here the moment a buyer makes one —
114
+ live, no refresh.
115
+ </p>
116
+ ) : (
117
+ <ul className="space-y-2">
118
+ {offers.map((o) => (
119
+ <li
120
+ key={o.id}
121
+ className="flex items-center justify-between gap-3 rounded-lg border bg-card p-3"
122
+ >
123
+ <div className="min-w-0">
124
+ <div className="flex items-center gap-2">
125
+ <span className="text-lg font-semibold tabular-nums">
126
+ {money(o.amount)}
127
+ </span>
128
+ <Badge variant={statusVariant[o.status] ?? "outline"}>
129
+ {o.status}
130
+ </Badge>
131
+ </div>
132
+ <p className="truncate text-sm text-muted-foreground">
133
+ {o.buyerName} · {timeAgo(o.createdAt)}
134
+ {o.message ? ` · "${o.message}"` : ""}
135
+ </p>
136
+ </div>
137
+ {o.status === "pending" ? (
138
+ <div className="flex shrink-0 gap-2">
139
+ <Button
140
+ size="sm"
141
+ disabled={busy === o.id}
142
+ onClick={() => respond(o.id, true)}
143
+ >
144
+ Accept
145
+ </Button>
146
+ <Button
147
+ size="sm"
148
+ variant="outline"
149
+ disabled={busy === o.id}
150
+ onClick={() => respond(o.id, false)}
151
+ >
152
+ Decline
153
+ </Button>
154
+ </div>
155
+ ) : null}
156
+ </li>
157
+ ))}
158
+ </ul>
159
+ )}
160
+ </div>
161
+ );
162
+ }
163
+
164
+ function BuyerView({
165
+ listingId,
166
+ title,
167
+ sellerId,
168
+ sellerName,
169
+ myOffer,
170
+ isSold,
171
+ suggestedPrice,
172
+ }: Props & { myOffer?: Offer; isSold: boolean; suggestedPrice: number }) {
173
+ // Rendered inside <AuthGate>, so identity is non-null here.
174
+ const identity = useIdentity();
175
+ const userId = identity?.userId ?? "";
176
+ const name = identity?.name ?? "you";
177
+ const [amount, setAmount] = useState(String(suggestedPrice));
178
+ const [message, setMessage] = useState("");
179
+ const [err, setErr] = useState<string | null>(null);
180
+
181
+ // Local-first optimism, baked in: db.useMutation paints the Offer into the
182
+ // local store the instant you click (the `optimistic` ghost), so the live
183
+ // query below renders "Your offer" immediately — no waiting on the server,
184
+ // no hand-rolled state. The server's makeOffer reuses the same id (threaded
185
+ // as _optimisticId), so its broadcast merges in place; on failure the engine
186
+ // rolls the ghost back on its own.
187
+ const makeOffer = db.useMutation<
188
+ { listingId: string; amount: number; message: string; buyerName: string },
189
+ { id: string }
190
+ >("makeOffer", {
191
+ optimistic: (args, ctx) => ({
192
+ entity: "Offer",
193
+ data: {
194
+ id: ctx.id,
195
+ listingId,
196
+ listingTitle: title,
197
+ sellerId,
198
+ buyerId: userId,
199
+ buyerName: name,
200
+ amount: args.amount,
201
+ message: args.message,
202
+ status: "pending",
203
+ createdAt: ctx.now,
204
+ },
205
+ }),
206
+ });
207
+
208
+ // Buy now: same optimistic pattern, but the ghost is an *accepted* offer at
209
+ // the list price — the buyer sees "🎉 Accepted" instantly while the server
210
+ // marks the listing sold + declines other bids.
211
+ const buyNow = db.useMutation<{ listingId: string; buyerName: string }, { id: string }>(
212
+ "buyNow",
213
+ {
214
+ optimistic: (_args, ctx) => ({
215
+ entity: "Offer",
216
+ data: {
217
+ id: ctx.id,
218
+ listingId,
219
+ listingTitle: title,
220
+ sellerId,
221
+ buyerId: userId,
222
+ buyerName: name,
223
+ amount: suggestedPrice,
224
+ message: "Bought at list price",
225
+ status: "accepted",
226
+ createdAt: ctx.now,
227
+ },
228
+ }),
229
+ },
230
+ );
231
+
232
+ async function buy() {
233
+ setErr(null);
234
+ try {
235
+ await buyNow.mutate({ listingId, buyerName: name });
236
+ } catch (e) {
237
+ setErr((e as Error).message ?? "Could not complete the purchase.");
238
+ }
239
+ }
240
+
241
+ // `myOffer` now includes the optimistic ghost, so this flips the instant
242
+ // the offer is made.
243
+ if (myOffer) {
244
+ return (
245
+ <div className="space-y-2 rounded-lg border bg-card p-4">
246
+ <h2 className="font-semibold">Your offer</h2>
247
+ <div className="flex items-center gap-2">
248
+ <span className="text-2xl font-semibold tabular-nums">{money(myOffer.amount)}</span>
249
+ <Badge variant={statusVariant[myOffer.status] ?? "outline"}>
250
+ {myOffer.status}
251
+ </Badge>
252
+ </div>
253
+ <p className="text-sm text-muted-foreground">
254
+ {myOffer.status === "pending"
255
+ ? `Sent to ${sellerName} — you'll see their answer here live.`
256
+ : myOffer.status === "accepted"
257
+ ? "🎉 Accepted! Arrange pickup with the seller."
258
+ : "This offer was declined."}
259
+ </p>
260
+ </div>
261
+ );
262
+ }
263
+
264
+ if (isSold) {
265
+ return (
266
+ <div className="rounded-lg border bg-card p-4 text-sm text-muted-foreground">
267
+ This item has sold.
268
+ </div>
269
+ );
270
+ }
271
+
272
+ async function submit(e: React.FormEvent) {
273
+ e.preventDefault();
274
+ const value = Number.parseFloat(amount);
275
+ if (!Number.isFinite(value) || value <= 0) {
276
+ setErr("Enter an offer amount.");
277
+ return;
278
+ }
279
+ setErr(null);
280
+ try {
281
+ // The ghost is painted synchronously here; the view has already flipped
282
+ // to "Your offer" by the time this awaits.
283
+ await makeOffer.mutate({ listingId, amount: value, message, buyerName: name });
284
+ } catch (e) {
285
+ setErr((e as Error).message ?? "Could not send offer.");
286
+ }
287
+ }
288
+
289
+ return (
290
+ <div className="space-y-4 rounded-lg border bg-card p-4">
291
+ <div className="space-y-2">
292
+ <Button
293
+ type="button"
294
+ onClick={buy}
295
+ disabled={buyNow.loading}
296
+ className="w-full"
297
+ >
298
+ {buyNow.loading ? "Buying…" : `Buy now — ${money(suggestedPrice)}`}
299
+ </Button>
300
+ <p className="text-center text-xs text-muted-foreground">
301
+ Instant purchase at the asking price.
302
+ </p>
303
+ </div>
304
+
305
+ <div className="flex items-center gap-3 text-xs uppercase tracking-wide text-muted-foreground">
306
+ <span className="h-px flex-1 bg-border" />
307
+ or make an offer
308
+ <span className="h-px flex-1 bg-border" />
309
+ </div>
310
+
311
+ <form onSubmit={submit} className="space-y-3">
312
+ <div className="flex items-center gap-2">
313
+ <span className="text-muted-foreground">$</span>
314
+ <Input
315
+ type="number"
316
+ min="1"
317
+ step="1"
318
+ value={amount}
319
+ onChange={(e) => setAmount(e.target.value)}
320
+ className="w-32"
321
+ aria-label="Offer amount"
322
+ />
323
+ </div>
324
+ <Textarea
325
+ placeholder="Add a note (optional)…"
326
+ value={message}
327
+ onChange={(e) => setMessage(e.target.value)}
328
+ rows={2}
329
+ />
330
+ {err ? <p className="text-sm text-destructive">{err}</p> : null}
331
+ <Button
332
+ type="submit"
333
+ variant="outline"
334
+ disabled={makeOffer.loading}
335
+ className="w-full"
336
+ >
337
+ {makeOffer.loading
338
+ ? "Sending…"
339
+ : `Offer ${money(Number.parseFloat(amount) || 0)}`}
340
+ </Button>
341
+ <p className="text-center text-xs text-muted-foreground">
342
+ You're bidding as <span className="font-medium">{name}</span>
343
+ </p>
344
+ </form>
345
+ </div>
346
+ );
347
+ }
348
+
349
+ export function OfferPanel(props: Props) {
350
+ return (
351
+ <MarketProvider>
352
+ <Panel {...props} />
353
+ </MarketProvider>
354
+ );
355
+ }
@@ -0,0 +1,26 @@
1
+ "use client";
2
+
3
+ import React, { useEffect, useRef } from "react";
4
+ import { ensureDemoSeed, ensureReadSession } from "./market";
5
+
6
+ // First-run convenience: if the marketplace is empty, ensure the demo account
7
+ // + seed a dozen listings under it, then reload once so the server-rendered
8
+ // grid picks them up. Guarded by a session flag so it never loops. Real apps
9
+ // wouldn't ship this; it just makes `pylon dev` show something on first visit.
10
+ export function SeedOnEmpty({ count }: { count: number }) {
11
+ const fired = useRef(false);
12
+ useEffect(() => {
13
+ if (count > 0 || fired.current) return;
14
+ if (sessionStorage.getItem("market:seeded") === "1") return;
15
+ fired.current = true;
16
+ sessionStorage.setItem("market:seeded", "1");
17
+ void (async () => {
18
+ await ensureReadSession();
19
+ await ensureDemoSeed();
20
+ // The seed inserts listings owned by the demo user; reload so the SSR
21
+ // grid renders them. The session flag prevents a reload loop.
22
+ window.location.reload();
23
+ })();
24
+ }, [count]);
25
+ return null;
26
+ }
@@ -0,0 +1,160 @@
1
+ "use client";
2
+
3
+ import React, { useState } from "react";
4
+ import { db, useRouter } from "@pylonsync/react";
5
+ import { Button } from "../ui/button";
6
+ import { Input } from "../ui/input";
7
+ import { Textarea } from "../ui/textarea";
8
+ import { Label } from "../ui/label";
9
+ import { AuthGate, MarketProvider, useIdentity } from "./MarketProvider";
10
+ import { makeSlug } from "./market";
11
+
12
+ const CATEGORIES = [
13
+ "furniture", "electronics", "cameras", "bikes", "audio", "kitchen",
14
+ "instruments", "outdoor", "apparel", "other",
15
+ ];
16
+ const CONDITIONS = ["new", "like-new", "good", "fair"];
17
+
18
+ const selectClass =
19
+ "flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm outline-none focus-visible:ring-1 focus-visible:ring-ring";
20
+
21
+ function Form() {
22
+ // Rendered inside <AuthGate>, so identity is guaranteed non-null here.
23
+ const identity = useIdentity();
24
+ const userId = identity?.userId ?? "";
25
+ const name = identity?.name ?? "you";
26
+ const router = useRouter();
27
+ const [title, setTitle] = useState("");
28
+ const [description, setDescription] = useState("");
29
+ const [price, setPrice] = useState("");
30
+ const [category, setCategory] = useState(CATEGORIES[0]);
31
+ const [condition, setCondition] = useState("good");
32
+ const [busy, setBusy] = useState(false);
33
+ const [err, setErr] = useState<string | null>(null);
34
+
35
+ async function submit(e: React.FormEvent) {
36
+ e.preventDefault();
37
+ const value = Number.parseFloat(price);
38
+ if (!title.trim()) return setErr("Give your item a title.");
39
+ if (!Number.isFinite(value) || value < 0) return setErr("Set a price.");
40
+ setBusy(true);
41
+ setErr(null);
42
+ // Local-first by default — no createListing function, no opt-in
43
+ // optimism flag. `db.insert` paints the listing into the local store
44
+ // synchronously (it's in the "just listed" ticker before the network
45
+ // call even leaves the tab) and pushes in the background. `sellerId`
46
+ // is declared `field.owner()` in app.ts, so the server stamps and
47
+ // verifies it from the session — we send our own id only so the
48
+ // optimistic row is complete; a forged seller id would be rejected.
49
+ const seed = Math.random().toString(36).slice(2, 8);
50
+ const slug = makeSlug(title.trim(), seed);
51
+ try {
52
+ await db.insert("Listing", {
53
+ sellerId: userId,
54
+ sellerName: name,
55
+ title: title.trim(),
56
+ slug,
57
+ description: description.trim(),
58
+ price: Math.max(0, Math.round(value * 100) / 100),
59
+ category,
60
+ condition,
61
+ status: "active",
62
+ seed,
63
+ createdAt: new Date().toISOString(),
64
+ });
65
+ router.push(`/listing/${slug}`);
66
+ } catch (e) {
67
+ setErr((e as Error).message ?? "Could not post your listing.");
68
+ setBusy(false);
69
+ }
70
+ }
71
+
72
+ return (
73
+ <form onSubmit={submit} className="space-y-5">
74
+ <div className="space-y-1.5">
75
+ <Label htmlFor="title">Title</Label>
76
+ <Input
77
+ id="title"
78
+ value={title}
79
+ onChange={(e) => setTitle(e.target.value)}
80
+ placeholder="e.g. Herman Miller Aeron, size B"
81
+ />
82
+ </div>
83
+ <div className="space-y-1.5">
84
+ <Label htmlFor="description">Description</Label>
85
+ <Textarea
86
+ id="description"
87
+ value={description}
88
+ onChange={(e) => setDescription(e.target.value)}
89
+ placeholder="Condition details, dimensions, why you're selling…"
90
+ rows={4}
91
+ />
92
+ </div>
93
+ <div className="grid grid-cols-2 gap-4">
94
+ <div className="space-y-1.5">
95
+ <Label htmlFor="price">Price ($)</Label>
96
+ <Input
97
+ id="price"
98
+ type="number"
99
+ min="0"
100
+ step="1"
101
+ value={price}
102
+ onChange={(e) => setPrice(e.target.value)}
103
+ placeholder="0"
104
+ />
105
+ </div>
106
+ <div className="space-y-1.5">
107
+ <Label htmlFor="condition">Condition</Label>
108
+ <select
109
+ id="condition"
110
+ value={condition}
111
+ onChange={(e) => setCondition(e.target.value)}
112
+ className={selectClass}
113
+ >
114
+ {CONDITIONS.map((c) => (
115
+ <option key={c} value={c}>
116
+ {c}
117
+ </option>
118
+ ))}
119
+ </select>
120
+ </div>
121
+ </div>
122
+ <div className="space-y-1.5">
123
+ <Label htmlFor="category">Category</Label>
124
+ <select
125
+ id="category"
126
+ value={category}
127
+ onChange={(e) => setCategory(e.target.value)}
128
+ className={selectClass}
129
+ >
130
+ {CATEGORIES.map((c) => (
131
+ <option key={c} value={c}>
132
+ {c}
133
+ </option>
134
+ ))}
135
+ </select>
136
+ </div>
137
+ {err ? <p className="text-sm text-destructive">{err}</p> : null}
138
+ <Button type="submit" disabled={busy} className="w-full">
139
+ {busy ? "Posting…" : "Post listing"}
140
+ </Button>
141
+ <p className="text-center text-xs text-muted-foreground">
142
+ Posting as <span className="font-medium">{name}</span> — buyers'
143
+ offers land in <a href="/me" className="underline">My Market</a>.
144
+ </p>
145
+ </form>
146
+ );
147
+ }
148
+
149
+ export function SellForm() {
150
+ return (
151
+ <MarketProvider>
152
+ <AuthGate
153
+ title="Sign in to list an item"
154
+ blurb="Selling needs an account so your listings are tied to you. The demo account is prefilled — just hit Log in."
155
+ >
156
+ <Form />
157
+ </AuthGate>
158
+ </MarketProvider>
159
+ );
160
+ }
@@ -0,0 +1,88 @@
1
+ "use client";
2
+
3
+ import React, { useEffect, useState } from "react";
4
+ import { db } from "@pylonsync/react";
5
+ import { Heart } from "lucide-react";
6
+ import { cn } from "../ui/utils";
7
+ import { bootClient, readIdentity, type Watch } from "./market";
8
+
9
+ // Heart toggle that saves a listing to your private watchlist. Self-contained
10
+ // (no provider needed): boots the client, reads identity, and toggles the
11
+ // Watch row with optimistic db.insert / db.delete — the live query below flips
12
+ // the fill instantly. Hidden for signed-out visitors (watchlists are a
13
+ // logged-in feature). The mounted gate keeps db.useQuery off the SSR pass.
14
+ export function WatchButton(props: {
15
+ listingId: string;
16
+ listingTitle: string;
17
+ className?: string;
18
+ }) {
19
+ const [mounted, setMounted] = useState(false);
20
+ useEffect(() => {
21
+ bootClient();
22
+ setMounted(true);
23
+ }, []);
24
+ if (!mounted) return null;
25
+ return <Inner {...props} />;
26
+ }
27
+
28
+ function Inner({
29
+ listingId,
30
+ listingTitle,
31
+ className,
32
+ }: {
33
+ listingId: string;
34
+ listingTitle: string;
35
+ className?: string;
36
+ }) {
37
+ const [identity, setIdentity] = useState(() => readIdentity());
38
+ useEffect(() => {
39
+ const on = () => setIdentity(readIdentity());
40
+ window.addEventListener("pylon-auth-changed", on);
41
+ window.addEventListener("storage", on);
42
+ return () => {
43
+ window.removeEventListener("pylon-auth-changed", on);
44
+ window.removeEventListener("storage", on);
45
+ };
46
+ }, []);
47
+
48
+ // Policy scopes reads to the caller, so this returns only MY watch (if any)
49
+ // for this listing.
50
+ const { data } = db.useQuery<Watch>("Watch", { where: { listingId } });
51
+ const mine = identity ? data?.find((w) => w.userId === identity.userId) : undefined;
52
+ const watched = !!mine;
53
+
54
+ // No heart for signed-out visitors.
55
+ if (!identity) return null;
56
+
57
+ function toggle(e: React.MouseEvent) {
58
+ // Stop the click from bubbling into the surrounding card link.
59
+ e.preventDefault();
60
+ e.stopPropagation();
61
+ if (mine) {
62
+ void db.delete("Watch", mine.id);
63
+ } else {
64
+ void db.insert("Watch", { userId: identity!.userId, listingId, listingTitle });
65
+ }
66
+ }
67
+
68
+ return (
69
+ <button
70
+ type="button"
71
+ onClick={toggle}
72
+ aria-pressed={watched}
73
+ aria-label={watched ? "Remove from watchlist" : "Save to watchlist"}
74
+ title={watched ? "Saved" : "Save to watchlist"}
75
+ className={cn(
76
+ "grid size-9 place-items-center rounded-full bg-background/80 backdrop-blur transition hover:bg-background",
77
+ className,
78
+ )}
79
+ >
80
+ <Heart
81
+ className={cn(
82
+ "size-5 transition",
83
+ watched ? "fill-rose-500 text-rose-500" : "text-foreground/70",
84
+ )}
85
+ />
86
+ </button>
87
+ );
88
+ }