@pylonsync/create-pylon 0.19.0 → 0.20.0

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 (675) hide show
  1. package/bin/create-pylon.js +30 -7
  2. package/package.json +1 -1
  3. package/templates/agency/.env.example +7 -0
  4. package/templates/agency/README.md +28 -3
  5. package/templates/agency/app/(marketing)/contact-form.tsx +20 -18
  6. package/templates/agency/app/(marketing)/page.tsx +3 -3
  7. package/templates/agency/app/(marketing)/work/page.tsx +3 -3
  8. package/templates/agency/app/auth-form.tsx +4 -4
  9. package/templates/agency/app/dashboard/dashboard-client.tsx +110 -64
  10. package/templates/agency/app/dashboard/page.tsx +14 -16
  11. package/templates/agency/app/icon.svg +3 -0
  12. package/templates/agency/app/login/page.tsx +12 -14
  13. package/templates/agency/components/marketing.tsx +12 -5
  14. package/templates/agency/functions/seedStudioBackoffice.ts +42 -11
  15. package/templates/agency/lib/agency.ts +22 -0
  16. package/templates/agency/lib/demo.ts +118 -0
  17. package/templates/agency/lib/site.config.ts +19 -13
  18. package/templates/agency/public/favicon.ico +0 -0
  19. package/templates/agency/tests/agency.test.ts +57 -0
  20. package/templates/ai-chat/.env.example +13 -27
  21. package/templates/ai-chat/README.md +53 -60
  22. package/templates/ai-chat/app/(chat)/c/[id]/page.tsx +19 -0
  23. package/templates/ai-chat/app/(chat)/chat-client.tsx +657 -622
  24. package/templates/ai-chat/app/(chat)/layout.tsx +6 -43
  25. package/templates/ai-chat/app/(chat)/not-found.tsx +6 -10
  26. package/templates/ai-chat/app/(chat)/page.tsx +7 -11
  27. package/templates/ai-chat/app/auth-form.tsx +45 -38
  28. package/templates/ai-chat/app/error.tsx +9 -12
  29. package/templates/ai-chat/app/globals.css +464 -102
  30. package/templates/ai-chat/app/layout.tsx +11 -19
  31. package/templates/ai-chat/app/login/page.tsx +39 -24
  32. package/templates/ai-chat/app.ts +118 -43
  33. package/templates/ai-chat/components/chat/composer.tsx +193 -0
  34. package/templates/ai-chat/components/chat/empty-state.tsx +65 -0
  35. package/templates/ai-chat/components/chat/icons.tsx +198 -0
  36. package/templates/ai-chat/components/chat/markdown.tsx +164 -0
  37. package/templates/ai-chat/components/chat/message.tsx +183 -0
  38. package/templates/ai-chat/components/chat/setup.tsx +71 -0
  39. package/templates/ai-chat/components/chat/sidebar.tsx +288 -0
  40. package/templates/ai-chat/functions/_beginTurn.ts +145 -0
  41. package/templates/ai-chat/functions/_finishTurn.ts +55 -0
  42. package/templates/ai-chat/functions/assistantStatus.ts +13 -0
  43. package/templates/ai-chat/functions/claimGuestHistory.ts +39 -0
  44. package/templates/ai-chat/functions/createConversation.ts +28 -0
  45. package/templates/ai-chat/functions/deleteConversation.ts +15 -20
  46. package/templates/ai-chat/functions/prepareGuestClaim.ts +32 -0
  47. package/templates/ai-chat/functions/renameConversation.ts +19 -0
  48. package/templates/ai-chat/functions/respond.ts +88 -0
  49. package/templates/ai-chat/functions/seedExamples.ts +46 -0
  50. package/templates/ai-chat/functions/stopResponse.ts +30 -0
  51. package/templates/ai-chat/lib/brand-icon.ts +7 -0
  52. package/templates/ai-chat/lib/chat.ts +277 -0
  53. package/templates/ai-chat/lib/claim.ts +15 -0
  54. package/templates/ai-chat/lib/examples.ts +179 -0
  55. package/templates/ai-chat/lib/highlight.ts +107 -0
  56. package/templates/ai-chat/lib/markdown.ts +317 -0
  57. package/templates/ai-chat/lib/provider.ts +38 -0
  58. package/templates/ai-chat/lib/site.config.ts +55 -39
  59. package/templates/ai-chat/tests/chat.test.ts +174 -0
  60. package/templates/ai-chat/tests/components.test.tsx +57 -0
  61. package/templates/ai-chat/tests/markdown.test.ts +88 -0
  62. package/templates/ai-chat/tests/provider.test.ts +32 -0
  63. package/templates/backend/consumer/apps/api/app/layout.tsx +33 -0
  64. package/templates/backend/consumer/apps/api/app/page.tsx +54 -0
  65. package/templates/backend/consumer/apps/api/app.ts +166 -0
  66. package/templates/backend/consumer/apps/api/functions/addComment.ts +24 -0
  67. package/templates/backend/consumer/apps/api/functions/createPost.ts +19 -36
  68. package/templates/backend/consumer/apps/api/functions/deletePost.ts +15 -13
  69. package/templates/backend/consumer/apps/api/functions/myProfile.ts +5 -8
  70. package/templates/backend/consumer/apps/api/functions/seedDemo.ts +95 -0
  71. package/templates/backend/consumer/apps/api/functions/setFollow.ts +36 -0
  72. package/templates/backend/consumer/apps/api/functions/setLike.ts +36 -0
  73. package/templates/backend/consumer/apps/api/functions/upsertProfile.ts +43 -37
  74. package/templates/backend/consumer/apps/api/lib/seed.ts +89 -0
  75. package/templates/backend/consumer/apps/api/lib/social.ts +50 -0
  76. package/templates/backend/consumer/apps/api/package.json +8 -2
  77. package/templates/backend/consumer/apps/api/public/images/posts/arch.jpg +0 -0
  78. package/templates/backend/consumer/apps/api/public/images/posts/coffee.jpg +0 -0
  79. package/templates/backend/consumer/apps/api/public/images/posts/dog.jpg +0 -0
  80. package/templates/backend/consumer/apps/api/public/images/posts/lisbon.jpg +0 -0
  81. package/templates/backend/consumer/apps/api/public/images/posts/ramen.jpg +0 -0
  82. package/templates/backend/consumer/apps/api/public/images/posts/run.jpg +0 -0
  83. package/templates/backend/consumer/apps/api/public/images/posts/sunset.jpg +0 -0
  84. package/templates/backend/consumer/apps/api/tsconfig.json +12 -2
  85. package/templates/chat/README.md +43 -21
  86. package/templates/chat/app/chat-client.tsx +495 -85
  87. package/templates/chat/app/error.tsx +1 -1
  88. package/templates/chat/app/globals.css +170 -54
  89. package/templates/chat/app/layout.tsx +12 -9
  90. package/templates/chat/app/not-found.tsx +4 -2
  91. package/templates/chat/app/page.tsx +9 -19
  92. package/templates/chat/app.ts +96 -17
  93. package/templates/chat/components/chat/avatar.tsx +45 -0
  94. package/templates/chat/components/chat/composer.tsx +110 -0
  95. package/templates/chat/components/chat/message-list.tsx +285 -0
  96. package/templates/chat/components/chat/sidebar.tsx +190 -0
  97. package/templates/chat/functions/joinChat.ts +83 -0
  98. package/templates/chat/functions/renameMember.ts +25 -0
  99. package/templates/chat/lib/chat.ts +188 -0
  100. package/templates/chat/lib/seed.ts +78 -0
  101. package/templates/chat/tests/chat.test.ts +159 -0
  102. package/templates/chat/tests/composer.test.tsx +54 -0
  103. package/templates/consumer/README.md +3 -3
  104. package/templates/consumer/app/globals.css +10 -3
  105. package/templates/consumer/app/icon.svg +1 -0
  106. package/templates/consumer/components/social/avatar.tsx +25 -11
  107. package/templates/consumer/functions/seedFeed.ts +3 -3
  108. package/templates/consumer/lib/seed.ts +43 -85
  109. package/templates/consumer/public/favicon.ico +0 -0
  110. package/templates/consumer/public/images/posts/arch.jpg +0 -0
  111. package/templates/consumer/public/images/posts/coffee.jpg +0 -0
  112. package/templates/consumer/public/images/posts/dog.jpg +0 -0
  113. package/templates/consumer/public/images/posts/lisbon.jpg +0 -0
  114. package/templates/consumer/public/images/posts/ramen.jpg +0 -0
  115. package/templates/consumer/public/images/posts/run.jpg +0 -0
  116. package/templates/consumer/public/images/posts/sunset.jpg +0 -0
  117. package/templates/consumer/tests/seed.test.ts +39 -0
  118. package/templates/creator/.env.example +7 -0
  119. package/templates/creator/README.md +24 -5
  120. package/templates/creator/app/(marketing)/newsletter-signup.tsx +33 -12
  121. package/templates/creator/app/auth-form.tsx +7 -7
  122. package/templates/creator/app/dashboard/dashboard-client.tsx +137 -100
  123. package/templates/creator/app/dashboard/page.tsx +18 -18
  124. package/templates/creator/app/icon.svg +2 -0
  125. package/templates/creator/app/login/page.tsx +14 -14
  126. package/templates/creator/app.ts +4 -4
  127. package/templates/creator/functions/seedDemo.ts +32 -0
  128. package/templates/creator/functions/subscribe.ts +4 -15
  129. package/templates/creator/lib/demo.ts +94 -0
  130. package/templates/creator/lib/site.config.ts +15 -10
  131. package/templates/creator/lib/stats.ts +35 -0
  132. package/templates/creator/public/favicon.ico +0 -0
  133. package/templates/creator/tests/counter.test.ts +92 -0
  134. package/templates/creator/tests/live-count.test.tsx +45 -0
  135. package/templates/crm/README.md +22 -1
  136. package/templates/crm/app/apple-icon.png +0 -0
  137. package/templates/crm/app/companies/companies-view.tsx +32 -14
  138. package/templates/crm/app/companies/page.tsx +2 -1
  139. package/templates/crm/app/contacts/contacts-view.tsx +34 -10
  140. package/templates/crm/app/contacts/page.tsx +2 -1
  141. package/templates/crm/app/deals/[id]/deal-view.tsx +137 -98
  142. package/templates/crm/app/deals/[id]/page.tsx +2 -1
  143. package/templates/crm/app/globals.css +29 -14
  144. package/templates/crm/app/icon.svg +1 -0
  145. package/templates/crm/app/layout.tsx +7 -5
  146. package/templates/crm/app/page.tsx +2 -1
  147. package/templates/crm/app/pipeline-view.tsx +5 -5
  148. package/templates/crm/app/workspace.tsx +29 -17
  149. package/templates/crm/app.ts +5 -5
  150. package/templates/crm/components/activity-timeline.tsx +14 -4
  151. package/templates/crm/components/app-shell.tsx +94 -0
  152. package/templates/crm/components/auth-form.tsx +35 -5
  153. package/templates/crm/components/avatar.tsx +5 -1
  154. package/templates/crm/components/board-skeleton.tsx +5 -5
  155. package/templates/crm/components/brand-mark.tsx +25 -0
  156. package/templates/crm/components/command-palette.tsx +17 -17
  157. package/templates/crm/components/data-table.tsx +97 -36
  158. package/templates/crm/components/deal-card.tsx +23 -27
  159. package/templates/crm/components/filter-tabs.tsx +101 -0
  160. package/templates/crm/components/metrics-bar.tsx +6 -6
  161. package/templates/crm/components/nav.tsx +15 -0
  162. package/templates/crm/components/page-header.tsx +43 -7
  163. package/templates/crm/components/pipeline-board.tsx +16 -10
  164. package/templates/crm/components/require-auth.tsx +24 -26
  165. package/templates/crm/components/sidebar.tsx +54 -49
  166. package/templates/crm/components/stage-badge.tsx +4 -2
  167. package/templates/crm/components/ui/button.tsx +5 -4
  168. package/templates/crm/components/ui/dialog.tsx +1 -1
  169. package/templates/crm/components/ui/input.tsx +1 -1
  170. package/templates/crm/components/ui/select.tsx +1 -1
  171. package/templates/crm/components/ui/textarea.tsx +1 -1
  172. package/templates/crm/functions/seedWorkspace.ts +32 -17
  173. package/templates/crm/lib/brand.ts +9 -0
  174. package/templates/crm/lib/demo-team.ts +51 -0
  175. package/templates/crm/lib/pipeline.ts +37 -0
  176. package/templates/crm/lib/search.ts +4 -3
  177. package/templates/crm/lib/seed.ts +53 -8
  178. package/templates/crm/public/favicon.ico +0 -0
  179. package/templates/crm/tests/components.test.tsx +5 -5
  180. package/templates/crm/tests/demo-team.test.ts +18 -0
  181. package/templates/crm/tests/pipeline.test.ts +59 -3
  182. package/templates/directory/.env.example +7 -0
  183. package/templates/directory/README.md +22 -3
  184. package/templates/directory/app/(marketing)/directory-browse.tsx +30 -12
  185. package/templates/directory/app/(marketing)/page.tsx +6 -4
  186. package/templates/directory/app/auth-form.tsx +4 -4
  187. package/templates/directory/app/dashboard/dashboard-client.tsx +177 -90
  188. package/templates/directory/app/dashboard/page.tsx +16 -14
  189. package/templates/directory/app/icon.svg +4 -0
  190. package/templates/directory/app/login/page.tsx +16 -15
  191. package/templates/directory/functions/seedDemo.ts +43 -0
  192. package/templates/directory/lib/demo.ts +145 -0
  193. package/templates/directory/lib/directory.ts +27 -0
  194. package/templates/directory/lib/site.config.ts +21 -16
  195. package/templates/directory/public/favicon.ico +0 -0
  196. package/templates/directory/tests/directory.test.ts +46 -0
  197. package/templates/expo/consumer/apps/expo/App.tsx +360 -288
  198. package/templates/expo/consumer/apps/expo/app.json +9 -1
  199. package/templates/expo/consumer/apps/expo/package.json +2 -1
  200. package/templates/helpdesk/README.md +21 -0
  201. package/templates/helpdesk/app/apple-icon.png +0 -0
  202. package/templates/helpdesk/app/customers/customers-view.tsx +23 -7
  203. package/templates/helpdesk/app/customers/page.tsx +2 -1
  204. package/templates/helpdesk/app/globals.css +29 -14
  205. package/templates/helpdesk/app/icon.svg +1 -0
  206. package/templates/helpdesk/app/inbox-view.tsx +66 -47
  207. package/templates/helpdesk/app/layout.tsx +7 -5
  208. package/templates/helpdesk/app/page.tsx +4 -7
  209. package/templates/helpdesk/app/tickets/[id]/page.tsx +2 -1
  210. package/templates/helpdesk/app/tickets/[id]/ticket-view.tsx +134 -97
  211. package/templates/helpdesk/app/workspace.tsx +33 -22
  212. package/templates/helpdesk/app.ts +2 -2
  213. package/templates/helpdesk/components/app-shell.tsx +94 -0
  214. package/templates/helpdesk/components/auth-form.tsx +35 -5
  215. package/templates/helpdesk/components/avatar.tsx +5 -1
  216. package/templates/helpdesk/components/brand-mark.tsx +28 -0
  217. package/templates/helpdesk/components/command-palette.tsx +17 -17
  218. package/templates/helpdesk/components/data-table.tsx +97 -36
  219. package/templates/helpdesk/components/filter-tabs.tsx +101 -0
  220. package/templates/helpdesk/components/nav.tsx +14 -0
  221. package/templates/helpdesk/components/page-header.tsx +43 -7
  222. package/templates/helpdesk/components/priority-badge.tsx +49 -6
  223. package/templates/helpdesk/components/require-auth.tsx +24 -26
  224. package/templates/helpdesk/components/sidebar.tsx +54 -48
  225. package/templates/helpdesk/components/sla-indicator.tsx +5 -8
  226. package/templates/helpdesk/components/thread.tsx +7 -7
  227. package/templates/helpdesk/components/ticket-list.tsx +79 -19
  228. package/templates/helpdesk/components/ui/button.tsx +5 -4
  229. package/templates/helpdesk/components/ui/dialog.tsx +1 -1
  230. package/templates/helpdesk/components/ui/input.tsx +1 -1
  231. package/templates/helpdesk/components/ui/select.tsx +1 -1
  232. package/templates/helpdesk/components/ui/textarea.tsx +1 -1
  233. package/templates/helpdesk/functions/seedWorkspace.ts +25 -15
  234. package/templates/helpdesk/lib/brand.ts +9 -0
  235. package/templates/helpdesk/lib/demo-team.ts +51 -0
  236. package/templates/helpdesk/lib/search.ts +4 -3
  237. package/templates/helpdesk/lib/seed.ts +43 -2
  238. package/templates/helpdesk/lib/tickets.ts +68 -20
  239. package/templates/helpdesk/public/favicon.ico +0 -0
  240. package/templates/helpdesk/tests/components.test.tsx +10 -0
  241. package/templates/helpdesk/tests/demo-team.test.ts +18 -0
  242. package/templates/helpdesk/tests/tickets.test.ts +70 -0
  243. package/templates/imessage/.env.example +49 -0
  244. package/templates/imessage/AGENTS.md +153 -0
  245. package/templates/imessage/CLAUDE.md +7 -0
  246. package/templates/imessage/README.md +187 -0
  247. package/templates/imessage/app/console.tsx +382 -0
  248. package/templates/imessage/app/contacts/page.tsx +13 -0
  249. package/templates/imessage/app/error.tsx +16 -0
  250. package/templates/imessage/app/gate.tsx +31 -0
  251. package/templates/imessage/app/globals.css +183 -0
  252. package/templates/imessage/app/layout.tsx +17 -0
  253. package/templates/imessage/app/login/login-form.tsx +115 -0
  254. package/templates/imessage/app/login/page.tsx +30 -0
  255. package/templates/imessage/app/not-found.tsx +13 -0
  256. package/templates/imessage/app/page.tsx +14 -0
  257. package/templates/imessage/app/robots.ts +6 -0
  258. package/templates/imessage/app/setup/page.tsx +13 -0
  259. package/templates/imessage/app/sign-out.tsx +20 -0
  260. package/templates/imessage/app.ts +299 -0
  261. package/templates/imessage/bunfig +4 -0
  262. package/templates/imessage/components/avatar.tsx +46 -0
  263. package/templates/imessage/components/contact-panel.tsx +263 -0
  264. package/templates/imessage/components/contacts-view.tsx +172 -0
  265. package/templates/imessage/components/conversation-list.tsx +129 -0
  266. package/templates/imessage/components/nav-rail.tsx +67 -0
  267. package/templates/imessage/components/setup-view.tsx +408 -0
  268. package/templates/imessage/components/thread.tsx +229 -0
  269. package/templates/imessage/components/ui/badge.tsx +37 -0
  270. package/templates/imessage/components/ui/button.tsx +56 -0
  271. package/templates/imessage/components/ui/card.tsx +90 -0
  272. package/templates/imessage/components/ui/dialog.tsx +110 -0
  273. package/templates/imessage/components/ui/input.tsx +30 -0
  274. package/templates/imessage/components/ui/label.tsx +21 -0
  275. package/templates/imessage/components/ui/select.tsx +39 -0
  276. package/templates/imessage/components/ui/table.tsx +124 -0
  277. package/templates/imessage/components/ui/textarea.tsx +24 -0
  278. package/templates/imessage/components.json +20 -0
  279. package/templates/imessage/functions/answerNow.ts +34 -0
  280. package/templates/imessage/functions/assistant.ts +96 -0
  281. package/templates/imessage/functions/beginTurn.ts +101 -0
  282. package/templates/imessage/functions/cancelReminder.ts +13 -0
  283. package/templates/imessage/functions/claimOutbound.ts +44 -0
  284. package/templates/imessage/functions/clearDemo.ts +18 -0
  285. package/templates/imessage/functions/deleteNote.ts +11 -0
  286. package/templates/imessage/functions/deliverOutbound.ts +55 -0
  287. package/templates/imessage/functions/finishTurn.ts +84 -0
  288. package/templates/imessage/functions/fireReminder.ts +42 -0
  289. package/templates/imessage/functions/ingestInbound.ts +22 -0
  290. package/templates/imessage/functions/ownerAccount.ts +13 -0
  291. package/templates/imessage/functions/processTurn.ts +74 -0
  292. package/templates/imessage/functions/relayApply.ts +102 -0
  293. package/templates/imessage/functions/relaySync.ts +63 -0
  294. package/templates/imessage/functions/releaseOutbound.ts +18 -0
  295. package/templates/imessage/functions/resolveTurn.ts +25 -0
  296. package/templates/imessage/functions/saveContact.ts +27 -0
  297. package/templates/imessage/functions/seedDemo.ts +70 -0
  298. package/templates/imessage/functions/sendOwnerMessage.ts +28 -0
  299. package/templates/imessage/functions/sendblueWebhook.ts +49 -0
  300. package/templates/imessage/functions/setContactStatus.ts +17 -0
  301. package/templates/imessage/functions/settleOutbound.ts +35 -0
  302. package/templates/imessage/functions/setupStatus.ts +33 -0
  303. package/templates/imessage/functions/sweep.ts +58 -0
  304. package/templates/imessage/functions/toolCancelReminder.ts +17 -0
  305. package/templates/imessage/functions/toolForgetNote.ts +15 -0
  306. package/templates/imessage/functions/toolListNotes.ts +16 -0
  307. package/templates/imessage/functions/toolListReminders.ts +17 -0
  308. package/templates/imessage/functions/toolSaveNote.ts +22 -0
  309. package/templates/imessage/functions/toolSetReminder.ts +55 -0
  310. package/templates/imessage/functions/updateSettings.ts +48 -0
  311. package/templates/imessage/gitignore +11 -0
  312. package/templates/imessage/lib/agent-call.ts +251 -0
  313. package/templates/imessage/lib/assistant.ts +74 -0
  314. package/templates/imessage/lib/chunk.ts +135 -0
  315. package/templates/imessage/lib/demo.ts +68 -0
  316. package/templates/imessage/lib/format.ts +170 -0
  317. package/templates/imessage/lib/gate.ts +104 -0
  318. package/templates/imessage/lib/handles.ts +67 -0
  319. package/templates/imessage/lib/provider.ts +38 -0
  320. package/templates/imessage/lib/relay-protocol.ts +95 -0
  321. package/templates/imessage/lib/reminders.ts +41 -0
  322. package/templates/imessage/lib/secrets.ts +59 -0
  323. package/templates/imessage/lib/sendblue.ts +173 -0
  324. package/templates/imessage/lib/store.ts +407 -0
  325. package/templates/imessage/lib/transport.ts +135 -0
  326. package/templates/imessage/lib/utils.ts +10 -0
  327. package/templates/imessage/package.json +40 -0
  328. package/templates/imessage/relay/.env.example +14 -0
  329. package/templates/imessage/relay/applescript.ts +70 -0
  330. package/templates/imessage/relay/chatdb.ts +165 -0
  331. package/templates/imessage/relay/client.ts +41 -0
  332. package/templates/imessage/relay/main.ts +137 -0
  333. package/templates/imessage/relay/state.ts +35 -0
  334. package/templates/imessage/tests/agent-call.test.ts +112 -0
  335. package/templates/imessage/tests/applescript.test.ts +88 -0
  336. package/templates/imessage/tests/chatdb.test.ts +139 -0
  337. package/templates/imessage/tests/chunk.test.ts +61 -0
  338. package/templates/imessage/tests/fake-ctx.ts +88 -0
  339. package/templates/imessage/tests/ingest.test.ts +141 -0
  340. package/templates/imessage/tests/lib.test.ts +99 -0
  341. package/templates/imessage/tests/review-fixes.test.ts +138 -0
  342. package/templates/imessage/tests/secrets.test.ts +36 -0
  343. package/templates/imessage/tests/sendblue.test.ts +143 -0
  344. package/templates/imessage/tests/setup.ts +5 -0
  345. package/templates/imessage/tsconfig.json +18 -0
  346. package/templates/inventory/README.md +21 -0
  347. package/templates/inventory/app/apple-icon.png +0 -0
  348. package/templates/inventory/app/globals.css +29 -14
  349. package/templates/inventory/app/icon.svg +1 -0
  350. package/templates/inventory/app/layout.tsx +7 -5
  351. package/templates/inventory/app/movements/movements-view.tsx +32 -10
  352. package/templates/inventory/app/movements/page.tsx +2 -1
  353. package/templates/inventory/app/page.tsx +4 -6
  354. package/templates/inventory/app/products/[id]/page.tsx +2 -1
  355. package/templates/inventory/app/products/[id]/product-view.tsx +55 -25
  356. package/templates/inventory/app/products-view.tsx +74 -33
  357. package/templates/inventory/app/workspace.tsx +34 -16
  358. package/templates/inventory/app.ts +10 -2
  359. package/templates/inventory/components/app-shell.tsx +94 -0
  360. package/templates/inventory/components/auth-form.tsx +35 -5
  361. package/templates/inventory/components/avatar.tsx +5 -1
  362. package/templates/inventory/components/brand-mark.tsx +25 -0
  363. package/templates/inventory/components/category-tile.tsx +49 -0
  364. package/templates/inventory/components/command-palette.tsx +17 -17
  365. package/templates/inventory/components/data-table.tsx +97 -36
  366. package/templates/inventory/components/filter-tabs.tsx +101 -0
  367. package/templates/inventory/components/nav.tsx +14 -0
  368. package/templates/inventory/components/page-header.tsx +43 -7
  369. package/templates/inventory/components/require-auth.tsx +24 -26
  370. package/templates/inventory/components/sidebar.tsx +54 -48
  371. package/templates/inventory/components/stock-level.tsx +30 -14
  372. package/templates/inventory/components/summary-bar.tsx +8 -15
  373. package/templates/inventory/components/ui/button.tsx +5 -4
  374. package/templates/inventory/components/ui/dialog.tsx +1 -1
  375. package/templates/inventory/components/ui/input.tsx +1 -1
  376. package/templates/inventory/components/ui/select.tsx +1 -1
  377. package/templates/inventory/components/ui/textarea.tsx +1 -1
  378. package/templates/inventory/functions/seedWorkspace.ts +18 -8
  379. package/templates/inventory/lib/brand.ts +9 -0
  380. package/templates/inventory/lib/demo-team.ts +51 -0
  381. package/templates/inventory/lib/search.ts +4 -3
  382. package/templates/inventory/lib/seed.ts +29 -2
  383. package/templates/inventory/lib/stock.ts +32 -0
  384. package/templates/inventory/public/favicon.ico +0 -0
  385. package/templates/inventory/tests/demo-team.test.ts +18 -0
  386. package/templates/inventory/tests/stock.test.ts +29 -0
  387. package/templates/invoices/README.md +16 -0
  388. package/templates/invoices/app/apple-icon.png +0 -0
  389. package/templates/invoices/app/clients/clients-view.tsx +18 -6
  390. package/templates/invoices/app/clients/page.tsx +2 -1
  391. package/templates/invoices/app/globals.css +29 -14
  392. package/templates/invoices/app/icon.svg +1 -0
  393. package/templates/invoices/app/invoices/[id]/invoice-view.tsx +72 -48
  394. package/templates/invoices/app/invoices/[id]/page.tsx +2 -1
  395. package/templates/invoices/app/invoices-view.tsx +81 -32
  396. package/templates/invoices/app/layout.tsx +7 -5
  397. package/templates/invoices/app/page.tsx +4 -6
  398. package/templates/invoices/app/workspace.tsx +26 -16
  399. package/templates/invoices/app.ts +2 -2
  400. package/templates/invoices/components/app-shell.tsx +94 -0
  401. package/templates/invoices/components/auth-form.tsx +35 -5
  402. package/templates/invoices/components/avatar.tsx +5 -1
  403. package/templates/invoices/components/brand-mark.tsx +26 -0
  404. package/templates/invoices/components/command-palette.tsx +17 -17
  405. package/templates/invoices/components/data-table.tsx +97 -36
  406. package/templates/invoices/components/filter-tabs.tsx +101 -0
  407. package/templates/invoices/components/line-items.tsx +19 -14
  408. package/templates/invoices/components/nav.tsx +14 -0
  409. package/templates/invoices/components/page-header.tsx +43 -7
  410. package/templates/invoices/components/require-auth.tsx +24 -26
  411. package/templates/invoices/components/sidebar.tsx +54 -48
  412. package/templates/invoices/components/status-badge.tsx +7 -7
  413. package/templates/invoices/components/summary-bar.tsx +5 -5
  414. package/templates/invoices/components/ui/button.tsx +5 -4
  415. package/templates/invoices/components/ui/dialog.tsx +1 -1
  416. package/templates/invoices/components/ui/input.tsx +1 -1
  417. package/templates/invoices/components/ui/select.tsx +1 -1
  418. package/templates/invoices/components/ui/textarea.tsx +1 -1
  419. package/templates/invoices/functions/recordPayment.ts +3 -0
  420. package/templates/invoices/lib/billing.ts +40 -0
  421. package/templates/invoices/lib/brand.ts +9 -0
  422. package/templates/invoices/lib/search.ts +4 -3
  423. package/templates/invoices/public/favicon.ico +0 -0
  424. package/templates/invoices/tests/billing.test.ts +27 -0
  425. package/templates/ios/consumer/apps/ios/Package.swift +4 -3
  426. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/ActivityView.swift +107 -0
  427. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/AppModel.swift +171 -0
  428. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/AuthView.swift +182 -0
  429. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/CommentsView.swift +136 -0
  430. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/ComposeView.swift +152 -0
  431. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/ExploreView.swift +117 -0
  432. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/FeedView.swift +175 -159
  433. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/MainTabView.swift +54 -0
  434. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/Media.swift +271 -0
  435. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/Models.swift +101 -4
  436. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/PostCard.swift +245 -0
  437. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/ProfileEditorView.swift +208 -0
  438. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/ProfileView.swift +183 -0
  439. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/RootView.swift +25 -31
  440. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/SocialStore.swift +285 -0
  441. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/StoryViewer.swift +155 -0
  442. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/Theme.swift +82 -0
  443. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +4 -47
  444. package/templates/ios/consumer/apps/ios/project.yml +13 -9
  445. package/templates/local-service/.env.example +7 -0
  446. package/templates/local-service/README.md +26 -2
  447. package/templates/local-service/app/(marketing)/booking-widget.tsx +48 -9
  448. package/templates/local-service/app/(marketing)/page.tsx +66 -42
  449. package/templates/local-service/app/auth-form.tsx +8 -8
  450. package/templates/local-service/app/dashboard/dashboard-client.tsx +119 -92
  451. package/templates/local-service/app/dashboard/page.tsx +14 -14
  452. package/templates/local-service/app/icon.svg +2 -0
  453. package/templates/local-service/app/login/page.tsx +15 -15
  454. package/templates/local-service/functions/seedDemo.ts +49 -0
  455. package/templates/local-service/lib/demo.ts +135 -0
  456. package/templates/local-service/lib/hours.ts +44 -0
  457. package/templates/local-service/lib/site.config.ts +8 -3
  458. package/templates/local-service/lib/slots.ts +3 -0
  459. package/templates/local-service/public/favicon.ico +0 -0
  460. package/templates/local-service/tests/booking.test.ts +104 -0
  461. package/templates/marketplace/README.md +23 -15
  462. package/templates/marketplace/app/globals.css +172 -114
  463. package/templates/marketplace/app/layout.tsx +75 -30
  464. package/templates/marketplace/app/listing/[id]/page.tsx +210 -123
  465. package/templates/marketplace/app/me/page.tsx +4 -4
  466. package/templates/marketplace/app/page.tsx +221 -298
  467. package/templates/marketplace/app/sell/page.tsx +8 -11
  468. package/templates/marketplace/app.ts +32 -7
  469. package/templates/marketplace/client/ActivityFeed.tsx +311 -0
  470. package/templates/marketplace/client/AuthNav.tsx +24 -16
  471. package/templates/marketplace/client/ListingCard.tsx +118 -0
  472. package/templates/marketplace/client/LiveGrid.tsx +92 -0
  473. package/templates/marketplace/client/LiveListingStatus.tsx +32 -24
  474. package/templates/marketplace/client/LoginCard.tsx +114 -136
  475. package/templates/marketplace/client/MarketProvider.tsx +3 -5
  476. package/templates/marketplace/client/MyMarket.tsx +371 -185
  477. package/templates/marketplace/client/OfferPanel.tsx +421 -287
  478. package/templates/marketplace/client/SellForm.tsx +271 -192
  479. package/templates/marketplace/client/ThemeToggle.tsx +1 -0
  480. package/templates/marketplace/client/WatchButton.tsx +16 -16
  481. package/templates/marketplace/client/market.ts +61 -14
  482. package/templates/marketplace/functions/seedMarket.ts +143 -36
  483. package/templates/marketplace/functions/seedOffers.ts +101 -0
  484. package/templates/marketplace/lib/catalog.ts +72 -1
  485. package/templates/marketplace/public/images/listings/ceramic-lamp-640.webp +0 -0
  486. package/templates/marketplace/public/images/listings/ceramic-lamp.webp +0 -0
  487. package/templates/marketplace/public/images/listings/duffel-640.webp +0 -0
  488. package/templates/marketplace/public/images/listings/duffel.webp +0 -0
  489. package/templates/marketplace/public/images/listings/film-camera-640.webp +0 -0
  490. package/templates/marketplace/public/images/listings/film-camera.webp +0 -0
  491. package/templates/marketplace/public/images/listings/lounge-chair-640.webp +0 -0
  492. package/templates/marketplace/public/images/listings/lounge-chair.webp +0 -0
  493. package/templates/marketplace/public/images/listings/road-bike-640.webp +0 -0
  494. package/templates/marketplace/public/images/listings/road-bike.webp +0 -0
  495. package/templates/marketplace/public/images/listings/slipper-chair-640.webp +0 -0
  496. package/templates/marketplace/public/images/listings/slipper-chair.webp +0 -0
  497. package/templates/marketplace/public/images/listings/turntable-640.webp +0 -0
  498. package/templates/marketplace/public/images/listings/turntable.webp +0 -0
  499. package/templates/marketplace/public/images/listings/wardrobe-640.webp +0 -0
  500. package/templates/marketplace/public/images/listings/wardrobe.webp +0 -0
  501. package/templates/marketplace/public/images/listings/watch-640.webp +0 -0
  502. package/templates/marketplace/public/images/listings/watch.webp +0 -0
  503. package/templates/marketplace/public/images/listings/weekender-bag-640.webp +0 -0
  504. package/templates/marketplace/public/images/listings/weekender-bag.webp +0 -0
  505. package/templates/marketplace/tests/example.test.ts +64 -2
  506. package/templates/marketplace/tests/listing-card.test.tsx +61 -0
  507. package/templates/projects/README.md +24 -3
  508. package/templates/projects/app/apple-icon.png +0 -0
  509. package/templates/projects/app/clients/clients-view.tsx +20 -6
  510. package/templates/projects/app/clients/page.tsx +2 -1
  511. package/templates/projects/app/globals.css +29 -14
  512. package/templates/projects/app/icon.svg +1 -0
  513. package/templates/projects/app/layout.tsx +7 -5
  514. package/templates/projects/app/page.tsx +3 -5
  515. package/templates/projects/app/projects/[id]/page.tsx +2 -1
  516. package/templates/projects/app/projects/[id]/project-view.tsx +45 -30
  517. package/templates/projects/app/projects-view.tsx +105 -52
  518. package/templates/projects/app/workspace.tsx +41 -20
  519. package/templates/projects/app.ts +2 -2
  520. package/templates/projects/components/app-shell.tsx +94 -0
  521. package/templates/projects/components/auth-form.tsx +35 -5
  522. package/templates/projects/components/avatar.tsx +5 -1
  523. package/templates/projects/components/board-skeleton.tsx +3 -3
  524. package/templates/projects/components/brand-mark.tsx +25 -0
  525. package/templates/projects/components/command-palette.tsx +17 -17
  526. package/templates/projects/components/data-table.tsx +97 -36
  527. package/templates/projects/components/filter-tabs.tsx +101 -0
  528. package/templates/projects/components/meter.tsx +48 -0
  529. package/templates/projects/components/nav.tsx +14 -0
  530. package/templates/projects/components/page-header.tsx +43 -7
  531. package/templates/projects/components/require-auth.tsx +24 -26
  532. package/templates/projects/components/sidebar.tsx +54 -48
  533. package/templates/projects/components/summary-bar.tsx +49 -0
  534. package/templates/projects/components/task-board.tsx +10 -9
  535. package/templates/projects/components/time-dialog.tsx +29 -5
  536. package/templates/projects/components/ui/button.tsx +5 -4
  537. package/templates/projects/components/ui/dialog.tsx +1 -1
  538. package/templates/projects/components/ui/input.tsx +1 -1
  539. package/templates/projects/components/ui/select.tsx +1 -1
  540. package/templates/projects/components/ui/textarea.tsx +1 -1
  541. package/templates/projects/functions/seedWorkspace.ts +26 -12
  542. package/templates/projects/lib/brand.ts +9 -0
  543. package/templates/projects/lib/demo-team.ts +51 -0
  544. package/templates/projects/lib/search.ts +4 -3
  545. package/templates/projects/lib/seed.ts +35 -1
  546. package/templates/projects/lib/work.ts +63 -0
  547. package/templates/projects/public/favicon.ico +0 -0
  548. package/templates/projects/tests/demo-team.test.ts +18 -0
  549. package/templates/projects/tests/work.test.ts +65 -0
  550. package/templates/restaurant/.env.example +7 -0
  551. package/templates/restaurant/README.md +29 -3
  552. package/templates/restaurant/app/(marketing)/layout.tsx +4 -2
  553. package/templates/restaurant/app/(marketing)/page.tsx +47 -23
  554. package/templates/restaurant/app/(marketing)/reservation-widget.tsx +37 -7
  555. package/templates/restaurant/app/auth-form.tsx +8 -8
  556. package/templates/restaurant/app/dashboard/dashboard-client.tsx +241 -109
  557. package/templates/restaurant/app/dashboard/page.tsx +14 -14
  558. package/templates/restaurant/app/icon.svg +4 -0
  559. package/templates/restaurant/app/login/page.tsx +15 -15
  560. package/templates/restaurant/functions/seedDemo.ts +44 -0
  561. package/templates/restaurant/lib/demo.ts +144 -0
  562. package/templates/restaurant/lib/hours.ts +45 -0
  563. package/templates/restaurant/lib/site.config.ts +3 -3
  564. package/templates/restaurant/public/favicon.ico +0 -0
  565. package/templates/restaurant/tests/reservations.test.ts +67 -0
  566. package/templates/saas/README.md +54 -35
  567. package/templates/saas/app/(auth)/auth-form.tsx +25 -23
  568. package/templates/saas/app/(auth)/forgot-password/page.tsx +1 -1
  569. package/templates/saas/app/(auth)/layout.tsx +27 -33
  570. package/templates/saas/app/(auth)/login/page.tsx +1 -1
  571. package/templates/saas/app/(auth)/reset-password/page.tsx +1 -1
  572. package/templates/saas/app/(auth)/signup/page.tsx +1 -1
  573. package/templates/saas/app/(marketing)/layout.tsx +127 -253
  574. package/templates/saas/app/(marketing)/page.tsx +133 -372
  575. package/templates/saas/app/(marketing)/pricing/page.tsx +21 -19
  576. package/templates/saas/app/(marketing)/products/[slug]/page.tsx +40 -95
  577. package/templates/saas/app/dashboard/board-client.tsx +416 -0
  578. package/templates/saas/app/dashboard/dashboard-client.tsx +30 -615
  579. package/templates/saas/app/dashboard/layout.tsx +27 -13
  580. package/templates/saas/app/dashboard/overview-client.tsx +332 -0
  581. package/templates/saas/app/dashboard/page.tsx +29 -35
  582. package/templates/saas/app/dashboard/projects/[id]/page.tsx +29 -0
  583. package/templates/saas/app/dashboard/projects/page.tsx +11 -7
  584. package/templates/saas/app/dashboard/projects-client.tsx +398 -0
  585. package/templates/saas/app/globals.css +5 -4
  586. package/templates/saas/app/icon.svg +1 -0
  587. package/templates/saas/app/onboarding/onboarding-client.tsx +93 -26
  588. package/templates/saas/app/onboarding/page.tsx +6 -2
  589. package/templates/saas/app.ts +68 -13
  590. package/templates/saas/components/dashboard-shell.tsx +212 -132
  591. package/templates/saas/components/marketing.tsx +137 -274
  592. package/templates/saas/components/pricing-table.tsx +12 -11
  593. package/templates/saas/components/setup-checklist.tsx +31 -29
  594. package/templates/saas/components/task-ui.tsx +199 -0
  595. package/templates/saas/functions/createProject.ts +7 -13
  596. package/templates/saas/functions/createTask.ts +67 -0
  597. package/templates/saas/functions/deleteProject.ts +17 -0
  598. package/templates/saas/functions/deleteTask.ts +14 -0
  599. package/templates/saas/functions/seedWorkspace.ts +46 -0
  600. package/templates/saas/functions/setProjectStatus.ts +41 -0
  601. package/templates/saas/functions/updateProfile.ts +13 -0
  602. package/templates/saas/functions/updateProject.ts +4 -5
  603. package/templates/saas/functions/updateTask.ts +88 -0
  604. package/templates/saas/lib/members.ts +53 -0
  605. package/templates/saas/lib/names.ts +53 -0
  606. package/templates/saas/lib/plans.ts +11 -0
  607. package/templates/saas/lib/sample-workspace.ts +117 -0
  608. package/templates/saas/lib/site.config.ts +200 -463
  609. package/templates/saas/lib/tasks.ts +101 -0
  610. package/templates/saas/lib/use-live.ts +25 -0
  611. package/templates/saas/public/favicon.ico +0 -0
  612. package/templates/saas/public/screenshots/board.png +0 -0
  613. package/templates/saas/public/screenshots/members.png +0 -0
  614. package/templates/saas/public/screenshots/overview.png +0 -0
  615. package/templates/saas/public/screenshots/projects.png +0 -0
  616. package/templates/saas/scripts/capture-screenshots.mjs +156 -0
  617. package/templates/saas/tests/names.test.ts +35 -0
  618. package/templates/saas/tests/plans.test.ts +12 -0
  619. package/templates/saas/tests/sample-workspace.test.ts +46 -0
  620. package/templates/saas/tests/tasks.test.ts +81 -0
  621. package/templates/shop/README.md +25 -13
  622. package/templates/shop/app/(marketing)/layout.tsx +94 -67
  623. package/templates/shop/app/(marketing)/not-found.tsx +13 -12
  624. package/templates/shop/app/(marketing)/page.tsx +120 -59
  625. package/templates/shop/app/(marketing)/shop-client.tsx +364 -237
  626. package/templates/shop/app/(marketing)/success/page.tsx +75 -22
  627. package/templates/shop/app/globals.css +94 -1
  628. package/templates/shop/app/layout.tsx +4 -4
  629. package/templates/shop/app/login/page.tsx +4 -4
  630. package/templates/shop/app.ts +18 -6
  631. package/templates/shop/components/cart-button.tsx +33 -0
  632. package/templates/shop/components/marketing.tsx +17 -87
  633. package/templates/shop/components/shelf-count.tsx +36 -0
  634. package/templates/shop/functions/checkout.ts +17 -1
  635. package/templates/shop/lib/cart-store.ts +57 -0
  636. package/templates/shop/lib/shelf-store.ts +35 -0
  637. package/templates/shop/lib/shop.ts +27 -0
  638. package/templates/shop/lib/site.config.ts +108 -98
  639. package/templates/shop/public/images/hero.jpg +0 -0
  640. package/templates/shop/public/images/products/cedar-smoke.jpg +0 -0
  641. package/templates/shop/public/images/products/fig-leaf.jpg +0 -0
  642. package/templates/shop/public/images/products/linen.jpg +0 -0
  643. package/templates/shop/public/images/products/sea-salt.jpg +0 -0
  644. package/templates/shop/public/images/products/tobacco-oak.jpg +0 -0
  645. package/templates/shop/tests/shop.test.ts +64 -0
  646. package/templates/waitlist/.env.example +7 -0
  647. package/templates/waitlist/README.md +29 -13
  648. package/templates/waitlist/app/(marketing)/page.tsx +9 -7
  649. package/templates/waitlist/app/(marketing)/waitlist-hero.tsx +64 -31
  650. package/templates/waitlist/app/auth-form.tsx +8 -8
  651. package/templates/waitlist/app/dashboard/dashboard-client.tsx +132 -95
  652. package/templates/waitlist/app/dashboard/page.tsx +17 -16
  653. package/templates/waitlist/app/icon.svg +2 -0
  654. package/templates/waitlist/app/login/page.tsx +14 -14
  655. package/templates/waitlist/app.ts +6 -6
  656. package/templates/waitlist/functions/joinWaitlist.ts +6 -18
  657. package/templates/waitlist/functions/seedDemo.ts +32 -0
  658. package/templates/waitlist/lib/demo.ts +94 -0
  659. package/templates/waitlist/lib/launch.ts +19 -0
  660. package/templates/waitlist/lib/site.config.ts +14 -9
  661. package/templates/waitlist/lib/stats.ts +35 -0
  662. package/templates/waitlist/public/favicon.ico +0 -0
  663. package/templates/waitlist/tests/counter.test.ts +107 -0
  664. package/templates/waitlist/tests/live-count.test.tsx +48 -0
  665. package/templates/ai-chat/tests/example.test.ts +0 -12
  666. package/templates/backend/consumer/apps/api/functions/feed.ts +0 -57
  667. package/templates/backend/consumer/apps/api/functions/profilePosts.ts +0 -17
  668. package/templates/backend/consumer/apps/api/functions/toggleLike.ts +0 -48
  669. package/templates/backend/consumer/apps/api/schema.ts +0 -143
  670. package/templates/chat/functions/_keep.ts +0 -13
  671. package/templates/chat/tests/example.test.ts +0 -12
  672. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/ProfileSetupView.swift +0 -68
  673. package/templates/marketplace/client/LiveTicker.tsx +0 -116
  674. package/templates/marketplace/client/ScrollToListingsLink.tsx +0 -29
  675. package/templates/marketplace/public/images/marketplace-hero.webp +0 -0
@@ -1,18 +1,33 @@
1
1
  "use client";
2
2
 
3
- import React, { useEffect, useMemo, useState } from "react";
4
- import { db, callFn } from "@pylonsync/react";
3
+ import React, { useEffect, useMemo, useRef, useState } from "react";
4
+ import { db, callFn, useRouter } from "@pylonsync/react";
5
5
  import { EnsureGuest } from "@pylonsync/client";
6
- import { siteConfig } from "@/lib/site.config";
7
- import { formatPrice, type CheckoutResult } from "@/lib/shop";
8
-
9
- // The storefront — the realtime heart of this template. The grid subscribes to
10
- // the public Product entity with `db.useQuery`, so every card's stock ("3 left")
11
- // ticks down — and flips to "Sold out" — for everyone with the page open the
12
- // instant someone checks out. Shopping is a normal cart: add items, then check
13
- // out (name + email). The `checkout` action holds stock under a per-product lock
14
- // (so the cart can't oversell) and, when Stripe is configured, hands the browser
15
- // off to a hosted Stripe Checkout page; otherwise it records a reserved order.
6
+ import { Minus, Plus, X } from "lucide-react";
7
+ import { siteConfig, productBySlug } from "@/lib/site.config";
8
+ import {
9
+ formatPrice,
10
+ freeShippingRemaining,
11
+ shippingCents,
12
+ stockStatus,
13
+ LOW_STOCK_AT,
14
+ type CheckoutResult,
15
+ } from "@/lib/shop";
16
+ import { cart, countItems, useCart, type CartLines } from "@/lib/cart-store";
17
+ import { publishShelf } from "@/lib/shelf-store";
18
+
19
+ // The storefront, and the realtime part of this template. The grid subscribes
20
+ // to the public Product entity with `db.useQuery`, so every card's stock
21
+ // ("Only 2 left") ticks down, and flips to "Sold out", for everyone with the
22
+ // page open when someone checks out. Shopping is a normal cart: add items, then
23
+ // check out (name + email). The `checkout` action holds stock under a
24
+ // per-product lock (so the cart can't oversell) and, when Stripe is configured,
25
+ // hands the browser off to a hosted Stripe Checkout page; otherwise it records
26
+ // a reserved order and the shopper lands on /success.
27
+ //
28
+ // Cart contents live in lib/cart-store.ts so the nav's cart button (a separate
29
+ // client island) shows the same count and can open the panel rendered here.
30
+ // Shelf totals go to lib/shelf-store.ts for the hero's live count.
16
31
  //
17
32
  // Wrapped in <EnsureGuest> so the sync connection is established for anonymous
18
33
  // visitors. The guest session holds no PII and can't read the Order table; the
@@ -28,6 +43,8 @@ interface ProductRow {
28
43
  stock: number;
29
44
  }
30
45
 
46
+ type CartEntry = { product: ProductRow; qty: number };
47
+
31
48
  export function ShopGrid() {
32
49
  return (
33
50
  <EnsureGuest fallback={<GridSkeleton />}>
@@ -38,15 +55,17 @@ export function ShopGrid() {
38
55
 
39
56
  function Shop() {
40
57
  const { data: products, loading } = db.useQuery<ProductRow>("Product");
41
- const [cart, setCart] = useState<Record<string, number>>({}); // slug → qty
42
- const [checkingOut, setCheckingOut] = useState(false);
43
- const [placed, setPlaced] = useState(false);
58
+ const { lines, open } = useCart();
44
59
 
45
60
  // Seed the catalog from config on first visit (idempotent server-side).
46
61
  useEffect(() => {
47
62
  void callFn("seedProducts", {});
48
63
  }, []);
49
64
 
65
+ useEffect(() => {
66
+ if (products.length > 0) publishShelf(products.map((p) => p.stock), LOW_STOCK_AT);
67
+ }, [products]);
68
+
50
69
  const bySlug = useMemo(() => new Map(products.map((p) => [p.slug, p])), [products]);
51
70
  const ordered = useMemo(() => {
52
71
  const idx = new Map(siteConfig.products.items.map((p, i) => [p.slug, i]));
@@ -56,7 +75,7 @@ function Shop() {
56
75
  function setQty(slug: string, qty: number) {
57
76
  const stock = bySlug.get(slug)?.stock ?? 0;
58
77
  const clamped = Math.max(0, Math.min(qty, stock));
59
- setCart((c) => {
78
+ cart.setLines((c: CartLines) => {
60
79
  const next = { ...c };
61
80
  if (clamped <= 0) delete next[slug];
62
81
  else next[slug] = clamped;
@@ -65,228 +84,253 @@ function Shop() {
65
84
  }
66
85
 
67
86
  // Cart entries that still reference a real product, clamped to live stock.
68
- const entries = Object.entries(cart)
87
+ const entries = Object.entries(lines)
69
88
  .map(([slug, qty]) => {
70
89
  const p = bySlug.get(slug);
71
90
  return p ? { product: p, qty: Math.min(qty, p.stock) } : null;
72
91
  })
73
- .filter((x): x is { product: ProductRow; qty: number } => !!x && x.qty > 0);
74
- const itemCount = entries.reduce((s, e) => s + e.qty, 0);
75
- const total = entries.reduce((s, e) => s + e.product.priceCents * e.qty, 0);
92
+ .filter((x): x is CartEntry => !!x && x.qty > 0);
76
93
 
77
94
  if (loading && products.length === 0) return <GridSkeleton />;
78
95
 
79
96
  return (
80
97
  <>
81
- <div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
82
- {ordered.map((p) => (
98
+ {/* The first product is featured: two columns wide on tablet, and two
99
+ columns by two rows on desktop, beside a 2x2 block of the rest. */}
100
+ <div className={GRID}>
101
+ {ordered.map((p, i) => (
83
102
  <ProductCard
84
103
  key={p.id}
104
+ featured={i === 0}
85
105
  product={p}
86
- qty={cart[p.slug] ?? 0}
87
- onAdd={() => setQty(p.slug, (cart[p.slug] ?? 0) + 1)}
106
+ qty={lines[p.slug] ?? 0}
107
+ onAdd={() => setQty(p.slug, (lines[p.slug] ?? 0) + 1)}
88
108
  onSet={(q) => setQty(p.slug, q)}
89
109
  />
90
110
  ))}
91
111
  </div>
92
112
 
93
- {/* Sticky cart bar */}
94
- {itemCount > 0 ? (
95
- <div className="pointer-events-none fixed inset-x-0 bottom-0 z-40 px-4 pb-4">
96
- <div className="pointer-events-auto mx-auto flex max-w-md items-center justify-between gap-4 rounded-full border border-zinc-200 bg-white px-5 py-3 shadow-[0_16px_48px_-16px_rgba(0,0,0,0.3)]">
97
- <span className="text-[14px] font-medium text-zinc-900">
98
- {itemCount} {itemCount === 1 ? "item" : "items"}
99
- <span className="ml-2 text-zinc-400">·</span>
100
- <span className="ml-2 tabular-nums">{formatPrice(total)}</span>
101
- </span>
102
- <button
103
- type="button"
104
- onClick={() => {
105
- setPlaced(false);
106
- setCheckingOut(true);
107
- }}
108
- className="inline-flex h-9 items-center rounded-full bg-brand px-5 text-[14px] font-medium text-white transition-opacity hover:opacity-90"
109
- >
110
- Checkout →
111
- </button>
112
- </div>
113
- </div>
114
- ) : null}
115
-
116
- {/* Checkout modal */}
117
- {checkingOut ? (
118
- <CheckoutModal
119
- entries={entries}
120
- total={total}
121
- placed={placed}
122
- onClose={() => setCheckingOut(false)}
123
- onSetQty={setQty}
124
- onPlaced={(succeededSlugs) => {
125
- setCart((c) => {
126
- const next = { ...c };
127
- succeededSlugs.forEach((s) => delete next[s]);
128
- return next;
129
- });
130
- setPlaced(true);
131
- }}
132
- />
133
- ) : null}
113
+ {open ? <CartPanel entries={entries} onClose={cart.close} onSetQty={setQty} /> : null}
134
114
  </>
135
115
  );
136
116
  }
137
117
 
118
+ const GRID = "grid gap-x-6 gap-y-14 sm:grid-cols-2 lg:grid-cols-4 lg:gap-x-8";
119
+ const FEATURED_CELL = "sm:col-span-2 lg:row-span-2";
120
+
138
121
  function ProductCard({
122
+ featured,
139
123
  product,
140
124
  qty,
141
125
  onAdd,
142
126
  onSet,
143
127
  }: {
128
+ featured?: boolean;
144
129
  product: ProductRow;
145
130
  qty: number;
146
131
  onAdd: () => void;
147
132
  onSet: (q: number) => void;
148
133
  }) {
149
134
  const soldOut = product.stock <= 0;
135
+ const size = productBySlug(product.slug)?.size;
150
136
  return (
151
- <div className="flex flex-col overflow-hidden rounded-2xl border border-zinc-200 bg-white">
152
- <ProductImage image={product.image} soldOut={soldOut} />
153
- <div className="flex flex-1 flex-col p-5">
154
- <div className="flex items-start justify-between gap-3">
155
- <h3 className="text-[15px] font-semibold text-zinc-900">{product.name}</h3>
156
- <span className="shrink-0 text-[15px] font-semibold text-zinc-900">
157
- {formatPrice(product.priceCents)}
137
+ <article className={"group flex flex-col " + (featured ? FEATURED_CELL : "")}>
138
+ <div
139
+ className={
140
+ "relative overflow-hidden bg-stone " +
141
+ (featured ? "aspect-[4/5] sm:aspect-[5/4] lg:aspect-auto lg:min-h-[480px] lg:flex-1" : "aspect-[4/5]")
142
+ }
143
+ >
144
+ <ProductPhoto
145
+ image={product.image}
146
+ name={product.name}
147
+ className={
148
+ "size-full object-cover transition-[transform,opacity,filter] duration-[1400ms] ease-out-quint group-hover:scale-[1.035] " +
149
+ (soldOut ? "opacity-60 grayscale-[0.6]" : "")
150
+ }
151
+ />
152
+ {soldOut ? (
153
+ <span className="absolute top-4 left-4 rounded-full bg-warm-white/90 px-3 py-1 text-[12px] font-medium text-ink">
154
+ Sold out
158
155
  </span>
159
- </div>
160
- {product.description ? (
161
- <p className="mt-1.5 text-[13px] leading-relaxed text-zinc-500">{product.description}</p>
162
156
  ) : null}
157
+ </div>
163
158
 
164
- <div className="mt-3">
165
- <StockBadge stock={product.stock} />
166
- </div>
159
+ <div className="mt-5 flex items-baseline justify-between gap-4">
160
+ <h3
161
+ className={
162
+ "font-display leading-tight tracking-[-0.01em] text-ink " +
163
+ (featured ? "text-[2rem] sm:text-[2.25rem]" : "text-[1.375rem]")
164
+ }
165
+ >
166
+ {product.name}
167
+ </h3>
168
+ <span className="shrink-0 text-[15px] font-medium tabular-nums text-ink">
169
+ {formatPrice(product.priceCents)}
170
+ </span>
171
+ </div>
172
+ {product.description ? (
173
+ <p className="mt-1.5 max-w-[38ch] text-[14px] leading-relaxed text-ink-2">{product.description}</p>
174
+ ) : null}
175
+ {size ? <p className="mt-1 text-[13px] text-ink-2/75">{size}</p> : null}
167
176
 
168
- <div className="mt-4">
169
- {qty > 0 ? (
170
- <div className="flex items-center justify-between rounded-lg border border-zinc-200 px-1.5 py-1">
171
- <button
172
- type="button"
173
- onClick={() => onSet(qty - 1)}
174
- aria-label="Decrease quantity"
175
- className="flex size-7 items-center justify-center rounded-md text-zinc-600 hover:bg-zinc-100"
176
- >
177
- −
178
- </button>
179
- <span className="text-[14px] font-medium tabular-nums">{qty} in cart</span>
180
- <button
181
- type="button"
182
- onClick={onAdd}
183
- disabled={qty >= product.stock}
184
- aria-label="Increase quantity"
185
- className="flex size-7 items-center justify-center rounded-md text-zinc-600 hover:bg-zinc-100 disabled:opacity-30"
186
- >
187
- +
188
- </button>
189
- </div>
190
- ) : (
191
- <button
192
- type="button"
193
- disabled={soldOut}
194
- onClick={onAdd}
195
- className={
196
- "inline-flex h-10 w-full items-center justify-center rounded-lg text-sm font-medium transition-colors " +
197
- (soldOut
198
- ? "cursor-not-allowed bg-zinc-100 text-zinc-400"
199
- : "bg-brand text-white hover:opacity-90")
200
- }
201
- >
202
- {soldOut ? "Sold out" : "Add to cart"}
203
- </button>
204
- )}
205
- </div>
177
+ <div className="mt-auto flex min-h-15 items-center justify-between gap-4 pt-5">
178
+ <StockLine stock={product.stock} />
179
+ {qty > 0 ? (
180
+ <Stepper
181
+ qty={qty}
182
+ max={product.stock}
183
+ onDec={() => onSet(qty - 1)}
184
+ onInc={onAdd}
185
+ label={product.name}
186
+ />
187
+ ) : soldOut ? null : (
188
+ <button
189
+ type="button"
190
+ onClick={onAdd}
191
+ className="inline-flex h-10 shrink-0 items-center justify-center gap-1.5 rounded-full border border-ink/25 px-5 text-[13.5px] font-medium text-ink transition-[background-color,color,border-color,transform] duration-300 ease-out-quint hover:border-ink hover:bg-ink hover:text-warm-white active:scale-[0.97]"
192
+ >
193
+ <Plus className="size-3.5" strokeWidth={1.5} />
194
+ Add to cart
195
+ </button>
196
+ )}
206
197
  </div>
207
- </div>
198
+ </article>
208
199
  );
209
200
  }
210
201
 
211
- // Product image. `image` in lib/site.config.ts is either a real photo URL (or
212
- // /public path) — rendered as an <img> — or an emoji stand-in, which we render
213
- // big AND tag "sample image" so it's obvious it's a placeholder to replace with
214
- // a real product photo. Swap the config value for a URL and the photo shows up.
215
- function ProductImage({ image, soldOut }: { image: string; soldOut: boolean }) {
216
- const isPhoto = /^(https?:\/\/|\/)/.test(image);
217
- if (isPhoto) {
202
+ // A product photo from lib/site.config.ts. If the file is missing, the frame
203
+ // shows the product name on the stone background instead of a broken image.
204
+ function ProductPhoto({ image, name, className }: { image: string; name: string; className?: string }) {
205
+ const [failed, setFailed] = useState(false);
206
+ if (failed || !image) {
218
207
  return (
219
- <div className="aspect-[4/3] overflow-hidden bg-paper">
220
- {/* eslint-disable-next-line @next/next/no-img-element */}
221
- <img
222
- src={image}
223
- alt=""
224
- className={"size-full object-cover " + (soldOut ? "opacity-40 grayscale" : "")}
225
- />
208
+ <div className="grid size-full place-items-center p-6 text-center">
209
+ <span className="font-display text-[1.75rem] font-light text-ink/40">{name}</span>
226
210
  </div>
227
211
  );
228
212
  }
213
+ // eslint-disable-next-line @next/next/no-img-element
214
+ return <img src={image} alt={name} loading="lazy" onError={() => setFailed(true)} className={className} />;
215
+ }
216
+
217
+ // Stock status with a colored dot. When the live count changes, the line
218
+ // flashes once so the change is visible.
219
+ function StockLine({ stock }: { stock: number }) {
220
+ const status = stockStatus(stock);
221
+ const flash = useChangeCount(stock);
222
+ const dot =
223
+ status.kind === "sold_out" ? "bg-ink/25" : status.kind === "low" ? "bg-brand" : "bg-[#5d7a4a]";
229
224
  return (
230
- <div className="relative grid aspect-[4/3] place-items-center bg-paper">
231
- <span className={"text-6xl " + (soldOut ? "opacity-40 grayscale" : "")}>{image}</span>
232
- <span className="absolute left-2 top-2 rounded-full border border-dashed border-zinc-300 bg-white/70 px-2 py-0.5 text-[10px] font-medium text-zinc-400">
233
- sample image
225
+ <p
226
+ key={flash}
227
+ className={
228
+ "flex items-center gap-2 rounded-full px-1 -mx-1 text-[13px] tabular-nums " +
229
+ (flash > 0 ? "stock-tick " : "") +
230
+ (status.kind === "low" ? "font-medium text-brand" : "text-ink-2")
231
+ }
232
+ aria-live="polite"
233
+ >
234
+ <span className="relative flex size-1.5">
235
+ {status.kind === "low" ? <span className={`ping-soft absolute inset-0 rounded-full ${dot}`} /> : null}
236
+ <span className={`relative size-1.5 rounded-full ${dot}`} />
234
237
  </span>
235
- </div>
238
+ {status.label}
239
+ </p>
236
240
  );
237
241
  }
238
242
 
239
- function StockBadge({ stock }: { stock: number }) {
240
- if (stock <= 0) {
241
- return (
242
- <span className="inline-flex items-center gap-1.5 rounded-full bg-zinc-100 px-2.5 py-1 text-[12px] font-medium text-zinc-500">
243
- Sold out
244
- </span>
245
- );
246
- }
247
- const low = stock <= 3;
243
+ // Counts how many times `value` has changed since mount (0 on first render).
244
+ function useChangeCount(value: number) {
245
+ const prev = useRef(value);
246
+ const [count, setCount] = useState(0);
247
+ useEffect(() => {
248
+ if (prev.current !== value) {
249
+ prev.current = value;
250
+ setCount((c) => c + 1);
251
+ }
252
+ }, [value]);
253
+ return count;
254
+ }
255
+
256
+ function Stepper({
257
+ qty,
258
+ max,
259
+ onDec,
260
+ onInc,
261
+ label,
262
+ small,
263
+ }: {
264
+ qty: number;
265
+ max: number;
266
+ onDec: () => void;
267
+ onInc: () => void;
268
+ label: string;
269
+ small?: boolean;
270
+ }) {
271
+ const btn =
272
+ "flex items-center justify-center rounded-full text-ink/70 transition-colors duration-200 hover:bg-ink/[0.06] hover:text-ink disabled:opacity-30 disabled:hover:bg-transparent " +
273
+ (small ? "size-7" : "size-8");
248
274
  return (
249
- <span
275
+ <div
250
276
  className={
251
- "inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[12px] font-medium " +
252
- (low ? "bg-red-50 text-red-600" : "bg-green-50 text-green-700")
277
+ "inline-flex shrink-0 items-center rounded-full border border-ink/20 " + (small ? "h-9 px-0.5" : "h-10 px-1")
253
278
  }
254
279
  >
255
- <span className="relative flex size-1.5">
256
- <span className={"absolute inline-flex size-1.5 animate-ping rounded-full " + (low ? "bg-red-500/70" : "bg-green-500/60")} />
257
- <span className={"relative inline-flex size-1.5 rounded-full " + (low ? "bg-red-500" : "bg-green-600")} />
280
+ <button type="button" onClick={onDec} aria-label={`Remove one ${label}`} className={btn}>
281
+ <Minus className="size-3.5" strokeWidth={1.5} />
282
+ </button>
283
+ <span className="min-w-7 text-center text-[13.5px] font-medium tabular-nums text-ink" aria-label={`${qty} in cart`}>
284
+ {qty}
258
285
  </span>
259
- {low ? `Only ${stock} left` : `${stock} in stock`}
260
- </span>
286
+ <button type="button" onClick={onInc} disabled={qty >= max} aria-label={`Add one ${label}`} className={btn}>
287
+ <Plus className="size-3.5" strokeWidth={1.5} />
288
+ </button>
289
+ </div>
261
290
  );
262
291
  }
263
292
 
264
- function CheckoutModal({
293
+ // The cart, as a panel sliding in from the right. Opened from the nav cart
294
+ // button (components/cart-button.tsx) through the shared store.
295
+ function CartPanel({
265
296
  entries,
266
- total,
267
- placed,
268
297
  onClose,
269
298
  onSetQty,
270
- onPlaced,
271
299
  }: {
272
- entries: { product: ProductRow; qty: number }[];
273
- total: number;
274
- placed: boolean;
300
+ entries: CartEntry[];
275
301
  onClose: () => void;
276
302
  onSetQty: (slug: string, qty: number) => void;
277
- onPlaced: (succeededSlugs: string[]) => void;
278
303
  }) {
304
+ const router = useRouter();
279
305
  const [name, setName] = useState("");
280
306
  const [email, setEmail] = useState("");
281
307
  const [status, setStatus] = useState<"idle" | "placing">("idle");
282
308
  const [error, setError] = useState<string | null>(null);
283
- const [soldOutNote, setSoldOutNote] = useState<string[]>([]);
309
+
310
+ const terms = siteConfig.checkout.shipping;
311
+ const subtotal = entries.reduce((s, e) => s + e.product.priceCents * e.qty, 0);
312
+ const shipping = shippingCents(subtotal, terms);
313
+ const remaining = freeShippingRemaining(subtotal, terms);
314
+ const count = countItems(Object.fromEntries(entries.map((e) => [e.product.slug, e.qty])));
315
+
316
+ useEffect(() => {
317
+ function onKey(e: KeyboardEvent) {
318
+ if (e.key === "Escape") onClose();
319
+ }
320
+ window.addEventListener("keydown", onKey);
321
+ const overflow = document.body.style.overflow;
322
+ document.body.style.overflow = "hidden";
323
+ return () => {
324
+ window.removeEventListener("keydown", onKey);
325
+ document.body.style.overflow = overflow;
326
+ };
327
+ }, [onClose]);
284
328
 
285
329
  async function submitCheckout(e: React.FormEvent) {
286
330
  e.preventDefault();
287
331
  if (status === "placing") return;
288
332
  if (!name.trim() || !email.trim()) {
289
- setError("Name and email are required.");
333
+ setError("Enter your name and email.");
290
334
  return;
291
335
  }
292
336
  setStatus("placing");
@@ -304,130 +348,213 @@ function CheckoutModal({
304
348
  });
305
349
  } catch (err) {
306
350
  setStatus("idle");
307
- setError(err instanceof Error ? err.message : "Checkout failed. Please try again.");
351
+ setError(err instanceof Error ? err.message : "Checkout failed. Try again.");
308
352
  return;
309
353
  }
310
354
 
311
355
  if (res.ok && res.mode === "stripe") {
312
- // Hand off to Stripe's hosted checkout — keep the button busy while the
356
+ // Hand off to Stripe's hosted checkout. The button stays busy while the
313
357
  // browser navigates away. Stock is already held; the webhook settles it.
314
358
  window.location.href = res.checkoutUrl;
315
359
  return;
316
360
  }
317
361
 
318
- setStatus("idle");
319
362
  if (res.ok && res.mode === "reserved") {
320
- // No payment processor configured — the order is held for the owner.
321
- setSoldOutNote(res.soldOut);
322
- onPlaced(entries.map((it) => it.product.slug));
363
+ // No payment processor configured: the order is held for the owner.
364
+ // Empty the cart and show the confirmation page.
365
+ const sold = res.soldOut;
366
+ cart.setLines(() => ({}));
367
+ cart.close();
368
+ const qs = new URLSearchParams({ order: "reserved" });
369
+ if (sold.length > 0) qs.set("sold", sold.join(","));
370
+ router.push(`/success?${qs.toString()}`);
323
371
  return;
324
372
  }
325
- // res.ok === false
373
+
374
+ setStatus("idle");
326
375
  if (res.reason === "sold_out") {
327
376
  setError(`Sold out before checkout: ${res.soldOut.join(", ") || "those items"}. Nothing was ordered.`);
328
377
  } else if (res.reason === "empty") {
329
378
  setError("Your cart is empty.");
330
379
  } else {
331
- setError("Could not place the order. Please check your details.");
380
+ setError("Could not place the order. Check your details.");
332
381
  }
333
382
  }
334
383
 
335
384
  return (
336
- <div className="fixed inset-0 z-50 flex items-end justify-center bg-zinc-900/40 p-4 sm:items-center">
337
- <div className="w-full max-w-md rounded-2xl bg-white p-6 shadow-2xl">
338
- {placed && entries.length === 0 ? (
339
- <div className="text-center">
340
- <div className="mx-auto flex size-10 items-center justify-center rounded-full bg-brand text-white">✓</div>
341
- <p className="mt-3 text-[15px] font-semibold text-zinc-900">
342
- {siteConfig.checkout.confirmationMessage}
343
- </p>
344
- {soldOutNote.length > 0 ? (
345
- <p className="mt-2 text-[13px] text-amber-600">
346
- {soldOutNote.join(", ")} sold out before we could reserve {soldOutNote.length === 1 ? "it" : "them"}.
347
- </p>
348
- ) : null}
385
+ <div className="fixed inset-0 z-50 flex justify-end">
386
+ <button
387
+ type="button"
388
+ aria-label="Close cart"
389
+ onClick={onClose}
390
+ className="animate-in fade-in absolute inset-0 bg-espresso/35 duration-500"
391
+ />
392
+ <aside
393
+ role="dialog"
394
+ aria-modal="true"
395
+ aria-label="Cart"
396
+ className="animate-in slide-in-from-right relative flex h-full w-full max-w-[460px] flex-col bg-warm-white shadow-[-40px_0_80px_-40px_rgba(34,29,24,0.35)] duration-500 ease-out-quint"
397
+ >
398
+ <div className="flex h-20 shrink-0 items-center justify-between px-6 sm:px-8">
399
+ <h2 className="font-display text-[1.75rem] font-light tracking-[-0.01em] text-ink">
400
+ Cart <span className="text-ink/40 tabular-nums">{count}</span>
401
+ </h2>
402
+ <button
403
+ type="button"
404
+ onClick={onClose}
405
+ aria-label="Close"
406
+ className="flex size-10 items-center justify-center rounded-full text-ink/70 transition-colors hover:bg-ink/[0.06] hover:text-ink"
407
+ >
408
+ <X className="size-5" strokeWidth={1.25} />
409
+ </button>
410
+ </div>
411
+
412
+ {entries.length === 0 ? (
413
+ <div className="flex flex-1 flex-col items-center justify-center px-8 text-center">
414
+ <p className="font-display text-[1.5rem] font-light text-ink">Your cart is empty.</p>
349
415
  <button
350
416
  type="button"
351
417
  onClick={onClose}
352
- className="mt-5 inline-flex h-10 items-center justify-center rounded-lg border border-zinc-300 px-5 text-sm font-medium text-zinc-700 hover:bg-zinc-50"
418
+ className="mt-6 inline-flex h-11 items-center rounded-full border border-ink/25 px-6 text-[14px] font-medium text-ink transition-colors hover:border-ink"
353
419
  >
354
- Keep shopping
420
+ Back to the shelf
355
421
  </button>
356
422
  </div>
357
423
  ) : (
358
424
  <>
359
- <div className="flex items-center justify-between">
360
- <h2 className="text-base font-semibold text-zinc-900">Your cart</h2>
361
- <button type="button" onClick={onClose} aria-label="Close" className="text-zinc-400 hover:text-zinc-700">
362
- ✕
363
- </button>
425
+ <div className="px-6 pb-5 sm:px-8">
426
+ <p className="text-[13px] text-ink-2">
427
+ {remaining > 0 ? (
428
+ <>
429
+ Add <span className="font-medium tabular-nums text-ink">{formatPrice(remaining)}</span> for free
430
+ shipping.
431
+ </>
432
+ ) : (
433
+ "Your order ships free."
434
+ )}
435
+ </p>
436
+ <div className="mt-2.5 h-[2px] overflow-hidden rounded-full bg-ink/10">
437
+ <div
438
+ className="h-full rounded-full bg-ink transition-[width] duration-700 ease-out-quint"
439
+ style={{ width: `${Math.min(100, (subtotal / terms.freeOverCents) * 100)}%` }}
440
+ />
441
+ </div>
364
442
  </div>
365
443
 
366
- <ul className="mt-4 space-y-3">
444
+ <ul className="flex-1 divide-y divide-ink/10 overflow-y-auto border-t border-ink/10 px-6 sm:px-8">
367
445
  {entries.map(({ product, qty }) => (
368
- <li key={product.slug} className="flex items-center gap-3">
369
- <span className="grid size-10 shrink-0 place-items-center rounded-lg bg-paper text-2xl">{product.image}</span>
370
- <div className="min-w-0 flex-1">
371
- <div className="truncate text-[14px] font-medium text-zinc-900">{product.name}</div>
372
- <div className="text-[12.5px] text-zinc-500">{formatPrice(product.priceCents)} each</div>
446
+ <li key={product.slug} className="flex gap-4 py-5">
447
+ <div className="h-[100px] w-20 shrink-0 overflow-hidden bg-stone">
448
+ <ProductPhoto image={product.image} name="" className="size-full object-cover" />
373
449
  </div>
374
- <div className="flex items-center rounded-md border border-zinc-200">
375
- <button type="button" onClick={() => onSetQty(product.slug, qty - 1)} className="px-2 py-1 text-zinc-500 hover:text-zinc-900">−</button>
376
- <span className="min-w-5 text-center text-[13px] font-medium tabular-nums">{qty}</span>
377
- <button type="button" onClick={() => onSetQty(product.slug, qty + 1)} disabled={qty >= product.stock} className="px-2 py-1 text-zinc-500 hover:text-zinc-900 disabled:opacity-30">+</button>
450
+ <div className="flex min-w-0 flex-1 flex-col">
451
+ <div className="flex items-baseline justify-between gap-3">
452
+ <span className="font-display truncate text-[1.1875rem] text-ink">{product.name}</span>
453
+ <span className="shrink-0 text-[14px] font-medium tabular-nums text-ink">
454
+ {formatPrice(product.priceCents * qty)}
455
+ </span>
456
+ </div>
457
+ <span className="mt-0.5 text-[12.5px] text-ink-2">
458
+ {formatPrice(product.priceCents)} each
459
+ </span>
460
+ <div className="mt-auto flex items-center justify-between pt-3">
461
+ <Stepper
462
+ small
463
+ qty={qty}
464
+ max={product.stock}
465
+ onDec={() => onSetQty(product.slug, qty - 1)}
466
+ onInc={() => onSetQty(product.slug, qty + 1)}
467
+ label={product.name}
468
+ />
469
+ <button
470
+ type="button"
471
+ onClick={() => onSetQty(product.slug, 0)}
472
+ className="text-[12.5px] text-ink-2 underline decoration-ink/20 underline-offset-4 transition-colors hover:text-ink hover:decoration-ink"
473
+ >
474
+ Remove
475
+ </button>
476
+ </div>
378
477
  </div>
379
- <span className="w-16 shrink-0 text-right text-[14px] font-medium tabular-nums">
380
- {formatPrice(product.priceCents * qty)}
381
- </span>
382
478
  </li>
383
479
  ))}
384
480
  </ul>
385
481
 
386
- {entries.length === 0 ? (
387
- <p className="mt-4 text-center text-sm text-zinc-500">Your cart is empty.</p>
388
- ) : (
389
- <form onSubmit={submitCheckout} className="mt-5 border-t border-zinc-100 pt-4">
390
- <div className="flex items-center justify-between text-[15px] font-semibold text-zinc-900">
391
- <span>Total</span>
392
- <span className="tabular-nums">{formatPrice(total)}</span>
482
+ <form onSubmit={submitCheckout} className="shrink-0 border-t border-ink/10 bg-stone/40 px-6 py-6 sm:px-8">
483
+ <dl className="space-y-1.5 text-[14px]">
484
+ <div className="flex justify-between text-ink-2">
485
+ <dt>Subtotal</dt>
486
+ <dd className="tabular-nums">{formatPrice(subtotal)}</dd>
487
+ </div>
488
+ <div className="flex justify-between text-ink-2">
489
+ <dt>Shipping</dt>
490
+ <dd className="tabular-nums">{shipping === 0 ? "Free" : formatPrice(shipping)}</dd>
393
491
  </div>
394
- <div className="mt-4 grid gap-2.5">
395
- <input value={name} onChange={(e) => setName(e.target.value)} placeholder="Your name" aria-label="Your name" autoComplete="name" className={inputCls} />
396
- <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="you@email.com" aria-label="Email" autoComplete="email" className={inputCls} />
492
+ <div className="flex justify-between pt-1.5 text-[16px] font-medium text-ink">
493
+ <dt>Total</dt>
494
+ <dd className="tabular-nums">{formatPrice(subtotal + shipping)}</dd>
397
495
  </div>
398
- {error ? <p className="mt-2 text-[13px] text-red-600">{error}</p> : null}
399
- <button
400
- type="submit"
401
- disabled={status === "placing"}
402
- className="mt-4 inline-flex h-11 w-full items-center justify-center rounded-lg bg-brand text-sm font-medium text-white transition-opacity hover:opacity-90 disabled:opacity-60"
403
- >
404
- {status === "placing" ? "Taking you to checkout…" : `Checkout · ${formatPrice(total)}`}
405
- </button>
406
- <p className="mt-2 text-center text-[12px] text-zinc-400">
407
- Secure checkout. You&apos;ll confirm payment on the next step.
408
- </p>
409
- </form>
410
- )}
496
+ </dl>
497
+ <div className="mt-5 grid gap-2.5 sm:grid-cols-2">
498
+ <label className="block">
499
+ <span className="sr-only">Name</span>
500
+ <input
501
+ value={name}
502
+ onChange={(e) => setName(e.target.value)}
503
+ placeholder="Name"
504
+ autoComplete="name"
505
+ className={inputCls}
506
+ />
507
+ </label>
508
+ <label className="block">
509
+ <span className="sr-only">Email</span>
510
+ <input
511
+ type="email"
512
+ value={email}
513
+ onChange={(e) => setEmail(e.target.value)}
514
+ placeholder="Email"
515
+ autoComplete="email"
516
+ className={inputCls}
517
+ />
518
+ </label>
519
+ </div>
520
+ {error ? <p className="mt-3 text-[13px] text-[#a1301f]">{error}</p> : null}
521
+ <button
522
+ type="submit"
523
+ disabled={status === "placing"}
524
+ className="mt-4 inline-flex h-12 w-full items-center justify-center rounded-full bg-ink text-[14.5px] font-medium text-warm-white transition-[background-color,transform] duration-300 ease-out-quint hover:bg-brand active:scale-[0.99] disabled:opacity-60"
525
+ >
526
+ {status === "placing" ? "Placing order…" : `Check out · ${formatPrice(subtotal + shipping)}`}
527
+ </button>
528
+ </form>
411
529
  </>
412
530
  )}
413
- </div>
531
+ </aside>
414
532
  </div>
415
533
  );
416
534
  }
417
535
 
418
536
  const inputCls =
419
- "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";
537
+ "h-11 w-full rounded-full border border-ink/15 bg-warm-white px-4 text-[14px] text-ink outline-none transition-[border-color,box-shadow] duration-300 placeholder:text-ink-2/70 focus:border-ink/50 focus:ring-4 focus:ring-ink/[0.06]";
420
538
 
421
539
  function GridSkeleton() {
422
540
  return (
423
- <div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
424
- {Array.from({ length: 6 }).map((_, i) => (
425
- <div key={i} className="overflow-hidden rounded-2xl border border-zinc-200 bg-white">
426
- <div className="aspect-[4/3] animate-pulse bg-zinc-100" />
427
- <div className="space-y-2 p-5">
428
- <div className="h-4 w-2/3 animate-pulse rounded bg-zinc-100" />
429
- <div className="h-3 w-full animate-pulse rounded bg-zinc-100" />
430
- <div className="h-10 w-full animate-pulse rounded-lg bg-zinc-100" />
541
+ <div className={GRID}>
542
+ {Array.from({ length: 5 }).map((_, i) => (
543
+ <div key={i} className={"flex flex-col " + (i === 0 ? FEATURED_CELL : "")}>
544
+ <div
545
+ className={
546
+ "animate-pulse bg-stone " +
547
+ (i === 0 ? "aspect-[4/5] sm:aspect-[5/4] lg:aspect-auto lg:min-h-[480px] lg:flex-1" : "aspect-[4/5]")
548
+ }
549
+ />
550
+ <div className="mt-5 flex justify-between">
551
+ <div className="h-6 w-1/2 animate-pulse rounded bg-ink/[0.06]" />
552
+ <div className="h-5 w-12 animate-pulse rounded bg-ink/[0.06]" />
553
+ </div>
554
+ <div className="mt-3 h-4 w-3/4 animate-pulse rounded bg-ink/[0.06]" />
555
+ <div className="mt-6 flex justify-between">
556
+ <div className="h-4 w-20 animate-pulse rounded bg-ink/[0.06]" />
557
+ <div className="h-10 w-32 animate-pulse rounded-full bg-ink/[0.06]" />
431
558
  </div>
432
559
  </div>
433
560
  ))}