makersclaw 0.2.0 β 0.3.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.
- package/THIRD-PARTY-NOTICES.md +180 -0
- package/package.json +3 -2
- package/r/annotate.json +62 -0
- package/r/approval-request.json +52 -0
- package/r/audio-player.json +74 -0
- package/r/auth-form.json +55 -0
- package/r/board.json +10 -6
- package/r/calendar-view.json +105 -0
- package/r/cart.json +62 -0
- package/r/chart.json +83 -0
- package/r/chat.json +95 -0
- package/r/code-editor.json +94 -0
- package/r/comment-box.json +64 -0
- package/r/contact-section.json +53 -0
- package/r/core.json +205 -8
- package/r/csv-import.json +63 -0
- package/r/data-table.json +69 -0
- package/r/diagram.json +74 -0
- package/r/doc-editor.json +82 -0
- package/r/doc-page.json +61 -0
- package/r/email-render.json +73 -0
- package/r/faq.json +50 -0
- package/r/file-upload.json +54 -0
- package/r/flow-canvas.json +93 -0
- package/r/footer.json +52 -0
- package/r/gantt.json +70 -0
- package/r/job-panel.json +12 -7
- package/r/json-editor.json +66 -0
- package/r/map.json +89 -0
- package/r/menubar.json +52 -0
- package/r/mind-map.json +67 -0
- package/r/navigation-menu.json +52 -0
- package/r/order-detail.json +54 -0
- package/r/org-chart.json +66 -0
- package/r/page-builder.json +105 -0
- package/r/payment-methods.json +51 -0
- package/r/pdf-document.json +79 -0
- package/r/pdf-viewer.json +88 -0
- package/r/product-detail.json +76 -0
- package/r/product-grid.json +65 -0
- package/r/record-form.json +67 -0
- package/r/record-notes.json +55 -0
- package/r/registry.json +3411 -29
- package/r/search-dialog.json +51 -0
- package/r/settings-list.json +66 -0
- package/r/setup-checklist.json +51 -0
- package/r/share-dialog.json +52 -0
- package/r/sidebar.json +69 -0
- package/r/signature-pad.json +72 -0
- package/r/sketch-canvas.json +99 -0
- package/r/spreadsheet.json +90 -0
- package/r/stat-cards.json +66 -0
- package/r/task-list.json +63 -0
- package/r/task-model.json +98 -0
- package/r/testimonials.json +52 -0
- package/r/transaction-list.json +53 -0
- package/r/whiteboard.json +66 -0
- package/registry/blocks/annotate/README.md +179 -0
- package/registry/blocks/annotate/annotate-data.js +250 -0
- package/registry/blocks/annotate/annotate.jsx +646 -0
- package/registry/blocks/annotate/block.json +10 -0
- package/registry/blocks/annotate/demo.jsx +135 -0
- package/registry/blocks/annotate/index.js +15 -0
- package/registry/blocks/approval-request/README.md +116 -0
- package/registry/blocks/approval-request/approval-request.jsx +386 -0
- package/registry/blocks/approval-request/block.json +10 -0
- package/registry/blocks/approval-request/demo.jsx +127 -0
- package/registry/blocks/approval-request/index.js +4 -0
- package/registry/blocks/audio-player/README.md +167 -0
- package/registry/blocks/audio-player/audio-player.jsx +623 -0
- package/registry/blocks/audio-player/block.json +14 -0
- package/registry/blocks/audio-player/colors.js +109 -0
- package/registry/blocks/audio-player/demo.jsx +209 -0
- package/registry/blocks/audio-player/index.js +6 -0
- package/registry/blocks/audio-player/time.js +67 -0
- package/registry/blocks/audio-player/wavesurfer.js +9 -0
- package/registry/blocks/auth-form/README.md +126 -0
- package/registry/blocks/auth-form/auth-form.jsx +568 -0
- package/registry/blocks/auth-form/block.json +10 -0
- package/registry/blocks/auth-form/demo.jsx +131 -0
- package/registry/blocks/auth-form/index.js +3 -0
- package/registry/blocks/board/README.md +55 -5
- package/registry/blocks/board/block.json +2 -2
- package/registry/blocks/board/board.jsx +230 -86
- package/registry/blocks/board/demo.jsx +103 -24
- package/registry/blocks/calendar-view/README.md +180 -0
- package/registry/blocks/calendar-view/agenda.jsx +147 -0
- package/registry/blocks/calendar-view/block.json +76 -0
- package/registry/blocks/calendar-view/calendar-model.js +442 -0
- package/registry/blocks/calendar-view/calendar.jsx +627 -0
- package/registry/blocks/calendar-view/demo.jsx +195 -0
- package/registry/blocks/calendar-view/draft.jsx +49 -0
- package/registry/blocks/calendar-view/index.js +39 -0
- package/registry/blocks/calendar-view/month.jsx +346 -0
- package/registry/blocks/calendar-view/paint.js +69 -0
- package/registry/blocks/calendar-view/time-grid.jsx +527 -0
- package/registry/blocks/calendar-view/tray.jsx +87 -0
- package/registry/blocks/cart/README.md +146 -0
- package/registry/blocks/cart/block.json +10 -0
- package/registry/blocks/cart/cart.jsx +597 -0
- package/registry/blocks/cart/demo.jsx +98 -0
- package/registry/blocks/cart/index.js +4 -0
- package/registry/blocks/cart/money.js +28 -0
- package/registry/blocks/chart/README.md +316 -0
- package/registry/blocks/chart/block.json +10 -0
- package/registry/blocks/chart/cartesian.jsx +642 -0
- package/registry/blocks/chart/core.jsx +262 -0
- package/registry/blocks/chart/demo.jsx +357 -0
- package/registry/blocks/chart/heatmap.jsx +336 -0
- package/registry/blocks/chart/index.js +8 -0
- package/registry/blocks/chart/pie.jsx +245 -0
- package/registry/blocks/chart/sparkline.jsx +88 -0
- package/registry/blocks/chat/README.md +273 -0
- package/registry/blocks/chat/attachment.jsx +95 -0
- package/registry/blocks/chat/block.json +10 -0
- package/registry/blocks/chat/chat.jsx +132 -0
- package/registry/blocks/chat/composer.jsx +364 -0
- package/registry/blocks/chat/demo.jsx +578 -0
- package/registry/blocks/chat/header.jsx +34 -0
- package/registry/blocks/chat/index.js +9 -0
- package/registry/blocks/chat/message.jsx +450 -0
- package/registry/blocks/chat/scroller.jsx +247 -0
- package/registry/blocks/chat/text.jsx +104 -0
- package/registry/blocks/code-editor/README.md +210 -0
- package/registry/blocks/code-editor/block.json +22 -0
- package/registry/blocks/code-editor/code-editor.jsx +534 -0
- package/registry/blocks/code-editor/codemirror.js +38 -0
- package/registry/blocks/code-editor/demo.jsx +205 -0
- package/registry/blocks/code-editor/index.js +8 -0
- package/registry/blocks/code-editor/json.js +317 -0
- package/registry/blocks/code-editor/sql.js +136 -0
- package/registry/blocks/code-editor/text.js +101 -0
- package/registry/blocks/code-editor/theme.js +186 -0
- package/registry/blocks/comment-box/README.md +123 -0
- package/registry/blocks/comment-box/block.json +10 -0
- package/registry/blocks/comment-box/comment-body.jsx +217 -0
- package/registry/blocks/comment-box/comment-box.jsx +198 -0
- package/registry/blocks/comment-box/comment-time.jsx +77 -0
- package/registry/blocks/comment-box/demo.jsx +141 -0
- package/registry/blocks/comment-box/index.js +8 -0
- package/registry/blocks/contact-section/README.md +152 -0
- package/registry/blocks/contact-section/block.json +11 -0
- package/registry/blocks/contact-section/contact-section.jsx +509 -0
- package/registry/blocks/contact-section/demo.jsx +93 -0
- package/registry/blocks/contact-section/index.js +4 -0
- package/registry/blocks/csv-import/README.md +133 -0
- package/registry/blocks/csv-import/block.json +13 -0
- package/registry/blocks/csv-import/csv-import.jsx +479 -0
- package/registry/blocks/csv-import/demo.jsx +106 -0
- package/registry/blocks/csv-import/import.js +200 -0
- package/registry/blocks/csv-import/index.js +7 -0
- package/registry/blocks/data-table/README.md +386 -0
- package/registry/blocks/data-table/block.json +10 -0
- package/registry/blocks/data-table/data-table.jsx +1164 -0
- package/registry/blocks/data-table/demo.jsx +588 -0
- package/registry/blocks/data-table/index.js +5 -0
- package/registry/blocks/data-table/model.js +169 -0
- package/registry/blocks/diagram/README.md +108 -0
- package/registry/blocks/diagram/block.json +13 -0
- package/registry/blocks/diagram/demo.jsx +113 -0
- package/registry/blocks/diagram/diagram.jsx +481 -0
- package/registry/blocks/diagram/index.js +7 -0
- package/registry/blocks/diagram/mermaid.js +52 -0
- package/registry/blocks/diagram/palette.js +313 -0
- package/registry/blocks/diagram/source.js +98 -0
- package/registry/blocks/doc-editor/README.md +215 -0
- package/registry/blocks/doc-editor/block.json +15 -0
- package/registry/blocks/doc-editor/demo.jsx +225 -0
- package/registry/blocks/doc-editor/doc-editor.jsx +291 -0
- package/registry/blocks/doc-editor/doc.js +309 -0
- package/registry/blocks/doc-editor/index.js +7 -0
- package/registry/blocks/doc-editor/schema.jsx +56 -0
- package/registry/blocks/doc-editor/shadcn-classes.js +110 -0
- package/registry/blocks/doc-editor/theme.js +82 -0
- package/registry/blocks/doc-page/README.md +119 -0
- package/registry/blocks/doc-page/autosave.js +104 -0
- package/registry/blocks/doc-page/block.json +10 -0
- package/registry/blocks/doc-page/demo.jsx +137 -0
- package/registry/blocks/doc-page/doc-page.jsx +276 -0
- package/registry/blocks/doc-page/index.js +4 -0
- package/registry/blocks/email-render/README.md +151 -0
- package/registry/blocks/email-render/block.json +14 -0
- package/registry/blocks/email-render/demo.jsx +153 -0
- package/registry/blocks/email-render/doc-email.jsx +210 -0
- package/registry/blocks/email-render/email.jsx +221 -0
- package/registry/blocks/email-render/index.js +10 -0
- package/registry/blocks/email-render/render.js +50 -0
- package/registry/blocks/email-render/theme.js +215 -0
- package/registry/blocks/faq/README.md +101 -0
- package/registry/blocks/faq/block.json +11 -0
- package/registry/blocks/faq/demo.jsx +89 -0
- package/registry/blocks/faq/faq.jsx +220 -0
- package/registry/blocks/faq/index.js +4 -0
- package/registry/blocks/file-upload/README.md +138 -0
- package/registry/blocks/file-upload/block.json +10 -0
- package/registry/blocks/file-upload/demo.jsx +204 -0
- package/registry/blocks/file-upload/file-upload.jsx +635 -0
- package/registry/blocks/file-upload/index.js +4 -0
- package/registry/blocks/flow-canvas/README.md +420 -0
- package/registry/blocks/flow-canvas/block.json +13 -0
- package/registry/blocks/flow-canvas/board.js +819 -0
- package/registry/blocks/flow-canvas/chrome.jsx +649 -0
- package/registry/blocks/flow-canvas/context.jsx +35 -0
- package/registry/blocks/flow-canvas/demo.jsx +249 -0
- package/registry/blocks/flow-canvas/edges.jsx +107 -0
- package/registry/blocks/flow-canvas/flow-canvas.jsx +1329 -0
- package/registry/blocks/flow-canvas/index.js +41 -0
- package/registry/blocks/flow-canvas/nodes.jsx +392 -0
- package/registry/blocks/flow-canvas/theme.js +174 -0
- package/registry/blocks/footer/README.md +133 -0
- package/registry/blocks/footer/block.json +11 -0
- package/registry/blocks/footer/demo.jsx +119 -0
- package/registry/blocks/footer/footer.jsx +406 -0
- package/registry/blocks/footer/index.js +4 -0
- package/registry/blocks/gantt/README.md +220 -0
- package/registry/blocks/gantt/block.json +10 -0
- package/registry/blocks/gantt/demo.jsx +148 -0
- package/registry/blocks/gantt/gantt.jsx +923 -0
- package/registry/blocks/gantt/index.js +7 -0
- package/registry/blocks/gantt/rows.jsx +468 -0
- package/registry/blocks/gantt/schedule.js +278 -0
- package/registry/blocks/job-panel/README.md +80 -0
- package/registry/blocks/job-panel/block.json +3 -3
- package/registry/blocks/job-panel/demo.jsx +230 -3
- package/registry/blocks/job-panel/job-panel.jsx +169 -3
- package/registry/blocks/json-editor/README.md +133 -0
- package/registry/blocks/json-editor/block.json +10 -0
- package/registry/blocks/json-editor/demo.jsx +92 -0
- package/registry/blocks/json-editor/index.js +7 -0
- package/registry/blocks/json-editor/json-editor.jsx +305 -0
- package/registry/blocks/json-editor/json-tree.jsx +393 -0
- package/registry/blocks/json-editor/tree.js +225 -0
- package/registry/blocks/map/README.md +221 -0
- package/registry/blocks/map/block.json +14 -0
- package/registry/blocks/map/demo.jsx +201 -0
- package/registry/blocks/map/geo.js +198 -0
- package/registry/blocks/map/index.js +8 -0
- package/registry/blocks/map/list.jsx +128 -0
- package/registry/blocks/map/map-view.jsx +715 -0
- package/registry/blocks/map/maplibre.js +30 -0
- package/registry/blocks/map/marks.jsx +146 -0
- package/registry/blocks/map/theme.js +142 -0
- package/registry/blocks/menubar/README.md +109 -0
- package/registry/blocks/menubar/block.json +10 -0
- package/registry/blocks/menubar/demo.jsx +128 -0
- package/registry/blocks/menubar/index.js +4 -0
- package/registry/blocks/menubar/menubar.jsx +199 -0
- package/registry/blocks/mind-map/README.md +206 -0
- package/registry/blocks/mind-map/block.json +10 -0
- package/registry/blocks/mind-map/demo.jsx +103 -0
- package/registry/blocks/mind-map/index.js +31 -0
- package/registry/blocks/mind-map/mind-map.jsx +1125 -0
- package/registry/blocks/mind-map/topic.jsx +143 -0
- package/registry/blocks/mind-map/tree.js +645 -0
- package/registry/blocks/navigation-menu/README.md +124 -0
- package/registry/blocks/navigation-menu/block.json +10 -0
- package/registry/blocks/navigation-menu/demo.jsx +84 -0
- package/registry/blocks/navigation-menu/index.js +4 -0
- package/registry/blocks/navigation-menu/navigation-menu.jsx +448 -0
- package/registry/blocks/order-detail/README.md +109 -0
- package/registry/blocks/order-detail/block.json +10 -0
- package/registry/blocks/order-detail/demo.jsx +88 -0
- package/registry/blocks/order-detail/index.js +4 -0
- package/registry/blocks/order-detail/order-detail.jsx +354 -0
- package/registry/blocks/org-chart/README.md +145 -0
- package/registry/blocks/org-chart/block.json +10 -0
- package/registry/blocks/org-chart/demo.jsx +94 -0
- package/registry/blocks/org-chart/index.js +7 -0
- package/registry/blocks/org-chart/org-chart.jsx +631 -0
- package/registry/blocks/org-chart/org.js +245 -0
- package/registry/blocks/org-chart/person.jsx +96 -0
- package/registry/blocks/page-builder/README.md +257 -0
- package/registry/blocks/page-builder/block.json +14 -0
- package/registry/blocks/page-builder/demo.jsx +145 -0
- package/registry/blocks/page-builder/grapes.js +18 -0
- package/registry/blocks/page-builder/index.js +11 -0
- package/registry/blocks/page-builder/page-builder.jsx +704 -0
- package/registry/blocks/page-builder/page-view.jsx +33 -0
- package/registry/blocks/page-builder/page.js +118 -0
- package/registry/blocks/page-builder/panels.jsx +588 -0
- package/registry/blocks/page-builder/sections.js +209 -0
- package/registry/blocks/page-builder/theme.js +87 -0
- package/registry/blocks/page-builder/thumbs.jsx +189 -0
- package/registry/blocks/page-builder/tokens.js +329 -0
- package/registry/blocks/payment-methods/README.md +104 -0
- package/registry/blocks/payment-methods/block.json +10 -0
- package/registry/blocks/payment-methods/demo.jsx +71 -0
- package/registry/blocks/payment-methods/index.js +4 -0
- package/registry/blocks/payment-methods/payment-methods.jsx +302 -0
- package/registry/blocks/pdf-document/README.md +213 -0
- package/registry/blocks/pdf-document/block.json +13 -0
- package/registry/blocks/pdf-document/demo.jsx +136 -0
- package/registry/blocks/pdf-document/index.js +33 -0
- package/registry/blocks/pdf-document/money.js +83 -0
- package/registry/blocks/pdf-document/pdf-document.jsx +144 -0
- package/registry/blocks/pdf-document/primitives.jsx +271 -0
- package/registry/blocks/pdf-document/quote.jsx +179 -0
- package/registry/blocks/pdf-document/theme.js +193 -0
- package/registry/blocks/pdf-viewer/README.md +191 -0
- package/registry/blocks/pdf-viewer/block.json +14 -0
- package/registry/blocks/pdf-viewer/demo.jsx +252 -0
- package/registry/blocks/pdf-viewer/helpers.js +270 -0
- package/registry/blocks/pdf-viewer/index.js +8 -0
- package/registry/blocks/pdf-viewer/notes.jsx +195 -0
- package/registry/blocks/pdf-viewer/pdf-viewer.jsx +1368 -0
- package/registry/blocks/pdf-viewer/pdfjs.js +37 -0
- package/registry/blocks/pdf-viewer/ssr-shim-end.js +6 -0
- package/registry/blocks/pdf-viewer/ssr-shim.js +44 -0
- package/registry/blocks/product-detail/README.md +217 -0
- package/registry/blocks/product-detail/block.json +10 -0
- package/registry/blocks/product-detail/demo.jsx +233 -0
- package/registry/blocks/product-detail/index.js +7 -0
- package/registry/blocks/product-detail/price.jsx +115 -0
- package/registry/blocks/product-detail/product-detail.jsx +348 -0
- package/registry/blocks/product-detail/product-media.jsx +102 -0
- package/registry/blocks/product-detail/product-options.jsx +144 -0
- package/registry/blocks/product-grid/README.md +162 -0
- package/registry/blocks/product-grid/block.json +10 -0
- package/registry/blocks/product-grid/demo.jsx +255 -0
- package/registry/blocks/product-grid/index.js +5 -0
- package/registry/blocks/product-grid/product-card.jsx +232 -0
- package/registry/blocks/product-grid/product-grid.jsx +235 -0
- package/registry/blocks/record-form/README.md +196 -0
- package/registry/blocks/record-form/block.json +10 -0
- package/registry/blocks/record-form/demo.jsx +525 -0
- package/registry/blocks/record-form/index.js +4 -0
- package/registry/blocks/record-form/record-fields.jsx +457 -0
- package/registry/blocks/record-form/record-form.jsx +415 -0
- package/registry/blocks/record-form/rules.js +81 -0
- package/registry/blocks/record-notes/README.md +139 -0
- package/registry/blocks/record-notes/block.json +10 -0
- package/registry/blocks/record-notes/demo.jsx +179 -0
- package/registry/blocks/record-notes/index.js +4 -0
- package/registry/blocks/record-notes/record-notes.jsx +266 -0
- package/registry/blocks/search-dialog/README.md +108 -0
- package/registry/blocks/search-dialog/block.json +10 -0
- package/registry/blocks/search-dialog/demo.jsx +116 -0
- package/registry/blocks/search-dialog/index.js +3 -0
- package/registry/blocks/search-dialog/search-dialog.jsx +235 -0
- package/registry/blocks/settings-list/README.md +171 -0
- package/registry/blocks/settings-list/block.json +10 -0
- package/registry/blocks/settings-list/demo.jsx +333 -0
- package/registry/blocks/settings-list/index.js +4 -0
- package/registry/blocks/settings-list/row-state.jsx +93 -0
- package/registry/blocks/settings-list/settings-list.jsx +349 -0
- package/registry/blocks/settings-list/settings-matrix.jsx +118 -0
- package/registry/blocks/setup-checklist/README.md +142 -0
- package/registry/blocks/setup-checklist/block.json +10 -0
- package/registry/blocks/setup-checklist/demo.jsx +159 -0
- package/registry/blocks/setup-checklist/index.js +4 -0
- package/registry/blocks/setup-checklist/setup-checklist.jsx +446 -0
- package/registry/blocks/share-dialog/README.md +113 -0
- package/registry/blocks/share-dialog/block.json +10 -0
- package/registry/blocks/share-dialog/demo.jsx +140 -0
- package/registry/blocks/share-dialog/index.js +3 -0
- package/registry/blocks/share-dialog/share-dialog.jsx +384 -0
- package/registry/blocks/sidebar/README.md +190 -0
- package/registry/blocks/sidebar/block.json +43 -0
- package/registry/blocks/sidebar/demo.jsx +577 -0
- package/registry/blocks/sidebar/index.js +4 -0
- package/registry/blocks/sidebar/sidebar-panel.jsx +149 -0
- package/registry/blocks/sidebar/sidebar.jsx +864 -0
- package/registry/blocks/signature-pad/README.md +137 -0
- package/registry/blocks/signature-pad/block.json +13 -0
- package/registry/blocks/signature-pad/colors.js +115 -0
- package/registry/blocks/signature-pad/demo.jsx +94 -0
- package/registry/blocks/signature-pad/index.js +6 -0
- package/registry/blocks/signature-pad/pad.js +7 -0
- package/registry/blocks/signature-pad/signature-pad.jsx +434 -0
- package/registry/blocks/signature-pad/signature.js +68 -0
- package/registry/blocks/sketch-canvas/README.md +280 -0
- package/registry/blocks/sketch-canvas/block.json +13 -0
- package/registry/blocks/sketch-canvas/demo.jsx +159 -0
- package/registry/blocks/sketch-canvas/excalidraw.js +40 -0
- package/registry/blocks/sketch-canvas/index.js +9 -0
- package/registry/blocks/sketch-canvas/palette.js +226 -0
- package/registry/blocks/sketch-canvas/sketch-canvas.jsx +1188 -0
- package/registry/blocks/sketch-canvas/sketch.js +179 -0
- package/registry/blocks/sketch-canvas/ssr-shim-end.js +6 -0
- package/registry/blocks/sketch-canvas/ssr-shim.js +45 -0
- package/registry/blocks/sketch-canvas/stamps.js +104 -0
- package/registry/blocks/sketch-canvas/theme.js +131 -0
- package/registry/blocks/spreadsheet/README.md +307 -0
- package/registry/blocks/spreadsheet/block.json +13 -0
- package/registry/blocks/spreadsheet/cells.jsx +455 -0
- package/registry/blocks/spreadsheet/chrome.jsx +325 -0
- package/registry/blocks/spreadsheet/demo.jsx +177 -0
- package/registry/blocks/spreadsheet/grid.js +387 -0
- package/registry/blocks/spreadsheet/index.js +34 -0
- package/registry/blocks/spreadsheet/spreadsheet.jsx +1732 -0
- package/registry/blocks/spreadsheet/types.js +473 -0
- package/registry/blocks/stat-cards/README.md +173 -0
- package/registry/blocks/stat-cards/block.json +10 -0
- package/registry/blocks/stat-cards/demo.jsx +257 -0
- package/registry/blocks/stat-cards/index.js +4 -0
- package/registry/blocks/stat-cards/stat-cards.jsx +307 -0
- package/registry/blocks/stat-cards/usage.jsx +243 -0
- package/registry/blocks/task-list/README.md +176 -0
- package/registry/blocks/task-list/block.json +10 -0
- package/registry/blocks/task-list/demo.jsx +134 -0
- package/registry/blocks/task-list/index.js +5 -0
- package/registry/blocks/task-list/task-list.jsx +907 -0
- package/registry/blocks/task-model/README.md +455 -0
- package/registry/blocks/task-model/block.json +167 -0
- package/registry/blocks/task-model/changes.js +89 -0
- package/registry/blocks/task-model/dates.js +371 -0
- package/registry/blocks/task-model/demo.jsx +215 -0
- package/registry/blocks/task-model/dnd.jsx +240 -0
- package/registry/blocks/task-model/index.js +97 -0
- package/registry/blocks/task-model/model.js +919 -0
- package/registry/blocks/task-model/parts.jsx +449 -0
- package/registry/blocks/task-model/toolbar.jsx +271 -0
- package/registry/blocks/testimonials/README.md +139 -0
- package/registry/blocks/testimonials/block.json +11 -0
- package/registry/blocks/testimonials/demo.jsx +137 -0
- package/registry/blocks/testimonials/index.js +4 -0
- package/registry/blocks/testimonials/testimonials.jsx +430 -0
- package/registry/blocks/transaction-list/README.md +126 -0
- package/registry/blocks/transaction-list/block.json +10 -0
- package/registry/blocks/transaction-list/demo.jsx +79 -0
- package/registry/blocks/transaction-list/index.js +4 -0
- package/registry/blocks/transaction-list/transaction-list.jsx +383 -0
- package/registry/blocks/whiteboard/README.md +179 -0
- package/registry/blocks/whiteboard/block.json +10 -0
- package/registry/blocks/whiteboard/demo.jsx +169 -0
- package/registry/blocks/whiteboard/index.js +6 -0
- package/registry/blocks/whiteboard/templates.js +150 -0
- package/registry/blocks/whiteboard/whiteboard.jsx +721 -0
- package/src/install.mjs +18 -0
- package/src/shadcn.mjs +8 -0
- package/templates/next/files/README.md +145 -11
- package/templates/next/files/app/globals.css +55 -4
- package/templates/next/files/check/run.mjs +3 -3
- package/templates/next/files/components/ui/avatar.jsx +141 -0
- package/templates/next/files/components/ui/breadcrumb.jsx +149 -0
- package/templates/next/files/components/ui/button-group.jsx +70 -0
- package/templates/next/files/components/ui/button.jsx +132 -12
- package/templates/next/files/components/ui/calendar.jsx +609 -0
- package/templates/next/files/components/ui/carousel.jsx +281 -0
- package/templates/next/files/components/ui/collapsible.jsx +208 -0
- package/templates/next/files/components/ui/combobox.jsx +608 -0
- package/templates/next/files/components/ui/command.jsx +577 -0
- package/templates/next/files/components/ui/context-menu.jsx +191 -0
- package/templates/next/files/components/ui/date-picker.jsx +360 -0
- package/templates/next/files/components/ui/distribution.jsx +117 -0
- package/templates/next/files/components/ui/feedback.jsx +143 -0
- package/templates/next/files/components/ui/field.jsx +115 -3
- package/templates/next/files/components/ui/file-input.jsx +234 -0
- package/templates/next/files/components/ui/icon.jsx +130 -8
- package/templates/next/files/components/ui/image.jsx +143 -0
- package/templates/next/files/components/ui/index.js +47 -2
- package/templates/next/files/components/ui/input-group.jsx +213 -0
- package/templates/next/files/components/ui/input-otp.jsx +170 -0
- package/templates/next/files/components/ui/item.jsx +206 -0
- package/templates/next/files/components/ui/menu.jsx +454 -82
- package/templates/next/files/components/ui/notice.jsx +126 -14
- package/templates/next/files/components/ui/pagination.jsx +156 -0
- package/templates/next/files/components/ui/popover.jsx +284 -0
- package/templates/next/files/components/ui/radio.jsx +193 -0
- package/templates/next/files/components/ui/rating.jsx +131 -0
- package/templates/next/files/components/ui/resizable.jsx +338 -0
- package/templates/next/files/components/ui/scroll-area.jsx +123 -0
- package/templates/next/files/components/ui/separator.jsx +119 -0
- package/templates/next/files/components/ui/sheet.jsx +96 -11
- package/templates/next/files/components/ui/slider.jsx +174 -0
- package/templates/next/files/components/ui/stat.jsx +574 -0
- package/templates/next/files/components/ui/steps.jsx +338 -0
- package/templates/next/files/components/ui/swatch.jsx +33 -0
- package/templates/next/files/components/ui/table.jsx +130 -0
- package/templates/next/files/components/ui/toast.jsx +440 -0
- package/templates/next/files/components/ui/toggle.jsx +195 -0
- package/templates/next/files/components/ui/tooltip.jsx +270 -0
- package/templates/next/template.json +264 -8
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "product-detail",
|
|
4
|
+
"type": "registry:block",
|
|
5
|
+
"title": "Product detail",
|
|
6
|
+
"description": "One product on its own page: pictures (thumbnails, a side rail, a carousel, a tall stack, or one scene with the buy box floating over it), brand, name, price with the old price struck through, star rating and review count, options (sizes as a segmented row, colours as swatches, long lists as a select, plans as cards with their own price), a quantity control, add to cart through an async callback that may fail, buy now, save to favourites, perks, and details in an accordion or tabs. Loading, error, not-found and sold-out states.",
|
|
7
|
+
"registryDependencies": [
|
|
8
|
+
"https://cdn.jsdelivr.net/npm/makersclaw@0.3.0/r/core.json"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "registry/blocks/product-detail/index.js",
|
|
14
|
+
"type": "registry:component",
|
|
15
|
+
"target": "@components/makersclaw/blocks/product-detail/index.js",
|
|
16
|
+
"content": "// The Product detail block β `makersclaw add product-detail`. Fixed:\n// compose it through its props (README.md beside this file); a product\n// page it cannot be is your own component in components/.\nexport { ProductDetail } from \"./product-detail\";\nexport { ProductMedia } from \"./product-media\";\nexport { OptionPicker, QuantityControl } from \"./product-options\";\nexport { Price, formatPrice, cartLine } from \"./price\";\n"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"path": "registry/blocks/product-detail/product-detail.jsx",
|
|
20
|
+
"type": "registry:component",
|
|
21
|
+
"target": "@components/makersclaw/blocks/product-detail/product-detail.jsx",
|
|
22
|
+
"content": "\"use client\";\n\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport {\n Accordion,\n Badge,\n Breadcrumb,\n Button,\n Empty,\n Icon,\n Image,\n Kicker,\n Notice,\n Rating,\n Skeleton,\n TabPanel,\n Tabs,\n Toggle,\n cx,\n} from \"../../ui\";\nimport { Price, cartLine, defaultSelection, priceFor } from \"./price\";\nimport { OptionPicker, QuantityControl } from \"./product-options\";\nimport { ProductMedia, picturesOf } from \"./product-media\";\n\n// One product: its pictures, name, price, rating, options, quantity, add\n// to cart and save, and the details under them. The add goes out through\n// `onAddToCart(line)`, which may fail; the block shows why.\n\nasync function attempt(fn, ...args) {\n try {\n const r = await fn(...args);\n return r && typeof r === \"object\" ? r : { ok: Boolean(r) };\n } catch (e) {\n return { ok: false, error: e?.message };\n }\n}\n\nfunction Details({ sections, as }) {\n const id = useId();\n const list = (sections ?? []).filter(Boolean).map((s, i) => ({ key: s.key ?? `s${i}`, ...s }));\n const [tab, setTab] = useState(list[0]?.key);\n if (list.length === 0) return null;\n const body = (s) => (typeof s.content === \"string\" ? <p className=\"leading-relaxed text-muted-foreground\">{s.content}</p> : s.content);\n if (as === \"tabs\") {\n const current = list.some((s) => s.key === tab) ? tab : list[0].key;\n return (\n <div className=\"min-w-0\">\n <Tabs id={id} aria-label=\"Product details\" tabs={list.map((s) => ({ key: s.key, label: s.title }))} value={current} onChange={setTab} />\n {list.map((s) => (\n <TabPanel key={s.key} id={id} tab={s.key} value={current} className=\"text-sm\">\n {body(s)}\n </TabPanel>\n ))}\n </div>\n );\n }\n return (\n <Accordion\n aria-label=\"Product details\"\n defaultValue={list[0].key}\n items={list.map((s) => ({ key: s.key, title: s.title, content: body(s) }))}\n />\n );\n}\n\nfunction Perks({ perks }) {\n const list = (perks ?? []).filter(Boolean);\n if (list.length === 0) return null;\n return (\n <ul className=\"grid gap-2 sm:grid-cols-2\">\n {list.map((p, i) => (\n <li key={p.label ?? i} className=\"flex items-center gap-2 rounded-md bg-muted px-3 py-2.5 text-sm text-foreground\">\n {p.icon ? <Icon name={p.icon} size={16} className=\"shrink-0 text-muted-foreground\" /> : null}\n <span className=\"min-w-0\">{p.label}</span>\n </li>\n ))}\n </ul>\n );\n}\n\nfunction Loading({ gallery }) {\n return (\n <div aria-busy=\"true\" aria-label=\"Loading the product\" className={cx(\"grid gap-8\", gallery !== \"overlay\" && \"md:grid-cols-2\")}>\n <Skeleton shape=\"rect\" className=\"aspect-square w-full\" />\n <div className=\"grid content-start gap-4\">\n <Skeleton className=\"w-1/4\" />\n <Skeleton className=\"h-8 w-3/4\" />\n <Skeleton className=\"h-7 w-1/3\" />\n <Skeleton className=\"w-full\" />\n <Skeleton className=\"w-5/6\" />\n <Skeleton shape=\"rect\" className=\"h-11 w-full\" />\n <Skeleton shape=\"rect\" className=\"h-11 w-full\" />\n </div>\n </div>\n );\n}\n\nexport function ProductDetail({\n product,\n gallery = \"thumbs\",\n details = \"accordion\",\n onAddToCart,\n onBuyNow,\n onFavorite,\n favorite = false,\n onViewCart,\n breadcrumb,\n perks,\n currency = \"USD\",\n locale,\n quantity: showQuantity = true,\n maxQuantity = 10,\n imageFit = \"cover\",\n loading = false,\n error,\n onRetry,\n addLabel = \"Add to cart\",\n buyLabel = \"Buy now\",\n headingLevel = 2,\n className,\n}) {\n const [selected, setSelected] = useState(() => defaultSelection(product));\n const [qty, setQty] = useState(1);\n const [status, setStatus] = useState({ state: \"idle\" }); // idle Β· busy Β· added Β· failed\n const [fav, setFav] = useState(favorite);\n const [favError, setFavError] = useState(null);\n const shown = useRef(product?.id);\n\n // A different product: its own first choices, one of it, no old message.\n useEffect(() => {\n if (shown.current === product?.id) return;\n shown.current = product?.id;\n setSelected(defaultSelection(product));\n setQty(1);\n setStatus({ state: \"idle\" });\n }, [product]);\n useEffect(() => setFav(favorite), [favorite]);\n\n if (loading) return <Loading gallery={gallery} />;\n if (error) {\n return (\n <Notice\n tone=\"danger\"\n title=\"The product did not load\"\n action={onRetry ? <Button size=\"sm\" onClick={onRetry}>Try again</Button> : null}\n className={className}\n >\n {typeof error === \"string\" ? error : null}\n </Notice>\n );\n }\n if (!product) {\n return <Empty icon=\"package\" title=\"No product here\" body=\"It may have been removed, or the link is out of date.\" className={className} />;\n }\n\n const cur = product.currency ?? currency;\n const { price, compareAt } = priceFor(product, selected);\n const soldOut = product.available === false;\n const max = Math.max(1, Math.min(maxQuantity, typeof product.stock === \"number\" ? product.stock : maxQuantity));\n const busy = status.state === \"busy\";\n const H = `h${Math.min(6, Math.max(1, headingLevel))}`;\n const perkList = perks ?? product.perks;\n\n function choose(key, value) {\n setSelected((s) => ({ ...s, [key]: value }));\n if (status.state !== \"busy\") setStatus({ state: \"idle\" });\n }\n\n async function run(which, fn) {\n const line = cartLine(product, selected, showQuantity ? qty : 1, cur);\n setStatus({ state: \"busy\", which });\n const r = await attempt(fn, line);\n if (r.ok) setStatus({ state: which === \"cart\" ? \"added\" : \"idle\", line });\n else setStatus({ state: \"failed\", which, error: r.error || (which === \"cart\" ? \"It was not added to your cart.\" : \"The checkout did not open.\") });\n }\n\n async function toggleFavorite(next) {\n setFav(next);\n setFavError(null);\n if (!onFavorite) return;\n const r = await attempt(onFavorite, product, next);\n if (!r.ok) {\n setFav(!next);\n setFavError(r.error || (next ? \"It was not saved.\" : \"It was not removed.\"));\n }\n }\n\n const buyBox = (\n <div className=\"grid min-w-0 content-start gap-5\">\n <div className=\"grid gap-2\">\n {product.badge || product.brand ? (\n <div className=\"flex flex-wrap items-center gap-2\">\n {product.brand ? <Kicker>{product.brand}</Kicker> : null}\n {product.badge ? <Badge variant=\"outline\">{product.badge}</Badge> : null}\n </div>\n ) : null}\n <H className=\"text-2xl font-semibold leading-tight tracking-tight text-foreground sm:text-3xl\">{product.name}</H>\n <div className=\"flex flex-wrap items-center gap-x-4 gap-y-2\">\n <Price price={price} compareAt={compareAt} currency={cur} locale={locale} />\n {typeof product.rating === \"number\" ? (\n <span className=\"inline-flex items-center gap-2 text-sm text-muted-foreground\">\n <Rating value={product.rating} size=\"sm\" label=\"Rated\" />\n <span data-nums>\n {product.rating.toFixed(1)}\n {typeof product.reviews === \"number\" ? ` (${product.reviews.toLocaleString(locale)} reviews)` : \"\"}\n </span>\n </span>\n ) : null}\n </div>\n {product.description ? <p className=\"max-w-prose text-sm leading-relaxed text-muted-foreground\">{product.description}</p> : null}\n </div>\n\n {product.stats?.length ? (\n <dl className=\"flex flex-wrap gap-x-6 gap-y-2 rounded-md bg-muted px-4 py-3 text-sm\">\n {product.stats.map((s) => (\n <div key={s.label} className=\"flex flex-row-reverse items-baseline justify-end gap-1.5\">\n <dt className=\"text-muted-foreground\">{s.label}</dt>\n <dd data-nums className=\"font-semibold text-foreground\">\n {s.value}\n </dd>\n </div>\n ))}\n </dl>\n ) : null}\n\n {(product.options ?? []).length ? (\n <div className=\"grid gap-5\">\n {product.options.map((o) => (\n <OptionPicker\n key={o.key}\n option={o}\n value={selected[o.key]}\n onChange={(v) => choose(o.key, v)}\n currency={cur}\n locale={locale}\n />\n ))}\n </div>\n ) : null}\n\n {showQuantity && !soldOut ? <QuantityControl value={qty} onChange={setQty} max={max} disabled={busy} /> : null}\n\n <div className=\"grid gap-2\">\n <div className=\"flex gap-2\">\n <Button\n variant=\"primary\"\n icon={soldOut ? undefined : \"shopping-cart\"}\n className=\"min-w-0 flex-1\"\n disabled={soldOut || !onAddToCart || (busy && status.which !== \"cart\")}\n loading={busy && status.which === \"cart\"}\n onClick={() => run(\"cart\", onAddToCart)}\n >\n {soldOut ? \"Sold out\" : addLabel}\n </Button>\n {onFavorite ? (\n <Toggle\n icon=\"heart\"\n label={fav ? \"Saved β remove from favourites\" : \"Save to favourites\"}\n pressed={fav}\n onPressedChange={toggleFavorite}\n />\n ) : null}\n </div>\n {onBuyNow && !soldOut ? (\n <Button\n variant=\"secondary\"\n className=\"w-full\"\n disabled={busy && status.which !== \"buy\"}\n loading={busy && status.which === \"buy\"}\n onClick={() => run(\"buy\", onBuyNow)}\n >\n {buyLabel}\n </Button>\n ) : null}\n <div aria-live=\"polite\" className=\"empty:hidden\">\n {status.state === \"added\" ? (\n <Notice\n tone=\"success\"\n action={onViewCart ? <Button size=\"sm\" onClick={onViewCart}>View cart</Button> : null}\n onDismiss={() => setStatus({ state: \"idle\" })}\n >\n {status.line.quantity} Γ {status.line.name}\n {status.line.variant ? ` (${status.line.variant})` : \"\"} added to your cart.\n </Notice>\n ) : null}\n </div>\n {status.state === \"failed\" ? (\n <Notice tone=\"danger\" onDismiss={() => setStatus({ state: \"idle\" })}>\n {status.error}\n </Notice>\n ) : null}\n {favError ? (\n <Notice tone=\"danger\" onDismiss={() => setFavError(null)}>\n {favError}\n </Notice>\n ) : null}\n </div>\n\n <Perks perks={perkList} />\n {gallery !== \"overlay\" ? <Details sections={product.details} as={details} /> : null}\n </div>\n );\n\n const crumbs = breadcrumb?.length ? <Breadcrumb items={breadcrumb} className=\"mb-6\" /> : null;\n\n if (gallery === \"overlay\") {\n const scene = product.scene ?? picturesOf(product)[0];\n const src = typeof scene === \"object\" ? scene?.src : scene;\n return (\n <section aria-label={product.name} className={cx(\"min-w-0\", className)}>\n {crumbs}\n <div className=\"grid gap-4 lg:relative lg:block\">\n <Image\n src={src}\n alt={typeof scene === \"object\" && scene?.alt ? scene.alt : `${product.name} in a room`}\n ratio=\"photo\"\n rounded=\"lg\"\n loading=\"eager\"\n className=\"w-full lg:aspect-auto lg:h-[40rem]\"\n />\n <div className=\"rounded-lg border border-border bg-card p-5 text-card-foreground shadow-lg lg:absolute lg:inset-y-6 lg:end-6 lg:w-[26rem] lg:overflow-y-auto\">\n {buyBox}\n </div>\n </div>\n {product.details?.length ? (\n <div className=\"mt-8 max-w-3xl\">\n <Details sections={product.details} as={details} />\n </div>\n ) : null}\n </section>\n );\n }\n\n return (\n <section aria-label={product.name} className={cx(\"min-w-0\", className)}>\n {crumbs}\n <div\n className={cx(\n \"grid items-start gap-8\",\n gallery === \"stack\" ? \"md:grid-cols-[3fr_2fr] lg:gap-12\" : \"md:grid-cols-2 lg:gap-12\",\n )}\n >\n <ProductMedia product={product} layout={gallery} fit={imageFit} />\n <div className={cx(\"min-w-0\", gallery === \"stack\" && \"md:sticky md:top-6\")}>{buyBox}</div>\n </div>\n </section>\n );\n}\n"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"path": "registry/blocks/product-detail/product-media.jsx",
|
|
26
|
+
"type": "registry:component",
|
|
27
|
+
"target": "@components/makersclaw/blocks/product-detail/product-media.jsx",
|
|
28
|
+
"content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { Carousel, Image, cx } from \"../../ui\";\n\n// The product's pictures, four ways:\n// thumbs one large picture, a row of thumbnails under it\n// rail the same, the thumbnails in a column beside it from lg up\n// carousel one at a time, swipe or arrows, dots under it\n// stack every picture shown: the first full width, the rest in pairs\n\n/** product.images (strings or { src, alt }) or product.image β [{ src, alt }]. */\nexport function picturesOf(product) {\n const list = product?.images?.length ? product.images : product?.image ? [product.image] : [];\n return list\n .filter(Boolean)\n .map((p, i) =>\n typeof p === \"object\"\n ? { src: p.src, alt: p.alt ?? `${product?.name ?? \"Product\"}, picture ${i + 1}` }\n : { src: p, alt: `${product?.name ?? \"Product\"}, picture ${i + 1}` },\n );\n}\n\nfunction Thumbs({ pictures, index, onPick, vertical }) {\n if (pictures.length < 2) return null;\n return (\n <div\n role=\"group\"\n aria-label=\"Pictures\"\n className={cx(\n \"flex gap-2\",\n vertical ? \"flex-wrap lg:max-h-[32rem] lg:flex-col lg:flex-nowrap lg:overflow-y-auto lg:p-0.5\" : \"flex-wrap\",\n )}\n >\n {pictures.map((p, i) => (\n // raw: a thumbnail picker β the kit's ToggleGroup is a text track\n // that neither wraps nor scrolls a row of pictures.\n <button\n key={`${p.src}-${i}`}\n type=\"button\"\n aria-pressed={i === index}\n aria-label={`Show picture ${i + 1} of ${pictures.length}`}\n onClick={() => onPick(i)}\n className={cx(\n \"shrink-0 overflow-hidden rounded-md border-2 outline-none transition-colors\",\n \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n i === index ? \"border-foreground\" : \"border-border hover:border-muted-foreground\",\n )}\n >\n <Image src={p.src} alt=\"\" ratio=\"square\" rounded=\"none\" className=\"size-14 sm:size-16\" />\n </button>\n ))}\n </div>\n );\n}\n\nexport function ProductMedia({ product, layout = \"thumbs\", fit = \"cover\", className }) {\n const pictures = picturesOf(product);\n const [index, setIndex] = useState(0);\n const at = Math.min(index, Math.max(0, pictures.length - 1));\n\n if (pictures.length === 0) {\n return <Image src={null} alt={product?.name ?? \"No picture\"} ratio=\"square\" rounded=\"lg\" className={cx(\"w-full\", className)} />;\n }\n\n if (layout === \"carousel\" && pictures.length > 1) {\n return (\n <Carousel label={`${product?.name ?? \"Product\"} pictures`} className={className}>\n {pictures.map((p, i) => (\n <Image key={`${p.src}-${i}`} src={p.src} alt={p.alt} ratio=\"square\" fit={fit} rounded=\"lg\" loading={i === 0 ? \"eager\" : \"lazy\"} />\n ))}\n </Carousel>\n );\n }\n\n if (layout === \"stack\") {\n const [first, ...rest] = pictures;\n return (\n <div className={cx(\"grid gap-3\", className)}>\n <Image src={first.src} alt={first.alt} ratio=\"square\" fit={fit} rounded=\"lg\" loading=\"eager\" />\n {rest.length ? (\n <div className=\"grid grid-cols-2 gap-3\">\n {rest.map((p, i) => (\n <Image key={`${p.src}-${i}`} src={p.src} alt={p.alt} ratio=\"portrait\" fit={fit} rounded=\"lg\" />\n ))}\n </div>\n ) : null}\n </div>\n );\n }\n\n const rail = layout === \"rail\";\n const main = pictures[at];\n return (\n <div className={cx(\"flex min-w-0 flex-col gap-3\", rail && \"lg:flex-row-reverse\", className)}>\n <div className=\"min-w-0 flex-1\">\n <Image src={main.src} alt={main.alt} ratio=\"square\" fit={fit} rounded=\"lg\" loading=\"eager\" />\n </div>\n <Thumbs pictures={pictures} index={at} onPick={setIndex} vertical={rail} />\n </div>\n );\n}\n"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"path": "registry/blocks/product-detail/product-options.jsx",
|
|
32
|
+
"type": "registry:component",
|
|
33
|
+
"target": "@components/makersclaw/blocks/product-detail/product-options.jsx",
|
|
34
|
+
"content": "\"use client\";\n\nimport { useId } from \"react\";\nimport { Badge, ButtonGroup, Field, IconButton, RadioGroup, Select, Swatch, ToggleGroup, cx } from \"../../ui\";\nimport { formatPrice, labelOf, valuesOf } from \"./price\";\n\n// One option's control. How it is drawn follows the values unless the\n// option says (`display`):\n// swatches values carry a `color` β colour squares, the name in the heading\n// buttons up to 6 short values β a segmented row (sizes)\n// select more, or longer, values β the native select\n// plans values carry a `price` or a `description` β a card per value\nfunction displayOf(option, values) {\n if (option.display) return option.display;\n if (values.some((v) => v.color)) return \"swatches\";\n if (values.some((v) => typeof v.price === \"number\" || v.description)) return \"plans\";\n const long = values.reduce((n, v) => n + labelOf(v).length, 0) > 30;\n return values.length <= 6 && !long ? \"buttons\" : \"select\";\n}\n\nexport function OptionPicker({ option, value, onChange, currency, locale }) {\n const id = useId();\n const values = valuesOf(option);\n if (values.length === 0) return null;\n const display = displayOf(option, values);\n const current = values.find((v) => v.value === value);\n const heading = option.label ?? option.key;\n\n if (display === \"select\") {\n return (\n <Field id={id} label={heading}>\n <Select id={id} value={value ?? \"\"} onChange={(e) => onChange(e.target.value)}>\n {values.map((v) => (\n <option key={v.value} value={v.value} disabled={v.disabled}>\n {labelOf(v)}\n {v.disabled ? \" β sold out\" : \"\"}\n </option>\n ))}\n </Select>\n </Field>\n );\n }\n\n if (display === \"plans\") {\n return (\n <div className=\"grid gap-2\">\n <p id={`${id}-l`} className=\"text-sm font-medium text-foreground\">\n {heading}\n </p>\n <RadioGroup\n aria-labelledby={`${id}-l`}\n variant=\"card\"\n value={value ?? undefined}\n onChange={onChange}\n options={values.map((v) => ({\n value: v.value,\n disabled: v.disabled,\n description: v.description,\n label: (\n <span className=\"flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1\">\n <span className=\"inline-flex flex-wrap items-center gap-2\">\n {labelOf(v)}\n {v.badge ? (\n <Badge tone=\"primary\" variant=\"soft\">\n {v.badge}\n </Badge>\n ) : null}\n </span>\n {typeof v.price === \"number\" ? (\n <span data-nums className=\"font-semibold\">\n {formatPrice(v.price, currency, locale)}\n {v.per ? <span className=\"text-xs font-normal text-muted-foreground\"> {v.per}</span> : null}\n </span>\n ) : null}\n </span>\n ),\n }))}\n />\n </div>\n );\n }\n\n const swatches = display === \"swatches\";\n return (\n <div className=\"grid gap-2\">\n <p id={`${id}-l`} className=\"text-sm font-medium text-foreground\">\n {heading}\n {swatches && current ? <span className=\"font-normal text-muted-foreground\">: {labelOf(current)}</span> : null}\n </p>\n <ToggleGroup\n aria-label={heading}\n value={value ?? undefined}\n onChange={onChange}\n className={swatches ? \"flex-wrap\" : undefined}\n items={values.map((v) => ({\n value: v.value,\n disabled: v.disabled,\n label: swatches ? (\n <Swatch color={v.color} label={`${labelOf(v)}${v.disabled ? \", sold out\" : \"\"}`} />\n ) : (\n <span className={cx(v.disabled && \"line-through\")}>{labelOf(v)}</span>\n ),\n }))}\n />\n </div>\n );\n}\n\n/** β n + as one attached control. The count is announced as it changes. */\nexport function QuantityControl({ value, onChange, min = 1, max = 10, disabled, label = \"Quantity\" }) {\n const id = useId();\n return (\n <div className=\"grid gap-2\">\n <p id={id} className=\"text-sm font-medium text-foreground\">\n {label}\n </p>\n <ButtonGroup aria-labelledby={id} className=\"w-fit\">\n <IconButton\n icon=\"minus\"\n label=\"Decrease quantity\"\n variant=\"secondary\"\n disabled={disabled || value <= min}\n onClick={() => onChange(Math.max(min, value - 1))}\n />\n <output\n aria-live=\"polite\"\n aria-label={`${label} ${value}`}\n data-nums\n className=\"grid min-w-12 place-items-center border border-border bg-background px-2 text-sm font-medium text-foreground\"\n >\n {value}\n </output>\n <IconButton\n icon=\"plus\"\n label=\"Increase quantity\"\n variant=\"secondary\"\n disabled={disabled || value >= max}\n onClick={() => onChange(Math.min(max, value + 1))}\n />\n </ButtonGroup>\n {value >= max && max > min ? <p className=\"text-xs text-muted-foreground\">{max} is the most you can add at once.</p> : null}\n </div>\n );\n}\n"
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"path": "registry/blocks/product-detail/price.jsx",
|
|
38
|
+
"type": "registry:component",
|
|
39
|
+
"target": "@components/makersclaw/blocks/product-detail/price.jsx",
|
|
40
|
+
"content": "import { Badge, cx } from \"../../ui\";\n\n// Money and the cart line, shared by the product page's parts. Prices are\n// numbers in the currency's major unit (19.5 is $19.50) β the same shape\n// the cart block reads, so a line made here drops straight into it.\n\nconst formats = new Map();\n\n/** 1290 β \"$1,290.00\" in the reader's locale. A bad currency code falls\n * back to USD rather than throwing mid-render. */\nexport function formatPrice(amount, currency = \"USD\", locale) {\n const key = `${locale ?? \"\"}|${currency}`;\n let f = formats.get(key);\n if (!f) {\n try {\n f = new Intl.NumberFormat(locale, { style: \"currency\", currency });\n } catch {\n f = new Intl.NumberFormat(locale, { style: \"currency\", currency: \"USD\" });\n }\n formats.set(key, f);\n }\n return f.format(Number(amount) || 0);\n}\n\nconst num = (v) => (typeof v === \"number\" && Number.isFinite(v) ? v : null);\n\n/** The price as shown: now, the old price struck through, and how much\n * is off. `size` lg on a product page, md on a card. */\nexport function Price({ price, compareAt, currency, locale, size = \"lg\", className }) {\n const now = num(price);\n const was = num(compareAt);\n if (now === null) return null;\n const off = was !== null && was > now ? Math.round(((was - now) / was) * 100) : 0;\n return (\n <p className={cx(\"flex flex-wrap items-baseline gap-x-2 gap-y-1\", className)}>\n <span data-nums className={cx(\"font-semibold text-foreground\", size === \"lg\" ? \"text-2xl\" : \"text-base\")}>\n {formatPrice(now, currency, locale)}\n </span>\n {off ? (\n <>\n <span data-nums className=\"text-sm text-muted-foreground line-through\">\n <span className=\"sr-only\">was </span>\n {formatPrice(was, currency, locale)}\n </span>\n <Badge tone=\"success\" variant=\"soft\">\n {off}% off\n </Badge>\n </>\n ) : null}\n </p>\n );\n}\n\n// An option's values arrive as strings or objects; objects from here on.\nexport const valuesOf = (option) =>\n (option?.values ?? []).filter((v) => v != null).map((v) => (typeof v === \"object\" ? v : { value: String(v), label: String(v) }));\n\nexport const labelOf = (v) => v?.label ?? String(v?.value ?? \"\");\n\n/** The first choice per option: `product.selected` where it names a\n * value that exists and is not sold out, else the first open value. */\nexport function defaultSelection(product) {\n const out = {};\n for (const o of product?.options ?? []) {\n const values = valuesOf(o);\n const given = product?.selected?.[o.key];\n const hit = values.find((v) => v.value === given && !v.disabled);\n out[o.key] = (hit ?? values.find((v) => !v.disabled))?.value ?? null;\n }\n return out;\n}\n\n/** What one unit costs with these choices: a chosen value with its own\n * `price` (a plan, a larger size) replaces the product's. */\nexport function priceFor(product, selected) {\n let price = product?.price;\n let compareAt = product?.compareAt;\n for (const o of product?.options ?? []) {\n const v = valuesOf(o).find((x) => x.value === selected?.[o.key]);\n if (v && num(v.price) !== null) {\n price = v.price;\n compareAt = v.compareAt;\n }\n }\n return { price, compareAt };\n}\n\n/**\n * The cart line for a product with these choices β the shape `cart`\n * takes as an item:\n * { id, key, name, price, compareAt?, currency, image, href?, quantity,\n * selected: { size: \"m\" }, variant: \"M Β· Sand\" }\n * `key` is the product id plus its choices, so the same shirt in two\n * sizes is two lines and the same one added twice is one.\n */\nexport function cartLine(product, selected = {}, quantity = 1, currency = \"USD\") {\n const { price, compareAt } = priceFor(product, selected);\n const chosen = (product?.options ?? [])\n .map((o) => ({ o, v: valuesOf(o).find((x) => x.value === selected[o.key]) }))\n .filter((c) => c.v);\n const first = product?.images?.[0];\n return {\n id: product?.id,\n key: [product?.id, ...chosen.map((c) => `${c.o.key}=${c.v.value}`)].join(\":\"),\n name: product?.name,\n price,\n ...(num(compareAt) !== null ? { compareAt } : {}),\n currency: product?.currency ?? currency,\n image: product?.image ?? (typeof first === \"object\" ? first?.src : first) ?? null,\n ...(product?.href ? { href: product.href } : {}),\n quantity,\n selected: Object.fromEntries(chosen.map((c) => [c.o.key, c.v.value])),\n variant: chosen.map((c) => labelOf(c.v)).join(\" Β· \"),\n };\n}\n"
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"path": "registry/blocks/product-detail/README.md",
|
|
44
|
+
"type": "registry:file",
|
|
45
|
+
"target": "@components/makersclaw/blocks/product-detail/README.md",
|
|
46
|
+
"content": "# Product detail\n\nOne product on its own page: its pictures, name, price, rating, the\noptions a customer picks (size, colour, plan), how many, and **Add to\ncart**. The add goes out through `onAddToCart(line)`; while it runs the\nbutton spins, when it fails the block says why and nothing else changes,\nwhen it works it says what was added. Details (description, care,\nshipping) sit under the buy box in an accordion or tabs.\n\n```jsx\nimport { ProductDetail } from \"../components/blocks/product-detail\"; // a Makersclaw app (\"./blocks/product-detail\" from components/)\nimport { ProductDetail } from \"@/components/makersclaw/blocks/product-detail\"; // a shadcn project\n```\n\nIt is a client component: render it from a `\"use client\"` component, and\nload the product in the server surface (README Β§4).\n\n## The data\n\nA **product** is one plain object. Only `id`, `name` and `price` are\nneeded; everything else draws a part when it is there.\n\n```js\n{\n id: \"tee-linen\", // required\n name: \"Linen camp shirt\", // required\n price: 68, // required: a number in the currency's major unit (68 = $68.00)\n compareAt: 85, // the old price, struck through, with \"20% off\"\n currency: \"USD\", // else the block's `currency`\n image: \"https://β¦\", // one picture, or:\n images: [\"https://β¦\", { src: \"https://β¦\", alt: \"The back\" }],\n brand: \"Harbor & Pine\", // a small line over the name\n badge: \"New\", // an outlined label beside it\n description: \"β¦\",\n rating: 4.6, reviews: 214, // stars (halves) and the count\n href: \"/shop/tee-linen\", // where the product lives (carried into the cart line)\n available: false, // sold out: the button says so and is off\n stock: 3, // the quantity control stops here\n options: [ // what the customer picks, in order\n { key: \"size\", label: \"Size\", values: [\"S\", \"M\", \"L\", { value: \"XL\", disabled: true }] },\n { key: \"color\", label: \"Colour\", values: [{ value: \"sand\", label: \"Sand\", color: \"#d8c8a8\" }] },\n ],\n selected: { size: \"M\" }, // the first choices (else the first open value of each)\n details: [{ key: \"care\", title: \"Care\", content: \"Cold washβ¦\" }], // content: text or a node\n perks: [{ icon: \"truck\", label: \"Free shipping over $100\" }],\n stats: [{ value: \"1,234\", label: \"sales\" }],\n scene: \"https://β¦\", // gallery=\"overlay\": the wide picture the buy box floats on\n}\n```\n\nAn option's **values** are strings or `{ value, label?, color?, price?,\ncompareAt?, per?, description?, badge?, disabled? }`. How an option is\ndrawn follows its values (or its `display`):\n\n- `color` on the values β **swatches** (colour squares; the chosen name\n in the heading). The colour is data, drawn by the kit's `Swatch`.\n- up to 6 short values β **buttons**, a segmented row (sizes).\n- more, or longer β **select**, the native one.\n- `price` or `description` on the values β **plans**, a card each. The\n chosen value's `price` replaces the product's (a licence, a bigger box).\n\n`disabled: true` on a value marks it sold out: struck through, not\nchoosable.\n\n### The cart line\n\n`onAddToCart` and `onBuyNow` get one **line**, the same shape the `cart`\nblock takes as an item, so a surface can push it straight into its cart\nrows:\n\n```js\n{\n id: \"tee-linen\",\n key: \"tee-linen:size=M:color=sand\", // one cart row per product + choices\n name: \"Linen camp shirt\",\n price: 68, compareAt: 85, currency: \"USD\",\n image: \"https://β¦\",\n href: \"/shop/tee-linen\",\n quantity: 2,\n selected: { size: \"M\", color: \"sand\" }, // machine values, for your order row\n variant: \"M Β· Sand\", // what the cart prints under the name\n}\n```\n\n`cartLine(product, selected, quantity, currency)` builds the same line\nanywhere else (a grid's quick add).\n\n## Props\n\n- `product` β the product above. `null` (and not loading) shows \"No\n product here\".\n- `onAddToCart(line)` β put the line in the cart; return `{ ok: true }` or\n `{ ok: false, error }` (a throw counts as failed, its message shown).\n Without it the button is off.\n- `gallery` β how the pictures sit: `\"thumbs\"` (default: one large, a\n row of thumbnails under it) Β· `\"rail\"` (thumbnails in a column beside\n it from lg up) Β· `\"carousel\"` (one at a time, swipe, arrows, dots) Β·\n `\"stack\"` (every picture, the buy box sticky beside them) Β·\n `\"overlay\"` (one wide `scene` picture with the buy box floating over it\n from lg up; under it on a phone).\n- `details` β `\"accordion\"` (default; the first section open) or `\"tabs\"`.\n- `onBuyNow(line)` β a second button that goes straight to checkout\n (open Paddle there); `{ ok }` as above. Absent: no button.\n- `onFavorite(product, next)` β shows a heart toggle; `next` is true to\n save, false to remove. It flips at once and flips back, with the\n error, when the save fails. `favorite` β its starting state.\n- `onViewCart()` β a \"View cart\" button in the added message.\n- `breadcrumb` β `[{ label, href? }]`, drawn above with the kit's\n `Breadcrumb`.\n- `perks` β `[{ icon, label }]` under the buttons (else `product.perks`).\n `icon` is a kit `Icon` name: `truck`, `refresh`, `shield-check`, `package`.\n- `quantity` β `false` hides the quantity control (a download); default\n `true`. `maxQuantity` β the most at once (10; `product.stock` caps it).\n- `currency` β default `\"USD\"`. `locale` β for prices and counts\n (default: the reader's).\n- `imageFit` β `\"cover\"` (default, cropped to the square) or `\"contain\"`\n (the whole picture, on a muted fill: packshots on white, screenshots).\n- `loading` β skeletons in the page's shape. `error` (a string) β the\n load failed; `onRetry` adds \"Try again\".\n- `addLabel` (`\"Add to cart\"`) Β· `buyLabel` (`\"Buy now\"`) β the words.\n- `headingLevel` β the name's heading, `2` by default; `1` when the block\n IS the page.\n- `className` β layout around it (width, margin). Never colour.\n\nThe parts are exported for a page of your own: `ProductMedia({ product,\nlayout, fit })`, `OptionPicker({ option, value, onChange })`,\n`QuantityControl({ value, onChange, min, max })`, `Price({ price,\ncompareAt, currency })`, `formatPrice(amount, currency, locale)`.\n\n## Example\n\n```jsx\n// surfaces/product.jsx β server: load, hand over\nimport { loadRow } from \"../lib/data\";\nimport ProductPage from \"../components/ProductPage\";\n\nexport default async function ProductSurface({ params }) {\n const { row, error } = await loadRow({ source: \"public.products\", id: params.id });\n return <ProductPage product={row} error={error} />;\n}\n```\n\n```jsx\n// components/ProductPage.jsx β client: the page, the cart write\n\"use client\";\nimport { insertRow } from \"../lib/actions\";\nimport { ProductDetail } from \"./blocks/product-detail\";\n\nexport default function ProductPage({ product, error }) {\n return (\n <ProductDetail\n product={product}\n error={error}\n breadcrumb={[{ label: \"Shop\", href: \"/shop\" }, { label: product?.name ?? \"Product\" }]}\n perks={[\n { icon: \"truck\", label: \"Free shipping over $100\" },\n { icon: \"refresh\", label: \"30-day returns\" },\n ]}\n onAddToCart={(line) =>\n insertRow({\n source: \"public.cart_items\",\n values: { product_id: line.id, line_key: line.key, quantity: line.quantity, selected: line.selected },\n detail: `${line.quantity} Γ ${line.name}`,\n })\n }\n />\n );\n}\n```\n\nA digital product with plans, no quantity, and a straight-to-checkout\nbutton:\n\n```jsx\n<ProductDetail\n product={{\n id: \"ui-kit\",\n name: \"Dashboard UI kit\",\n price: 49,\n images: [\"https://β¦/cover.png\"],\n rating: 4.9,\n reviews: 127,\n stats: [{ value: \"1,234\", label: \"sales\" }],\n options: [{\n key: \"licence\",\n label: \"Licence\",\n values: [\n { value: \"solo\", label: \"Solo\", price: 49, description: \"One maker, unlimited projects.\" },\n { value: \"team\", label: \"Team\", price: 149, badge: \"Popular\", description: \"Up to 10 seats.\" },\n ],\n }],\n }}\n quantity={false}\n imageFit=\"contain\"\n onAddToCart={addToCart}\n onBuyNow={(line) => openPaddleCheckout(line)}\n/>\n```\n\n## What it will not do\n\n- Take payment. \"Buy now\" hands the line to `onBuyNow`; checkout is\n Paddle's, and no card field is ever drawn here.\n- Keep the cart. It hands over one line; the cart's rows, totals and\n merging live in your data and the `cart` block.\n- Show similar products or reviews in full: put a `ProductGrid` (the\n `product-grid` block) and your review list under it.\n- Colour itself: swatch colours are data; everything else is the theme.\n\nA product page it cannot be is your own component in `components/`, built\nfrom the kit (and this block's exported parts) β not an edit to this\nblock. Its files are fixed: `npm run check` refuses a change, and\n`makersclaw add product-detail` restores them.\n\n## Credits\n\nPorted from Creative Tim UI (https://github.com/creativetimofficial/ui), MIT β a rewrite on the Makersclaw kit, none of its code, copy or pictures.\n"
|
|
47
|
+
}
|
|
48
|
+
],
|
|
49
|
+
"categories": [
|
|
50
|
+
"product",
|
|
51
|
+
"shop",
|
|
52
|
+
"store",
|
|
53
|
+
"ecommerce",
|
|
54
|
+
"commerce",
|
|
55
|
+
"buy",
|
|
56
|
+
"add to cart",
|
|
57
|
+
"price",
|
|
58
|
+
"rating",
|
|
59
|
+
"size",
|
|
60
|
+
"colour",
|
|
61
|
+
"variant",
|
|
62
|
+
"quantity",
|
|
63
|
+
"gallery",
|
|
64
|
+
"carousel",
|
|
65
|
+
"plans",
|
|
66
|
+
"favourite",
|
|
67
|
+
"pdp"
|
|
68
|
+
],
|
|
69
|
+
"docs": "Installed to components/makersclaw/blocks/product-detail. Import: import { ProductDetail, ProductMedia, OptionPicker, QuantityControl, Price, formatPrice, cartLine } from \"@/components/makersclaw/blocks/product-detail\". Props and an example: components/makersclaw/blocks/product-detail/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
|
|
70
|
+
"meta": {
|
|
71
|
+
"makersclaw": {
|
|
72
|
+
"version": "0.3.0",
|
|
73
|
+
"useWhen": "Selling one thing a customer looks at before buying: a product page, a template or digital download with plans, a merch item, a booking add-on. Not for many products at once (product-grid), the cart itself (cart), or a plan comparison table."
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "product-grid",
|
|
4
|
+
"type": "registry:block",
|
|
5
|
+
"title": "Product grid",
|
|
6
|
+
"description": "Products as cards in a grid: picture, badge, favourite heart, brand, name, star rating, price with the old one struck through, and a quick add to cart through an async callback that may fail (\"Choose options\" when the product has sizes or colours). Facet filters as selects that also match a product's option values, a sort, a live count, a lead tile that takes a double cell (a picture or a muted looping video), and a promo card for collection tiles. Loading, error, empty and no-matches states; two columns on a phone.",
|
|
7
|
+
"registryDependencies": [
|
|
8
|
+
"https://cdn.jsdelivr.net/npm/makersclaw@0.3.0/r/core.json",
|
|
9
|
+
"https://cdn.jsdelivr.net/npm/makersclaw@0.3.0/r/product-detail.json"
|
|
10
|
+
],
|
|
11
|
+
"dependencies": [],
|
|
12
|
+
"files": [
|
|
13
|
+
{
|
|
14
|
+
"path": "registry/blocks/product-grid/index.js",
|
|
15
|
+
"type": "registry:component",
|
|
16
|
+
"target": "@components/makersclaw/blocks/product-grid/index.js",
|
|
17
|
+
"content": "// The Product grid block β `makersclaw add product-grid`. Fixed: compose\n// it through its props (README.md beside this file); a catalogue it cannot\n// be is your own component in components/.\nexport { ProductGrid, productMatches } from \"./product-grid\";\nexport { ProductCard, PromoTile } from \"./product-card\";\n"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"path": "registry/blocks/product-grid/product-grid.jsx",
|
|
21
|
+
"type": "registry:component",
|
|
22
|
+
"target": "@components/makersclaw/blocks/product-grid/product-grid.jsx",
|
|
23
|
+
"content": "\"use client\";\n\nimport { isValidElement, useId, useMemo, useState } from \"react\";\nimport { Button, Empty, Notice, Select, Skeleton, cx } from \"../../ui\";\nimport { ProductCard, PromoTile } from \"./product-card\";\n\n// Products (or promotions) as cards in a grid, with facet filters, a sort\n// and a count over it, and an optional lead tile that takes a double\n// cell. Filtering and sorting happen here over `products`, or β with\n// `manual` β in your loader, the block only reporting what was picked.\n\nconst SORTS = {\n featured: { label: \"Featured\", compare: null },\n \"price-asc\": { label: \"Price: low to high\", compare: (a, b) => (a.price ?? 0) - (b.price ?? 0) },\n \"price-desc\": { label: \"Price: high to low\", compare: (a, b) => (b.price ?? 0) - (a.price ?? 0) },\n rating: { label: \"Top rated\", compare: (a, b) => (b.rating ?? 0) - (a.rating ?? 0) },\n newest: {\n label: \"Newest\",\n compare: (a, b) => String(b.createdAt ?? \"\").localeCompare(String(a.createdAt ?? \"\")),\n },\n};\nconst DEFAULT_SORTS = [\"featured\", \"price-asc\", \"price-desc\", \"rating\"];\n\nfunction sortsOf(sort) {\n if (!sort) return [];\n const list = Array.isArray(sort) ? sort : DEFAULT_SORTS;\n return list\n .map((s) => (typeof s === \"string\" ? { value: s, ...SORTS[s] } : { ...SORTS[s.value], ...s }))\n .filter((s) => s.value && s.label);\n}\n\nconst optionsOf = (filter) =>\n (filter.options ?? []).filter((o) => o != null).map((o) => (typeof o === \"object\" ? o : { value: String(o), label: String(o) }));\n\n/** Does `product` carry `value` under `key`? Looked for in the product's\n * own field (a string or a list), in `facets[key]`, and in the values of\n * its option with that key β so a size filter matches the sizes a\n * product is sold in. */\nexport function productMatches(product, key, value) {\n const found = [];\n const push = (v) => (Array.isArray(v) ? found.push(...v) : v != null && found.push(v));\n push(product?.[key]);\n push(product?.facets?.[key]);\n const option = product?.options?.find((o) => o.key === key);\n for (const v of option?.values ?? []) if (!(typeof v === \"object\" && v?.disabled)) push(typeof v === \"object\" ? v.value : v);\n return found.some((v) => String(typeof v === \"object\" ? v.value : v) === String(value));\n}\n\nconst COLS = {\n product: {\n 2: \"grid-cols-2\",\n 3: \"grid-cols-2 lg:grid-cols-3\",\n 4: \"grid-cols-2 lg:grid-cols-3 xl:grid-cols-4\",\n },\n promo: {\n 2: \"grid-cols-1 sm:grid-cols-2\",\n 3: \"grid-cols-1 sm:grid-cols-2 lg:grid-cols-3\",\n 4: \"grid-cols-1 sm:grid-cols-2 lg:grid-cols-4\",\n },\n};\n\nfunction LoadingCards({ count, promo, cols }) {\n return (\n <div aria-busy=\"true\" aria-label=\"Loading products\" className={cx(\"grid gap-3 sm:gap-4\", cols)}>\n {Array.from({ length: count }, (_, i) =>\n promo ? (\n <Skeleton key={i} shape=\"rect\" className=\"h-80 w-full\" />\n ) : (\n <div key={i} className=\"grid gap-2 overflow-hidden rounded-lg border border-border bg-card pb-4\">\n <Skeleton shape=\"rect\" className=\"aspect-square w-full\" />\n <Skeleton className=\"mx-4 w-1/3\" />\n <Skeleton className=\"mx-4 w-3/4\" />\n <Skeleton className=\"mx-4 w-1/4\" />\n </div>\n ),\n )}\n </div>\n );\n}\n\nexport function ProductGrid({\n products,\n card = \"product\",\n columns,\n filters,\n sort,\n defaultSort,\n manual = false,\n total,\n onQueryChange,\n feature,\n onOpen,\n onAddToCart,\n onFavorite,\n favorites,\n currency = \"USD\",\n locale,\n addLabel,\n countLabel = (n) => `${n.toLocaleString(locale)} ${n === 1 ? \"product\" : \"products\"}`,\n loading = false,\n error,\n onRetry,\n emptyTitle = \"No products yet\",\n emptyBody,\n label = \"Products\",\n className,\n}) {\n const id = useId();\n const promo = card === \"promo\";\n const sorts = sortsOf(sort);\n const facetList = (filters ?? []).filter(Boolean);\n const [picked, setPicked] = useState(() => Object.fromEntries(facetList.map((f) => [f.key, f.value ?? \"\"])));\n const [order, setOrder] = useState(defaultSort ?? sorts[0]?.value ?? \"featured\");\n const favSet = new Set(favorites ?? []);\n const active = Object.values(picked).some(Boolean);\n\n const shown = useMemo(() => {\n let list = (products ?? []).filter(Boolean);\n if (manual) return list;\n for (const [key, value] of Object.entries(picked)) if (value) list = list.filter((p) => productMatches(p, key, value));\n const cmp = sorts.find((s) => s.value === order)?.compare;\n return cmp ? [...list].sort(cmp) : list;\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [products, picked, order, manual, sort]);\n\n function change(nextPicked, nextOrder) {\n setPicked(nextPicked);\n setOrder(nextOrder);\n onQueryChange?.({ filters: Object.fromEntries(Object.entries(nextPicked).filter(([, v]) => v)), sort: nextOrder });\n }\n const clear = () => change(Object.fromEntries(facetList.map((f) => [f.key, \"\"])), order);\n\n const n = columns ?? (promo ? 3 : 4);\n const cols = (COLS[promo ? \"promo\" : \"product\"][n] ?? COLS.product[4]);\n const count = manual && typeof total === \"number\" ? total : shown.length;\n\n const toolbar =\n facetList.length || sorts.length ? (\n <div className=\"flex flex-wrap items-end justify-between gap-3\">\n {facetList.length ? (\n <div role=\"group\" aria-label=\"Filters\" className=\"grid w-full grid-cols-2 gap-2 sm:flex sm:w-auto sm:flex-wrap\">\n {facetList.map((f) => (\n <div key={f.key} className=\"min-w-0 sm:w-40\">\n <Select aria-label={f.label} value={picked[f.key] ?? \"\"} onChange={(e) => change({ ...picked, [f.key]: e.target.value }, order)}>\n <option value=\"\">{f.all ?? `All ${String(f.label).toLowerCase()}`}</option>\n {optionsOf(f).map((o) => (\n <option key={o.value} value={o.value}>\n {o.label}\n </option>\n ))}\n </Select>\n </div>\n ))}\n {active ? (\n <Button variant=\"quiet\" icon=\"x\" onClick={clear}>\n Clear\n </Button>\n ) : null}\n </div>\n ) : null}\n <div className=\"flex w-full items-center justify-between gap-3 sm:ml-auto sm:w-auto\">\n <p id={`${id}-count`} data-nums aria-live=\"polite\" className=\"text-sm text-muted-foreground whitespace-nowrap\">\n {loading ? \"Β \" : countLabel(count)}\n </p>\n {sorts.length ? (\n <div className=\"w-44 shrink-0\">\n <Select\n aria-label=\"Sort by\"\n value={order}\n onChange={(e) => change(picked, e.target.value)}\n options={sorts.map((s) => ({ value: s.value, label: s.label }))}\n />\n </div>\n ) : null}\n </div>\n </div>\n ) : null;\n\n let body;\n if (loading) body = <LoadingCards count={promo ? n : n * 2} promo={promo} cols={cols} />;\n else if (error) {\n body = (\n <Notice tone=\"danger\" title=\"The products did not load\" action={onRetry ? <Button size=\"sm\" onClick={onRetry}>Try again</Button> : null}>\n {typeof error === \"string\" ? error : null}\n </Notice>\n );\n } else if (shown.length === 0 && !feature) {\n body = active ? (\n <Empty\n icon=\"filter\"\n title=\"Nothing matches these filters\"\n body=\"Take one off, or clear them all.\"\n action={<Button onClick={clear}>Clear filters</Button>}\n />\n ) : (\n <Empty icon=\"package\" title={emptyTitle} body={emptyBody} />\n );\n } else {\n const lead = feature ? (\n <li key=\"feature\" className={cx(\"min-w-0\", promo ? \"sm:col-span-2 sm:row-span-2\" : \"col-span-2 lg:row-span-2\")}>\n {isValidElement(feature) ? feature : <PromoTile item={feature} large onOpen={feature.onOpen} className=\"h-full\" />}\n </li>\n ) : null;\n body = (\n <ul aria-describedby={toolbar ? `${id}-count` : undefined} className={cx(\"grid gap-3 sm:gap-4\", cols)}>\n {lead}\n {shown.map((p, i) => (\n <li key={p.id ?? i} className=\"min-w-0\">\n {promo ? (\n <PromoTile item={p} onOpen={onOpen} className=\"h-full\" />\n ) : (\n <ProductCard\n product={p}\n onOpen={onOpen}\n onAddToCart={onAddToCart}\n onFavorite={onFavorite}\n favorite={favSet.has(p.id)}\n currency={currency}\n locale={locale}\n addLabel={addLabel}\n />\n )}\n </li>\n ))}\n </ul>\n );\n }\n\n return (\n <section aria-label={label} className={cx(\"grid min-w-0 gap-4\", className)}>\n {toolbar}\n {body}\n </section>\n );\n}\n"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"path": "registry/blocks/product-grid/product-card.jsx",
|
|
27
|
+
"type": "registry:component",
|
|
28
|
+
"target": "@components/makersclaw/blocks/product-grid/product-card.jsx",
|
|
29
|
+
"content": "\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport { Badge, Button, Image, Kicker, Rating, Toggle, cx } from \"../../ui\";\nimport { Price, cartLine } from \"../product-detail\";\n\n// One product as a card in the grid, and one promotion as a picture tile.\n// The whole card opens the product (its name is the real link or button,\n// stretched over the card); the heart and the add button sit above that\n// target and click on their own.\n\nasync function attempt(fn, ...args) {\n try {\n const r = await fn(...args);\n return r && typeof r === \"object\" ? r : { ok: Boolean(r) };\n } catch (e) {\n return { ok: false, error: e?.message };\n }\n}\n\nconst STRETCH = \"after:absolute after:inset-0\";\n\nfunction Title({ href, onOpen, children, className }) {\n if (href) {\n return (\n <Button variant=\"link\" size=\"inherit\" tone=\"muted\" href={href} data-stretch className={cx(STRETCH, className)}>\n {children}\n </Button>\n );\n }\n if (onOpen) {\n return (\n <Button variant=\"link\" size=\"inherit\" tone=\"muted\" onClick={onOpen} data-stretch className={cx(\"text-left\", STRETCH, className)}>\n {children}\n </Button>\n );\n }\n return <span className={className}>{children}</span>;\n}\n\n/**\n * A product card: picture, badge, heart, brand, name, rating, price and a\n * quick add. A product with options cannot be added blind: its button\n * says \"Choose options\" and opens it.\n */\nexport function ProductCard({ product, onOpen, onAddToCart, onFavorite, favorite = false, currency = \"USD\", locale, addLabel = \"Add to cart\", className }) {\n const [fav, setFav] = useState(favorite);\n const [status, setStatus] = useState({ state: \"idle\" });\n useEffect(() => setFav(favorite), [favorite]);\n useEffect(() => {\n if (status.state !== \"added\") return;\n const t = setTimeout(() => setStatus({ state: \"idle\" }), 2500);\n return () => clearTimeout(t);\n }, [status.state]);\n\n const open = onOpen ? () => onOpen(product) : undefined;\n const cur = product.currency ?? currency;\n const needsChoice = (product.options ?? []).some((o) => (o.values ?? []).length > 1);\n const soldOut = product.available === false;\n const picture = product.image ?? (typeof product.images?.[0] === \"object\" ? product.images[0]?.src : product.images?.[0]);\n\n async function add() {\n setStatus({ state: \"busy\" });\n const r = await attempt(onAddToCart, cartLine(product, {}, 1, cur));\n setStatus(r.ok ? { state: \"added\" } : { state: \"failed\", error: r.error || \"Not added. Try again.\" });\n }\n\n async function toggle(next) {\n setFav(next);\n const r = await attempt(onFavorite, product, next);\n if (!r.ok) {\n setFav(!next);\n setStatus({ state: \"failed\", error: r.error || (next ? \"Not saved.\" : \"Not removed.\") });\n }\n }\n\n return (\n <article\n className={cx(\n \"relative flex h-full min-w-0 flex-col overflow-hidden rounded-lg border border-border bg-card text-card-foreground\",\n \"has-[a[data-stretch]:focus-visible,button[data-stretch]:focus-visible]:ring-2 has-[a[data-stretch]:focus-visible,button[data-stretch]:focus-visible]:ring-ring\",\n (product.href || onOpen) && \"transition-shadow hover:shadow-md\",\n className,\n )}\n >\n <div className=\"relative\">\n <Image src={picture} alt=\"\" ratio=\"square\" rounded=\"none\" />\n {product.badge ? (\n <Badge variant=\"soft\" className=\"absolute start-2 top-2\">\n {product.badge}\n </Badge>\n ) : null}\n {onFavorite ? (\n <Toggle\n icon=\"heart\"\n label={fav ? `Remove ${product.name} from favourites` : `Save ${product.name} to favourites`}\n pressed={fav}\n onPressedChange={toggle}\n className=\"absolute end-2 top-2 z-10\"\n />\n ) : null}\n </div>\n <div className=\"flex flex-1 flex-col gap-1.5 border-t border-border p-3 sm:p-4\">\n {product.brand ? <Kicker className=\"truncate\">{product.brand}</Kicker> : null}\n <h3 className=\"line-clamp-2 text-sm font-medium leading-snug\">\n <Title href={product.href} onOpen={open}>\n {product.name}\n </Title>\n </h3>\n {typeof product.rating === \"number\" ? (\n <p className=\"flex items-center gap-1.5 text-xs text-muted-foreground\">\n <Rating value={product.rating} size=\"sm\" />\n {typeof product.reviews === \"number\" ? <span data-nums>({product.reviews.toLocaleString(locale)})</span> : null}\n </p>\n ) : null}\n <Price price={product.price} compareAt={product.compareAt} currency={cur} locale={locale} size=\"md\" className=\"mt-auto pt-1\" />\n {onAddToCart ? (\n <div className=\"relative z-10 mt-2 grid gap-1.5\">\n {soldOut ? (\n <Button disabled className=\"w-full\">\n Sold out\n </Button>\n ) : needsChoice ? (\n <Button className=\"w-full\" onClick={open} disabled={!open} href={open ? undefined : product.href}>\n Choose options\n </Button>\n ) : (\n <Button variant=\"primary\" icon=\"shopping-cart\" className=\"w-full\" loading={status.state === \"busy\"} onClick={add}>\n {status.state === \"added\" ? \"Added\" : addLabel}\n </Button>\n )}\n <p aria-live=\"polite\" className=\"text-xs empty:hidden\">\n {status.state === \"failed\" ? <span className=\"text-destructive\">{status.error}</span> : null}\n {status.state === \"added\" ? <span className=\"sr-only\">{product.name} added to your cart.</span> : null}\n </p>\n </div>\n ) : null}\n </div>\n </article>\n );\n}\n\n/**\n * A promotion: a tall picture with a kicker, a title and an action on a\n * panel over its foot. The panel is the theme's background, so the words\n * read on any picture in any theme.\n */\nexport function PromoTile({ item, large = false, onOpen, className }) {\n const action = item.action ?? (item.href || onOpen ? { label: item.actionLabel ?? \"Shop now\", href: item.href } : null);\n const open = action?.onClick ?? (onOpen ? () => onOpen(item) : undefined);\n return (\n <article\n className={cx(\n \"relative flex min-w-0 overflow-hidden rounded-lg border border-border bg-muted\",\n \"has-[a[data-stretch]:focus-visible,button[data-stretch]:focus-visible]:ring-2 has-[a[data-stretch]:focus-visible,button[data-stretch]:focus-visible]:ring-ring\",\n large ? \"min-h-80 lg:min-h-full\" : \"min-h-80\",\n className,\n )}\n >\n <FeatureMedia item={item} />\n <div className={cx(\"relative mt-auto w-full p-3\", large && \"sm:p-5\")}>\n <div className=\"grid gap-1 rounded-md bg-background/90 p-4 text-foreground shadow-sm backdrop-blur-sm\">\n {item.kicker ? <Kicker>{item.kicker}</Kicker> : null}\n <h3 className={cx(\"font-semibold leading-tight\", large ? \"text-xl sm:text-2xl\" : \"text-lg\")}>{item.title ?? item.name}</h3>\n {item.body ? <p className=\"text-sm text-muted-foreground\">{item.body}</p> : null}\n {action ? (\n <p className=\"mt-1 text-sm\">\n {action.href ? (\n <Button variant=\"link\" href={action.href} data-stretch className={STRETCH}>\n {action.label}\n </Button>\n ) : open ? (\n <Button variant=\"link\" onClick={open} data-stretch className={STRETCH}>\n {action.label}\n </Button>\n ) : null}\n </p>\n ) : null}\n </div>\n </div>\n </article>\n );\n}\n\n// The tile's picture, or a muted looping video for a feature. The video\n// never plays for a reader who asks for reduced motion, and has a pause.\nfunction FeatureMedia({ item }) {\n const [paused, setPaused] = useState(false);\n const [calm, setCalm] = useState(true);\n useEffect(() => {\n if (!item.video || typeof matchMedia !== \"function\") return;\n const mq = matchMedia(\"(prefers-reduced-motion: reduce)\");\n setCalm(mq.matches);\n const on = () => setCalm(mq.matches);\n mq.addEventListener(\"change\", on);\n return () => mq.removeEventListener(\"change\", on);\n }, [item.video]);\n\n if (item.video) {\n const still = calm || paused;\n return (\n <>\n {/* A picture of motion, not content: muted, no controls of its own. */}\n <video\n key={still ? \"still\" : \"moving\"}\n src={item.video}\n poster={item.poster ?? item.image}\n muted\n loop\n playsInline\n autoPlay={!still}\n aria-hidden=\"true\"\n className=\"absolute inset-0 size-full object-cover\"\n />\n {!calm ? (\n <Toggle\n icon={paused ? \"play\" : \"pause\"}\n label={paused ? \"Play the video\" : \"Pause the video\"}\n pressed={paused}\n onPressedChange={setPaused}\n className=\"absolute start-3 top-3 z-10\"\n />\n ) : null}\n </>\n );\n }\n return (\n <div className=\"absolute inset-0\">\n <Image src={item.image} alt={item.alt ?? \"\"} ratio=\"square\" rounded=\"none\" className=\"size-full\" />\n </div>\n );\n}\n"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"path": "registry/blocks/product-grid/README.md",
|
|
33
|
+
"type": "registry:file",
|
|
34
|
+
"target": "@components/makersclaw/blocks/product-grid/README.md",
|
|
35
|
+
"content": "# Product grid\n\nProducts as cards in a grid β picture, badge, heart, brand, name, stars,\nprice β with a quick **Add to cart** on each. Over the grid: facet filters,\na sort and a live count. A lead tile (a picture or a muted looping video)\ncan take a double cell, and `card=\"promo\"` turns the cards into collection\ntiles (\"New\", \"Sale\"). The add goes out through `onAddToCart(line)`; while\nit runs the card's button spins, when it fails the card says why.\n\n```jsx\nimport { ProductGrid } from \"../components/blocks/product-grid\"; // a Makersclaw app (\"./blocks/product-grid\" from components/)\nimport { ProductGrid } from \"@/components/makersclaw/blocks/product-grid\"; // a shadcn project\n```\n\nIt is a client component: render it from a `\"use client\"` component, and\nload the rows in the server surface (README Β§4). It requires\n`product-detail` (added with it): the cards share its price and cart line.\n\n## The data\n\n`products` are the same objects `ProductDetail` takes (see its README):\n`{ id, name, price }` required; `compareAt`, `currency`, `image` (or\n`images`), `brand`, `badge`, `rating`, `reviews`, `href`, `available:\nfalse` (sold out), `options` all draw on the card when present. A product\nwith an option that has more than one value cannot be added blind: its\nbutton says **Choose options** and opens it (`onOpen`, else its `href`).\n\nFilters look for a value in three places on each product: its own field\n(`product.category`, a string or a list), `product.facets[key]`, and the\nvalues of its option with that key (so a **size** filter keeps what is\nsold in that size, skipping sold-out sizes).\n\nWith `card=\"promo\"` each item is a tile: `{ id, title, kicker?, body?,\nimage, href?, actionLabel?, action?: { label, href?, onClick? } }`.\n\n### The cart line\n\n`onAddToCart` gets one line, the shape the `cart` block takes as an item:\n`{ id, key, name, price, compareAt?, currency, image, href?, quantity: 1,\nselected: {}, variant: \"\" }`. `key` is the product id (plus its choices on\na product page), so adding the same thing twice is one row β merge\nquantities by `key`.\n\n## Props\n\n- `products` β the rows. Pass `useFixture(rows, SAMPLE).rows` to show\n example cards before there is data.\n- `onAddToCart(line)` β return `{ ok: true }` or `{ ok: false, error }` (a\n throw counts as failed). Without it the cards have no button.\n- `onOpen(product)` β the card becomes a button that opens the product (a\n `Sheet`, a route). A product's own `href` makes it a link instead.\n- `onFavorite(product, next)` β a heart on each picture; flips at once,\n flips back with the error when the save fails. `favorites` β the ids\n saved now.\n- `filters` β `[{ key, label, options, all?, value? }]`: one select per\n facet, first choice \"All β¦\" (`all` renames it, `value` starts it set).\n `options` are strings or `{ value, label }`.\n- `sort` β `true` for Featured, price up, price down, top rated; or a\n list of those keys (`\"featured\"`, `\"price-asc\"`, `\"price-desc\"`,\n `\"rating\"`, `\"newest\"` by `createdAt`) and your own `{ value, label,\n compare(a, b) }`. `defaultSort` β the key it starts on.\n- `manual` β the loader filters and sorts (a big catalogue): the block\n shows `products` as they come, reports each change through\n `onQueryChange({ filters, sort })`, and counts `total`.\n- `feature` β the lead tile, `{ kicker, title, body?, image | video +\n poster, href | onOpen, actionLabel }`, or any node. Two cells wide (and\n two tall from lg up). A video is muted and looping, has a pause, and\n never plays for a reader who asks for reduced motion.\n- `card` β `\"product\"` (default) or `\"promo\"`.\n- `columns` β how many from lg up: `2` Β· `3` Β· `4` (default 4 for\n products, 3 for tiles). A phone shows two product cards a row, one tile.\n- `currency` (`\"USD\"`) Β· `locale` β for prices and counts.\n- `countLabel(n)` β `\"12 products\"` by default. `addLabel` β the button's\n words.\n- `loading` β skeleton cards. `error` (a string) with `onRetry` β \"Try\n again\". `emptyTitle` Β· `emptyBody` β when there are no products; when\n filters hide them all it offers **Clear filters**.\n- `label` β what the grid is called to a screen reader.\n- `className` β layout around it (width, margin). Never colour.\n\n`ProductCard({ product, onAddToCart, onOpen, onFavorite, favorite })` and\n`PromoTile({ item, large })` are exported for a row of your own.\n\n## Example\n\n```jsx\n// surfaces/shop.jsx β server: load, hand over\nimport { loadRows } from \"../lib/data\";\nimport Shop from \"../components/Shop\";\n\nexport default async function ShopSurface() {\n const { rows, error } = await loadRows({ source: \"public.products\", orderBy: \"created_at desc\" });\n return <Shop products={rows} error={error} />;\n}\n```\n\n```jsx\n// components/Shop.jsx β client: the grid and the cart write\n\"use client\";\nimport { useRouter } from \"next/navigation\";\nimport { insertRow } from \"../lib/actions\";\nimport { ProductGrid } from \"./blocks/product-grid\";\n\nexport default function Shop({ products, error }) {\n const router = useRouter();\n return (\n <ProductGrid\n products={products}\n error={error}\n onRetry={() => router.refresh()}\n filters={[\n { key: \"category\", label: \"Category\", options: [\"Shirts\", \"Sweaters\", \"Jackets\"] },\n { key: \"size\", label: \"Size\", options: [\"S\", \"M\", \"L\", \"XL\"] },\n ]}\n sort\n onAddToCart={(line) =>\n insertRow({ source: \"public.cart_items\", values: { product_id: line.id, line_key: line.key, quantity: 1 }, detail: line.name })\n }\n label=\"Shop\"\n />\n );\n}\n```\n\nCollection tiles, and a lead picture beside four products:\n\n```jsx\n<ProductGrid\n card=\"promo\"\n products={[\n { id: \"new\", kicker: \"New\", title: \"The autumn edit\", image: \"/tiles/autumn.jpg\", href: \"/shop/new\" },\n { id: \"sale\", kicker: \"Sale\", title: \"Summer linen\", image: \"/tiles/linen.jpg\", href: \"/shop/sale\" },\n ]}\n label=\"Collections\"\n/>\n\n<ProductGrid\n products={skincare}\n feature={{ kicker: \"Skincare\", title: \"The morning routine\", image: \"/hero.jpg\", href: \"/routine\", actionLabel: \"Shop the routine\" }}\n onAddToCart={addToCart}\n/>\n```\n\n## What it will not do\n\n- Choose options on a card: that is the product page's job\n (`product-detail`); the card sends the customer there.\n- Page through a catalogue: pass a page of rows with `manual` and put the\n kit's `Pagination` under it.\n- Load a third-party player: a lead video is a plain `<video>` file, never\n a YouTube embed or script.\n- Colour itself: the tiles' words sit on the theme's background, so any\n picture reads in any theme.\n\nA catalogue it cannot be is your own component in `components/`, built\nfrom the kit (and `ProductCard`) β not an edit to this block. Its files\nare fixed: `npm run check` refuses a change, and `makersclaw add\nproduct-grid` restores them.\n\n## Credits\n\nPorted from Creative Tim UI (https://github.com/creativetimofficial/ui), MIT β a rewrite on the Makersclaw kit, none of its code, copy or pictures.\n"
|
|
36
|
+
}
|
|
37
|
+
],
|
|
38
|
+
"categories": [
|
|
39
|
+
"products",
|
|
40
|
+
"shop",
|
|
41
|
+
"store",
|
|
42
|
+
"ecommerce",
|
|
43
|
+
"commerce",
|
|
44
|
+
"catalogue",
|
|
45
|
+
"catalog",
|
|
46
|
+
"grid",
|
|
47
|
+
"cards",
|
|
48
|
+
"filter",
|
|
49
|
+
"facets",
|
|
50
|
+
"sort",
|
|
51
|
+
"add to cart",
|
|
52
|
+
"favourite",
|
|
53
|
+
"collection",
|
|
54
|
+
"promo",
|
|
55
|
+
"listing",
|
|
56
|
+
"plp"
|
|
57
|
+
],
|
|
58
|
+
"docs": "Installed to components/makersclaw/blocks/product-grid. Import: import { ProductGrid, ProductCard, PromoTile, productMatches } from \"@/components/makersclaw/blocks/product-grid\". Props and an example: components/makersclaw/blocks/product-grid/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
|
|
59
|
+
"meta": {
|
|
60
|
+
"makersclaw": {
|
|
61
|
+
"version": "0.3.0",
|
|
62
|
+
"useWhen": "A shop's catalogue, a collection page, related products under a product page, a row of promotions ('New', 'Sale'). Not for records to scan and act on in columns (data-table), one product's page (product-detail), or the cart (cart)."
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "record-form",
|
|
4
|
+
"type": "registry:block",
|
|
5
|
+
"title": "RecordForm",
|
|
6
|
+
"description": "Edit or create one record from a list of fields given as data (type, label, required, options, help, validation), grouped into titled sections β text, email, number, password, select, combobox, tags, radio, choice cards with prices, checkboxes, switch, date, time, rating, a photo and a row of images. It checks as you type, saves through an async onSave that answers { ok } or per-field errors shown under each field, and sits in a card, on a page (sections split title-left) or in a dialog.",
|
|
7
|
+
"registryDependencies": [
|
|
8
|
+
"https://cdn.jsdelivr.net/npm/makersclaw@0.3.0/r/core.json"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "registry/blocks/record-form/index.js",
|
|
14
|
+
"type": "registry:component",
|
|
15
|
+
"target": "@components/makersclaw/blocks/record-form/index.js",
|
|
16
|
+
"content": "// The RecordForm block β `makersclaw add record-form`. Fixed: compose it\n// through its props (README.md beside this file); a form it cannot be is\n// your own component in components/, built from the kit's Field parts.\nexport { RecordForm } from \"./record-form\";\n"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"path": "registry/blocks/record-form/record-form.jsx",
|
|
20
|
+
"type": "registry:component",
|
|
21
|
+
"target": "@components/makersclaw/blocks/record-form/record-form.jsx",
|
|
22
|
+
"content": "\"use client\";\n\nimport { useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport { Button, Card, Confirm, Empty, Notice, Separator, Sheet, Skeleton, cx } from \"../../ui\";\nimport { RecordField, WIDE } from \"./record-fields\";\nimport { checkAll, checkField } from \"./rules\";\n\n// RecordForm β edit (or create) one record from a list of fields: the\n// fields are data, grouped into titled sections, drawn with the kit's\n// parts. It holds the draft, checks it as the founder types, and saves it\n// through `onSave`, which answers { ok } β or { ok: false, error, errors }\n// with a message per field, shown under that field. In a card, on a page,\n// or in a dialog (a centred Sheet).\n\nconst SAVED_FOR = 3000;\n\nfunction sameValues(a, b) {\n try {\n return JSON.stringify(a) === JSON.stringify(b);\n } catch {\n return a === b;\n }\n}\n\n/** The fields' starting values: the record's, else each field's default. */\nfunction startFrom(fields, values) {\n const out = { ...(values ?? {}) };\n for (const f of fields) {\n if (out[f.name] !== undefined) continue;\n if (f.defaultValue !== undefined) out[f.name] = f.defaultValue;\n else if (f.type === \"checkboxes\" || f.type === \"images\" || f.type === \"tags\" || (f.type === \"combobox\" && f.multiple)) out[f.name] = [];\n else if (f.type === \"checkbox\" || f.type === \"switch\") out[f.name] = false;\n }\n return out;\n}\n\n/** What goes out: numbers as numbers, text trimmed, hidden fields dropped. */\nfunction outOf(fields, draft) {\n const out = { ...draft };\n for (const f of fields) {\n const v = out[f.name];\n if (f.type === \"number\" && typeof v === \"string\") out[f.name] = v.trim() === \"\" ? null : Number(v);\n else if (typeof v === \"string\" && f.trim !== false && f.type !== \"password\") out[f.name] = v.trim();\n }\n return out;\n}\n\nexport function RecordForm({\n sections: sectionsProp,\n fields: fieldsProp,\n values,\n onSave,\n onCancel,\n onDelete,\n onChange,\n title,\n description,\n container = \"card\",\n layout = \"stacked\",\n columns = 2,\n open = true,\n onClose,\n submitLabel = \"Save changes\",\n cancelLabel = \"Cancel\",\n savedLabel = \"Saved\",\n deleteLabel = \"Delete\",\n confirmDelete,\n requireChanges = true,\n loading = false,\n error: loadError,\n onRetry,\n emptyLabel = \"There is nothing to fill in here yet.\",\n className,\n}) {\n const formId = useId();\n const sections = useMemo(\n () => (sectionsProp?.length ? sectionsProp : fieldsProp?.length ? [{ fields: fieldsProp }] : []),\n [sectionsProp, fieldsProp],\n );\n const allFields = useMemo(() => sections.flatMap((s) => s.fields ?? []), [sections]);\n\n const [baseline, setBaseline] = useState(() => startFrom(allFields, values));\n const [draft, setDraft] = useState(baseline);\n const [touched, setTouched] = useState({});\n const [errors, setErrors] = useState({});\n const [formError, setFormError] = useState(null);\n const [saving, setSaving] = useState(false);\n const [savedAt, setSavedAt] = useState(null);\n const [asking, setAsking] = useState(false);\n const [deleting, setDeleting] = useState(false);\n const seen = useRef(values);\n const alive = useRef(true);\n useEffect(() => {\n alive.current = true;\n return () => {\n alive.current = false;\n };\n }, []);\n\n // A new record (a different `values`) starts the form over.\n useEffect(() => {\n if (sameValues(seen.current, values)) return;\n seen.current = values;\n const fresh = startFrom(allFields, values);\n setBaseline(fresh);\n setDraft(fresh);\n setTouched({});\n setErrors({});\n setFormError(null);\n }, [values, allFields]);\n\n // A dialog opens on the record as it stands, not on last time's edits.\n const wasOpen = useRef(open);\n useEffect(() => {\n if (open && !wasOpen.current) {\n setDraft(baseline);\n setTouched({});\n setErrors({});\n setFormError(null);\n setSavedAt(null);\n }\n wasOpen.current = open;\n }, [open, baseline]);\n\n useEffect(() => {\n if (!savedAt) return;\n const t = setTimeout(() => setSavedAt(null), SAVED_FOR);\n return () => clearTimeout(t);\n }, [savedAt]);\n\n const visible = (f) => (typeof f.when === \"function\" ? f.when(draft) : true);\n const shownFields = allFields.filter(visible);\n const dirty = !sameValues(draft, baseline);\n const idOf = (name) => `${formId}-${name}`;\n\n function set(name, value) {\n const next = { ...draft, [name]: value };\n setDraft(next);\n setSavedAt(null);\n onChange?.(next);\n // A field already marked wrong is checked again as it is fixed, and so\n // is a field that must match this one (a confirm).\n setErrors((e) => {\n const out = { ...e };\n for (const f of allFields) {\n const mine = f.name === name && (out[name] !== undefined || touched[name]);\n const pointer = f.matches === name && out[f.name] !== undefined;\n if (!mine && !pointer) continue;\n const m = checkField(f, next);\n if (m) out[f.name] = m;\n else delete out[f.name];\n }\n return out;\n });\n }\n\n function blur(name) {\n setTouched((t) => (t[name] ? t : { ...t, [name]: true }));\n const f = allFields.find((x) => x.name === name);\n if (!f) return;\n const m = checkField(f, draft);\n setErrors((e) => {\n if (m) return { ...e, [name]: m };\n if (e[name] === undefined) return e;\n const { [name]: _drop, ...rest } = e;\n return rest;\n });\n }\n\n function focusField(name) {\n if (typeof document === \"undefined\") return;\n const el = document.getElementById(idOf(name));\n if (!el) return;\n const target = el.matches(\"input,select,textarea,button\") ? el : el.querySelector(\"input:not([type=hidden]),select,textarea,button\");\n target?.focus();\n }\n\n function reset() {\n setDraft(baseline);\n setTouched({});\n setErrors({});\n setFormError(null);\n }\n\n async function submit(e) {\n e?.preventDefault?.();\n if (saving || !onSave) return;\n const found = checkAll(shownFields, draft);\n setErrors(found);\n const bad = shownFields.find((f) => found[f.name]);\n if (bad) {\n const n = Object.keys(found).length;\n setFormError(n === 1 ? \"One field needs attention.\" : `${n} fields need attention.`);\n focusField(bad.name);\n return;\n }\n setFormError(null);\n setSaving(true);\n // A field hidden by its `when` is not sent.\n const kept = { ...draft };\n for (const f of allFields) if (!shownFields.includes(f)) delete kept[f.name];\n const out = outOf(shownFields, kept);\n const changed = Object.keys(out).filter((k) => !sameValues(out[k], baseline[k]));\n let res;\n try {\n res = await onSave(out, { changed });\n } catch (err) {\n res = { ok: false, error: err?.message };\n }\n if (!alive.current) return;\n setSaving(false);\n if (res?.ok) {\n setBaseline(draft);\n setTouched({});\n setSavedAt(Date.now());\n if (container === \"dialog\") onClose?.();\n return;\n }\n const fieldErrors = res?.errors && typeof res.errors === \"object\" ? res.errors : {};\n setErrors(fieldErrors);\n const named = shownFields.find((f) => fieldErrors[f.name]);\n setFormError(res?.error || (named ? \"Some fields were not accepted. Fix them and save again.\" : \"The changes were not saved. Try again.\"));\n if (named) focusField(named.name);\n }\n\n async function remove() {\n setDeleting(true);\n let res;\n try {\n res = await onDelete();\n } catch (err) {\n res = { ok: false, error: err?.message };\n }\n if (!alive.current) return;\n setDeleting(false);\n setAsking(false);\n if (!res?.ok) setFormError(res?.error || \"It was not deleted. Try again.\");\n else if (container === \"dialog\") onClose?.();\n }\n\n function cancel() {\n reset();\n if (container === \"dialog\") onClose?.();\n onCancel?.();\n }\n\n const split = layout === \"split\" && container !== \"dialog\";\n const grid = columns >= 2 && container !== \"dialog\" ? \"grid gap-4 sm:grid-cols-2\" : \"grid gap-4\";\n const busy = saving || deleting;\n\n let body;\n if (loadError) {\n body = (\n <Notice tone=\"danger\" title=\"This could not be loaded\" action={onRetry ? <Button size=\"sm\" onClick={onRetry}>Try again</Button> : null}>\n {loadError}\n </Notice>\n );\n } else if (loading) {\n body = (\n <div aria-busy=\"true\" aria-label=\"Loading\" className={grid}>\n {[0, 1, 2, 3].map((i) => (\n <div key={i} className=\"grid gap-2\">\n <Skeleton className=\"w-24\" />\n <Skeleton shape=\"rect\" className=\"h-11 w-full\" />\n </div>\n ))}\n </div>\n );\n } else if (allFields.length === 0) {\n body = <Empty icon=\"file\" title={emptyLabel} />;\n } else {\n body = (\n <div className={cx(\"grid\", split ? \"gap-8\" : \"gap-6\")}>\n {sections.map((section, si) => {\n const fields = (section.fields ?? []).filter(visible);\n if (fields.length === 0) return null;\n const head =\n section.title || section.description ? (\n <div className=\"min-w-0\">\n {section.title ? <h3 className=\"text-sm font-semibold text-foreground\">{section.title}</h3> : null}\n {section.description ? <p className=\"mt-1 text-sm text-muted-foreground\">{section.description}</p> : null}\n </div>\n ) : null;\n const cols = section.columns ?? columns;\n const sectionGrid = cols >= 2 && container !== \"dialog\" ? \"grid gap-4 sm:grid-cols-2\" : \"grid gap-4\";\n return (\n <div key={section.key ?? section.title ?? si} className=\"grid gap-6\">\n {si > 0 && split ? <Separator /> : null}\n <section className={split ? \"grid gap-4 md:grid-cols-[minmax(0,1fr)_minmax(0,2fr)] md:gap-8\" : \"grid gap-4\"}>\n {head}\n <div className={sectionGrid}>\n {fields.map((f) => {\n const wide = f.width === \"full\" || (f.width !== \"half\" && WIDE.has(f.type ?? \"text\"));\n return (\n <div key={f.name} className={cx(\"min-w-0\", wide && \"sm:col-span-2\")}>\n <RecordField\n field={f}\n id={idOf(f.name)}\n value={draft[f.name]}\n values={draft}\n error={errors[f.name]}\n disabled={busy}\n onChange={(v) => set(f.name, v)}\n onBlur={() => blur(f.name)}\n />\n </div>\n );\n })}\n </div>\n </section>\n </div>\n );\n })}\n </div>\n );\n }\n\n const ready = !loading && !loadError && allFields.length > 0;\n const status = (\n <p aria-live=\"polite\" className=\"min-w-0 flex-1 text-sm text-muted-foreground\">\n {saving ? \"Savingβ¦\" : savedAt ? savedLabel : dirty ? \"Unsaved changes\" : \"\"}\n </p>\n );\n const buttons = ready ? (\n <>\n {onDelete && container !== \"dialog\" ? (\n <Button variant=\"danger\" onClick={() => setAsking(true)} disabled={busy} className=\"max-sm:order-last max-sm:w-full\">\n {deleteLabel}\n </Button>\n ) : null}\n {container !== \"dialog\" ? status : null}\n {onCancel || container === \"dialog\" || dirty ? (\n <Button variant=\"quiet\" onClick={cancel} disabled={busy}>\n {onCancel || container === \"dialog\" ? cancelLabel : \"Discard\"}\n </Button>\n ) : null}\n <Button type=\"submit\" form={formId} variant=\"primary\" loading={saving} disabled={deleting || (requireChanges && !dirty)}>\n {submitLabel}\n </Button>\n </>\n ) : null;\n\n const notice = formError ? (\n <Notice tone=\"danger\" onDismiss={() => setFormError(null)}>\n {formError}\n </Notice>\n ) : null;\n\n const form = (\n <form id={formId} noValidate onSubmit={submit} className=\"grid gap-6\" aria-busy={saving || undefined}>\n {notice}\n {body}\n </form>\n );\n\n const deleteDialog = onDelete ? (\n <Confirm\n open={asking}\n onClose={() => setAsking(false)}\n onConfirm={remove}\n busy={deleting}\n tone=\"danger\"\n title={confirmDelete?.title ?? `${deleteLabel}?`}\n body={confirmDelete?.body ?? \"This cannot be undone.\"}\n confirmLabel={confirmDelete?.confirmLabel ?? deleteLabel}\n consequences={confirmDelete?.consequences}\n requireText={confirmDelete?.requireText}\n acknowledge={confirmDelete?.acknowledge}\n />\n ) : null;\n\n if (container === \"dialog\") {\n return (\n <Sheet\n open={open}\n onClose={onClose}\n side=\"center\"\n title={title}\n description={description}\n className={className}\n footer={buttons}\n >\n {form}\n </Sheet>\n );\n }\n\n const footer = buttons ? <div className=\"flex flex-wrap items-center justify-end gap-2 border-t border-border pt-4\">{buttons}</div> : null;\n\n if (container === \"page\") {\n return (\n <div className={cx(\"grid gap-6\", className)}>\n {title || description ? (\n <header className=\"min-w-0\">\n {title ? <h2 className=\"text-xl font-semibold leading-tight\">{title}</h2> : null}\n {description ? <p className=\"mt-1 max-w-prose text-sm text-muted-foreground\">{description}</p> : null}\n </header>\n ) : null}\n {form}\n {footer}\n {deleteDialog}\n </div>\n );\n }\n\n return (\n <Card title={title} description={description} className={className}>\n <div className=\"grid gap-6\">\n {form}\n {footer}\n </div>\n {deleteDialog}\n </Card>\n );\n}\n"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"path": "registry/blocks/record-form/record-fields.jsx",
|
|
26
|
+
"type": "registry:component",
|
|
27
|
+
"target": "@components/makersclaw/blocks/record-form/record-fields.jsx",
|
|
28
|
+
"content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\nimport {\n Avatar,\n Badge,\n Checkbox,\n Combobox,\n DatePicker,\n Field,\n FileInput,\n IconButton,\n Image,\n Input,\n InputGroup,\n PasswordInput,\n RadioGroup,\n Rating,\n Select,\n Switch,\n Textarea,\n TimeInput,\n Button,\n} from \"../../ui\";\n\n// One field of a RecordForm, drawn from its description: the kit part its\n// `type` names, inside a Field (label, help, error). Every control is\n// controlled β the form holds the draft β and reports a plain value, never\n// an event. RecordForm is the only caller.\n\n// The types that take the whole row in a two-column grid unless the field\n// says `width: \"half\"`.\nexport const WIDE = new Set([\"textarea\", \"choice-cards\", \"checkboxes\", \"checkbox\", \"switch\", \"photo\", \"images\", \"radio\"]);\n\n// Types that carry their own label (a box or a switch names itself).\nconst SELF_LABELLED = new Set([\"checkbox\", \"switch\"]);\n\n/** An option list from strings or { value, label }. */\nfunction opts(options) {\n return (options ?? []).map((o) => (typeof o === \"object\" && o !== null ? o : { value: String(o), label: String(o) }));\n}\n\n/** A picture value β a URL string or { src, file } β to its src. */\nexport function srcOf(v) {\n return typeof v === \"string\" ? v : v?.src ?? null;\n}\n\nexport function RecordField({ field, id, value, values, error, disabled, onChange, onBlur }) {\n const type = field.type ?? \"text\";\n const help = field.help ?? field.hint;\n const describedBy = error ? `${id}-error` : help ? `${id}-hint` : undefined;\n const off = disabled || field.disabled;\n const invalid = Boolean(error);\n const common = { id, disabled: off, invalid, \"aria-describedby\": describedBy, onBlur };\n\n let control;\n switch (type) {\n case \"textarea\":\n control = (\n <Textarea\n {...common}\n name={field.name}\n value={value ?? \"\"}\n rows={field.rows ?? 4}\n placeholder={field.placeholder}\n maxLength={field.maxLength}\n required={field.required}\n readOnly={field.readOnly}\n onChange={(e) => onChange(e.target.value)}\n />\n );\n break;\n case \"password\":\n control = (\n <PasswordInput\n {...common}\n name={field.name}\n value={value ?? \"\"}\n placeholder={field.placeholder}\n autoComplete={field.autoComplete ?? \"new-password\"}\n required={field.required}\n onChange={(e) => onChange(e.target.value)}\n />\n );\n break;\n case \"select\":\n control = (\n <Select\n {...common}\n name={field.name}\n value={value ?? \"\"}\n required={field.required}\n onChange={(e) => onChange(e.target.value)}\n >\n <option value=\"\" disabled={field.required}>\n {field.placeholder ?? \"Chooseβ¦\"}\n </option>\n {opts(field.options).map((o) => (\n <option key={o.value} value={o.value} disabled={o.disabled}>\n {o.label}\n </option>\n ))}\n </Select>\n );\n break;\n case \"combobox\":\n case \"tags\": {\n const tags = type === \"tags\";\n const multiple = tags || Boolean(field.multiple);\n control = (\n <Combobox\n id={id}\n name={field.name}\n options={opts(field.options)}\n value={multiple ? value ?? [] : value ?? null}\n onValueChange={(v) => onChange(v)}\n multiple={multiple}\n creatable={tags || field.creatable}\n searchable={field.searchable ?? true}\n clearable={!multiple && !field.required}\n placeholder={field.placeholder}\n disabled={off}\n invalid={invalid}\n aria-describedby={describedBy}\n onOpenChange={(open) => {\n if (!open) onBlur?.();\n }}\n />\n );\n break;\n }\n case \"radio\":\n case \"choice-cards\":\n control = (\n <RadioGroup\n id={id}\n name={id}\n value={value ?? \"\"}\n onChange={(v) => {\n onChange(v);\n onBlur?.();\n }}\n options={opts(field.options).map((o) => ({ ...o, label: o.badge || o.price ? <ChoiceLabel option={o} /> : o.label }))}\n variant={type === \"choice-cards\" ? \"card\" : \"list\"}\n orientation={field.orientation ?? (type === \"choice-cards\" ? \"horizontal\" : \"vertical\")}\n disabled={off}\n invalid={invalid}\n required={field.required}\n />\n );\n break;\n case \"checkbox\":\n control = (\n <Checkbox\n id={id}\n name={field.name}\n checked={value === true}\n onChange={(e) => {\n onChange(e.target.checked);\n onBlur?.();\n }}\n disabled={off}\n aria-invalid={invalid || undefined}\n aria-describedby={describedBy}\n label={<Labelled field={field} />}\n />\n );\n break;\n case \"switch\":\n control = (\n <div className=\"flex items-start justify-between gap-4\">\n <div className=\"min-w-0 pt-2.5\">\n <label htmlFor={id} className=\"text-sm font-medium text-foreground\">\n {field.label}\n </label>\n {field.description ? <p className=\"text-sm text-muted-foreground\">{field.description}</p> : null}\n </div>\n <Switch\n id={id}\n checked={value === true}\n onChange={(e) => {\n onChange(e.target.checked);\n onBlur?.();\n }}\n disabled={off}\n />\n </div>\n );\n break;\n case \"checkboxes\": {\n const list = Array.isArray(value) ? value : [];\n return (\n <fieldset id={id} className=\"grid gap-1.5\" aria-describedby={describedBy} aria-invalid={invalid || undefined}>\n <legend className=\"text-sm font-medium text-foreground\">\n {field.label}\n {field.required ? <span aria-hidden=\"true\" className=\"text-destructive\"> *</span> : null}\n </legend>\n {field.description ? <p className=\"text-sm text-muted-foreground\">{field.description}</p> : null}\n <div className={field.orientation === \"horizontal\" ? \"flex flex-wrap gap-x-6\" : \"grid\"}>\n {opts(field.options).map((o) => (\n <Checkbox\n key={o.value}\n id={`${id}-${o.value}`}\n checked={list.includes(o.value)}\n disabled={off || o.disabled}\n onChange={(e) => {\n onChange(e.target.checked ? [...list, o.value] : list.filter((x) => x !== o.value));\n onBlur?.();\n }}\n label={o.description ? <Labelled field={o} /> : o.label}\n />\n ))}\n </div>\n <Note id={id} error={error} help={help} />\n </fieldset>\n );\n }\n case \"date\":\n control = (\n <DatePicker\n id={id}\n name={field.name}\n value={value || null}\n onChange={(v) => {\n onChange(v);\n onBlur?.();\n }}\n placeholder={field.placeholder ?? \"Pick a date\"}\n min={field.min}\n max={field.max}\n captionLayout={field.captionLayout}\n clearable={!field.required}\n disabled={off}\n invalid={invalid}\n aria-describedby={describedBy}\n />\n );\n break;\n case \"time\":\n control = (\n <TimeInput\n {...common}\n name={field.name}\n value={value ?? \"\"}\n min={field.min}\n max={field.max}\n step={field.step}\n required={field.required}\n onChange={(e) => onChange(e.target.value)}\n />\n );\n break;\n case \"rating\":\n control = (\n <Rating\n id={id}\n name={field.name}\n value={Number(value) || 0}\n max={field.max ?? 5}\n label={field.label}\n disabled={off}\n onChange={(n) => {\n onChange(n);\n onBlur?.();\n }}\n />\n );\n break;\n case \"photo\":\n control = <PhotoControl field={field} id={id} value={value} values={values} disabled={off} onChange={onChange} describedBy={describedBy} />;\n break;\n case \"images\":\n control = <ImagesControl field={field} id={id} value={value} disabled={off} onChange={onChange} describedBy={describedBy} />;\n break;\n case \"static\":\n control = (\n <p id={id} className=\"flex min-h-11 items-center text-sm text-foreground\">\n {field.format ? field.format(value, values) : value ?? \"β\"}\n </p>\n );\n break;\n default: {\n // text Β· email Β· url Β· tel Β· number, with an icon, a prefix or a suffix\n const inputType = [\"email\", \"url\", \"tel\", \"number\"].includes(type) ? type : \"text\";\n const props = {\n ...common,\n name: field.name,\n type: inputType,\n value: value ?? \"\",\n placeholder: field.placeholder,\n autoComplete: field.autoComplete,\n inputMode: type === \"number\" ? \"decimal\" : field.inputMode,\n min: field.min,\n max: field.max,\n step: field.step,\n maxLength: field.maxLength,\n required: field.required,\n readOnly: field.readOnly,\n onChange: (e) => onChange(e.target.value),\n };\n control =\n field.icon || field.prefix || field.suffix ? (\n <InputGroup {...props} icon={field.icon} prefix={field.prefix} suffix={field.suffix} />\n ) : (\n <Input {...props} />\n );\n }\n }\n\n if (SELF_LABELLED.has(type)) {\n return (\n <div className=\"grid gap-1\">\n {control}\n <Note id={id} error={error} help={type === \"checkbox\" ? null : help} />\n </div>\n );\n }\n return (\n <Field id={id} label={field.label} hint={help} error={error} required={field.required}>\n {control}\n </Field>\n );\n}\n\nfunction Note({ id, error, help }) {\n if (error) {\n return (\n <p id={`${id}-error`} role=\"alert\" className=\"text-xs text-destructive\">\n {error}\n </p>\n );\n }\n return help ? (\n <p id={`${id}-hint`} className=\"text-xs text-muted-foreground\">\n {help}\n </p>\n ) : null;\n}\n\n/** A box's label with its description under it. */\nfunction Labelled({ field }) {\n return (\n <span className=\"grid gap-0.5\">\n <span className=\"font-medium text-foreground\">\n {field.label}\n {field.required ? <span aria-hidden=\"true\" className=\"text-destructive\"> *</span> : null}\n </span>\n {field.description ?? field.help ? <span className=\"text-muted-foreground\">{field.description ?? field.help}</span> : null}\n </span>\n );\n}\n\n/** A choice card's title row: the name, a badge, a price at the end. */\nfunction ChoiceLabel({ option }) {\n return (\n <span className=\"flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1\">\n <span className=\"inline-flex flex-wrap items-center gap-2\">\n {option.label}\n {option.badge ? (\n <Badge tone=\"primary\" variant=\"soft\">\n {option.badge}\n </Badge>\n ) : null}\n </span>\n {option.price ? (\n <span data-nums className=\"text-sm font-normal text-foreground\">\n {option.price}\n {option.per ? <span className=\"text-muted-foreground\">{option.per}</span> : null}\n </span>\n ) : null}\n </span>\n );\n}\n\n/** Object URLs made for picked files, revoked when the form goes. */\nfunction useObjectUrls() {\n const made = useRef([]);\n useEffect(\n () => () => {\n made.current.forEach((u) => URL.revokeObjectURL(u));\n },\n [],\n );\n return (file) => {\n const u = URL.createObjectURL(file);\n made.current.push(u);\n return u;\n };\n}\n\n/** A profile picture: the current one, Upload, Remove. */\nfunction PhotoControl({ field, id, value, values, disabled, onChange, describedBy }) {\n const url = useObjectUrls();\n const src = srcOf(value);\n const who = field.nameFrom ? field.nameFrom(values) : field.label;\n return (\n <div id={id} className=\"flex flex-wrap items-center gap-4\" aria-describedby={describedBy}>\n <Avatar name={who} src={src ?? undefined} size=\"lg\" shape={field.shape ?? \"round\"} />\n <div className=\"flex flex-wrap gap-2\">\n <FileInput\n accept={field.accept ?? \"image/*\"}\n disabled={disabled}\n onFiles={([file]) => onChange({ src: url(file), file, name: file.name })}\n size=\"sm\"\n >\n {src ? field.replaceLabel ?? \"Upload new photo\" : field.uploadLabel ?? \"Upload photo\"}\n </FileInput>\n {src ? (\n <Button variant=\"quiet\" size=\"sm\" disabled={disabled} onClick={() => onChange(null)}>\n {field.removeLabel ?? \"Remove\"}\n </Button>\n ) : null}\n </div>\n </div>\n );\n}\n\n/** A row of pictures, each removable, and Add while there is room. */\nfunction ImagesControl({ field, id, value, disabled, onChange, describedBy }) {\n const url = useObjectUrls();\n const list = Array.isArray(value) ? value : [];\n const room = field.maxItems ? field.maxItems - list.length : Infinity;\n return (\n <div id={id} className=\"grid grid-cols-3 gap-3 sm:grid-cols-4\" aria-describedby={describedBy}>\n {list.map((img, i) => {\n const alt = (typeof img === \"object\" && (img.alt ?? img.name)) || `${field.label} ${i + 1}`;\n return (\n <div key={`${srcOf(img)}-${i}`} className=\"relative\">\n <Image src={srcOf(img)} alt={alt} ratio=\"square\" className=\"w-full border border-border\" />\n <IconButton\n icon=\"trash\"\n label={`Remove ${alt}`}\n size=\"sm\"\n variant=\"secondary\"\n disabled={disabled}\n className=\"absolute right-1 top-1\"\n onClick={() => onChange(list.filter((_, j) => j !== i))}\n />\n </div>\n );\n })}\n {room > 0 ? (\n <FileInput\n accept={field.accept ?? \"image/*\"}\n multiple\n disabled={disabled}\n variant=\"secondary\"\n icon=\"plus\"\n className=\"aspect-square h-auto w-full border-dashed\"\n onFiles={(files) => onChange([...list, ...files.slice(0, room).map((file) => ({ src: url(file), file, name: file.name }))])}\n >\n {field.addLabel ?? \"Add\"}\n </FileInput>\n ) : null}\n </div>\n );\n}\n"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"path": "registry/blocks/record-form/rules.js",
|
|
32
|
+
"type": "registry:component",
|
|
33
|
+
"target": "@components/makersclaw/blocks/record-form/rules.js",
|
|
34
|
+
"content": "// What a field's value must be, as data: required, the type's own shape\n// (an email, a web address, a number in range), a length, a pattern, a\n// match with another field, and the field's own `validate`. One message\n// per field, the first rule it breaks. Plain functions: no React.\n\nconst EMAIL = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\n\n/** Is there nothing in it? \"\", null, [], an unticked box, a 0 rating. */\nexport function isBlank(value, type) {\n if (value === undefined || value === null) return true;\n if (typeof value === \"string\") return value.trim() === \"\";\n if (Array.isArray(value)) return value.length === 0;\n if (type === \"checkbox\" || type === \"switch\") return value !== true;\n if (type === \"rating\") return !value;\n return false;\n}\n\n/** The first thing wrong with `field`'s value in `values`, or null. */\nexport function checkField(field, values) {\n const type = field.type ?? \"text\";\n const value = values[field.name];\n const label = field.label ?? field.name;\n if (isBlank(value, type)) {\n // An empty confirm is wrong only once there is something to confirm.\n if (field.matches && !isBlank(values[field.matches])) return field.matchMessage ?? \"These do not match.\";\n if (!field.required) return null;\n if (type === \"checkbox\" || type === \"switch\") return field.requiredMessage ?? \"This must be ticked to go on.\";\n if ([\"select\", \"combobox\", \"radio\", \"choice-cards\", \"date\", \"rating\"].includes(type)) return field.requiredMessage ?? `Choose ${lower(label)}.`;\n if (type === \"photo\" || type === \"images\") return field.requiredMessage ?? `Add ${lower(label)}.`;\n return field.requiredMessage ?? `${label} is required.`;\n }\n if (typeof value === \"string\") {\n const text = value.trim();\n if (type === \"email\" && !EMAIL.test(text)) return \"Enter an email like name@company.com.\";\n if (type === \"url\" && !isUrl(text)) return \"Enter a full address, starting with https://.\";\n if (type === \"number\") {\n const n = Number(text);\n if (!Number.isFinite(n)) return \"Enter a number.\";\n if (field.min !== undefined && n < Number(field.min)) return `Enter ${field.min} or more.`;\n if (field.max !== undefined && n > Number(field.max)) return `Enter ${field.max} or less.`;\n }\n if (field.minLength && text.length < field.minLength) return `Use at least ${field.minLength} characters.`;\n if (field.maxLength && text.length > field.maxLength) return `Use ${field.maxLength} characters at most.`;\n if (field.pattern && !new RegExp(`^(?:${field.pattern})$`).test(text)) return field.patternMessage ?? \"This is not in the expected form.\";\n }\n if (Array.isArray(value) && field.maxItems && value.length > field.maxItems) return `Choose ${field.maxItems} at most.`;\n if (field.matches && value !== values[field.matches]) return field.matchMessage ?? \"These do not match.\";\n if (typeof field.validate === \"function\") {\n try {\n const said = field.validate(value, values);\n if (typeof said === \"string\" && said) return said;\n } catch (e) {\n return e?.message ?? \"This value was refused.\";\n }\n }\n return null;\n}\n\n/** Every visible field's message, as { name: message }. */\nexport function checkAll(fields, values) {\n const errors = {};\n for (const f of fields) {\n const m = checkField(f, values);\n if (m) errors[f.name] = m;\n }\n return errors;\n}\n\nfunction isUrl(text) {\n try {\n const u = new URL(text);\n return u.protocol === \"http:\" || u.protocol === \"https:\";\n } catch {\n return false;\n }\n}\n\nfunction lower(label) {\n const s = String(label);\n return s.length > 1 && s[1] === s[1].toLowerCase() ? s[0].toLowerCase() + s.slice(1) : s;\n}\n"
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"path": "registry/blocks/record-form/README.md",
|
|
38
|
+
"type": "registry:file",
|
|
39
|
+
"target": "@components/makersclaw/blocks/record-form/README.md",
|
|
40
|
+
"content": "# RecordForm\n\nOne record's fields, described as data and drawn with the kit: a label, a\ncontrol, help under it, an error in its place. The fields are grouped into\ntitled sections. The form holds the draft, checks each field when you\nleave it (and again as you fix it), and saves the whole record through\n`onSave`. A save that fails keeps every edit on screen and says why β over\nthe form, and under each field the server refused.\n\n```jsx\nimport { RecordForm } from \"../components/blocks/record-form\"; // a Makersclaw app (\"./blocks/record-form\" from components/)\nimport { RecordForm } from \"@/components/makersclaw/blocks/record-form\"; // a shadcn project\n```\n\nIt is a client component: render it from a `\"use client\"` component, and\nload the record in the server surface (README Β§4).\n\n## The data\n\n`values` is the record: `{ name: \"β¦\", price: 189, tags: [\"gift\"] }`. Each\nfield reads and writes `values[field.name]`.\n\nA field:\n\n- `name` β the key in `values` (required, unique in the form).\n- `label` β what it is called.\n- `type` β how it is entered (default `\"text\"`):\n - `text` Β· `email` Β· `url` Β· `tel` Β· `number` β an Input. `icon`,\n `prefix` (\"$\") or `suffix` (\"kg\") make it an InputGroup. A number is\n sent as a number (empty β `null`).\n - `password` β a PasswordInput with show/hide.\n - `textarea` β `rows` (4).\n - `select` β a native Select over `options`; `placeholder` is the empty\n first choice.\n - `combobox` β a searchable Combobox over `options` (with `description`\n per option); `multiple: true` holds an array.\n - `tags` β a Combobox that holds an array and takes new words.\n - `radio` β a RadioGroup; `orientation: \"horizontal\"` for a short row.\n - `choice-cards` β the RadioGroup's card look: each option\n `{ value, label, description?, price?, per?, badge? }` β a plan, a\n package, a mode.\n - `checkbox` β one box that must be ticked when `required` (\"I agreeβ¦\");\n `description` under its label. Holds `true`/`false`.\n - `checkboxes` β several boxes over `options`; holds an array of values.\n - `switch` β an on/off with `description`; holds `true`/`false`.\n - `date` β a DatePicker; the value is `\"YYYY-MM-DD\"`. `min`, `max`,\n `captionLayout: \"dropdown\"` for a birth date.\n - `time` β a TimeInput; `\"HH:MM\"`.\n - `rating` β Rating stars as an input; `max` (5). Holds a whole number.\n - `photo` β an Avatar with Upload and Remove. `nameFrom(values)` gives\n the initials shown with no picture. Holds a URL string, or\n `{ src, file, name }` after a pick: upload `file` in `onSave`.\n - `images` β a row of thumbnails, each removable, and Add while under\n `maxItems`. Holds an array of URL strings or `{ src, file, name }`.\n - `static` β the value as text, not editable (\"Verified\"); `format(value,\n values)` to word it.\n- `required` β it must hold something (a ticked box, a chosen option).\n- `options` β for the choosing types: `[{ value, label, description?,\n disabled? }]` or plain strings.\n- `help` β the muted line under it (hidden while an error shows).\n- `placeholder`, `autoComplete`, `min`, `max`, `step`, `disabled`,\n `readOnly` β as the control's own.\n- `width` β `\"full\"` spans both columns; `\"half\"` keeps a wide type in one.\n Text areas, choices, boxes, switches, a photo and images are full by\n default.\n- `when(values)` β shown only while it returns true; a hidden field is not\n checked and not sent.\n- `defaultValue` β used when `values` has none.\n\nValidation, first broken rule wins, one message per field:\n\n- `required` (`requiredMessage` to word it);\n- the type's own shape: an email, a full `https://` address, a number\n within `min`/`max`;\n- `minLength`, `maxLength`;\n- `pattern` β a regular expression the whole text must match, with\n `patternMessage`;\n- `matches` β the name of another field this one must equal (a confirm),\n with `matchMessage`;\n- `maxItems` β for arrays (tags, images, checkboxes);\n- `validate(value, values)` β return a message, or nothing when fine.\n\n## Props\n\n- `fields` β the fields, one untitled section. Or:\n- `sections` β `[{ title?, description?, columns?, fields }]`: titled\n groups in order.\n- `values` β the record as it stands. Pass a different record and the form\n starts over from it (and forgets unsaved edits).\n- `onSave(values, { changed })` β save the record. `values` is every\n shown field, text trimmed, numbers as numbers; `changed` names the keys\n that differ from `values`. Return `{ ok: true }`, or `{ ok: false,\n error?, errors? }` where `errors` is `{ fieldName: \"message\" }` β each\n shows under its field and the first one is focused. A throw counts as\n failed. In a Makersclaw app it is one line over `updateRow`:\n `onSave={(v) => updateRow({ source: \"public.products\", id: p.id, patch: v })}`.\n- `container` β `\"card\"` (default: a Card with `title` and `description`)\n Β· `\"page\"` (no box: a page heading, then the sections) Β· `\"dialog\"` (a\n centred Sheet; on a phone a bottom sheet). A dialog needs `open` and\n `onClose`; it closes on a good save, and opens on the record as it\n stands, never on last time's edits.\n- `layout` β `\"stacked\"` (default: each section's title over its fields)\n or `\"split\"` (the title and description on the left, the fields on the\n right, a rule between sections, from 768px up). A dialog is always\n stacked.\n- `columns` β `2` (default: two columns from 640px) or `1`. A dialog is\n one column.\n- `title`, `description` β the card's, the page's, or the dialog's.\n- `onCancel()` β shows Cancel; it drops the edits first. Without it, a\n Discard button shows while there are edits.\n- `onDelete()` β shows a danger button at the start of the footer (not in\n a dialog) that asks first. Return `{ ok }` like `onSave`.\n- `confirmDelete` β the ask: `{ title?, body?, confirmLabel?,\n consequences?, requireText?, acknowledge? }` (the kit Confirm's).\n- `deleteLabel` β `\"Delete\"`.\n- `onChange(values)` β every edit, for a live preview beside the form.\n- `submitLabel` (`\"Save changes\"`) Β· `cancelLabel` (`\"Cancel\"`) Β·\n `savedLabel` (`\"Saved\"`, shown for three seconds after a good save).\n- `requireChanges` β `true` (default): Save waits for an edit. Pass\n `false` for a form that creates a record.\n- `loading` β skeleton fields while the record loads.\n- `error` β the record could not be loaded: a Notice in place of the\n fields; `onRetry` adds \"Try again\".\n- `emptyLabel` β shown when there are no fields.\n- `className` β layout around it (width, margin). Never colour.\n\n## Example\n\n```jsx\n// components/ProductEditor.jsx β client\n\"use client\";\nimport { updateRow, deleteRow } from \"../lib/actions\";\nimport { RecordForm } from \"./blocks/record-form\";\n\nconst FIELDS = [\n { name: \"name\", label: \"Product name\", required: true },\n { name: \"price\", label: \"Price\", type: \"number\", prefix: \"$\", min: 0, required: true },\n { name: \"category\", label: \"Category\", type: \"select\", options: [\"Outerwear\", \"Tops\", \"Footwear\"] },\n { name: \"launch\", label: \"Launch date\", type: \"date\" },\n { name: \"details\", label: \"Details\", type: \"textarea\" },\n];\n\nexport default function ProductEditor({ product }) {\n return (\n <RecordForm\n title=\"Edit product\"\n fields={FIELDS}\n values={product}\n onSave={(values) => updateRow({ source: \"public.products\", id: product.id, patch: values })}\n onDelete={() => deleteRow({ source: \"public.products\", id: product.id })}\n deleteLabel=\"Remove product\"\n />\n );\n}\n```\n\nIn a dialog, from a list:\n\n```jsx\nconst [editing, setEditing] = useState(null);\n\n<RecordForm\n container=\"dialog\"\n open={Boolean(editing)}\n onClose={() => setEditing(null)}\n title=\"Edit member\"\n values={editing ?? {}}\n sections={[\n { title: \"Profile\", fields: [{ name: \"photo\", label: \"Photo\", type: \"photo\" }, { name: \"name\", label: \"Name\", required: true }] },\n { title: \"Access\", fields: [{ name: \"role\", label: \"Role\", type: \"radio\", options: [\"Admin\", \"Member\", \"Viewer\"] }] },\n ]}\n onSave={async (values) => {\n const res = await updateRow({ source: \"public.members\", id: editing.id, patch: values });\n return res.ok ? res : { ok: false, errors: res.error?.includes(\"email\") ? { email: \"Already in use.\" } : undefined, error: res.error };\n }}\n/>\n```\n\n## What it will not do\n\n- Upload files: a picked photo or image arrives in `onSave` as\n `{ src, file }` β upload `file`, store the URL.\n- Save field by field: it saves the record with one button. Settings that\n each save when flipped are `settings-list`.\n- Sign in, sign up or change a password with rules beside it: that is\n `auth-form`.\n- Lay out a field by hand: a field it cannot draw is your own form in\n `components/`, built from the kit's `Field` and controls.\n\nA form it cannot be is your own component in `components/`, built from the\nkit β not an edit to this block. Its files are fixed: `npm run check`\nrefuses a change, and `makersclaw add record-form` restores them.\n\n## Credits\n\nShapes from Creative Tim UI (https://github.com/creativetimofficial/ui, MIT) and blocks.so (https://github.com/ephraimduncan/blocks, MIT); this is a rewrite on the Makersclaw kit.\n"
|
|
41
|
+
}
|
|
42
|
+
],
|
|
43
|
+
"categories": [
|
|
44
|
+
"form",
|
|
45
|
+
"record",
|
|
46
|
+
"edit",
|
|
47
|
+
"create",
|
|
48
|
+
"fields",
|
|
49
|
+
"sections",
|
|
50
|
+
"validation",
|
|
51
|
+
"save",
|
|
52
|
+
"profile",
|
|
53
|
+
"product",
|
|
54
|
+
"dialog",
|
|
55
|
+
"settings",
|
|
56
|
+
"crud",
|
|
57
|
+
"choice",
|
|
58
|
+
"plan"
|
|
59
|
+
],
|
|
60
|
+
"docs": "Installed to components/makersclaw/blocks/record-form. Import: import { RecordForm } from \"@/components/makersclaw/blocks/record-form\". Props and an example: components/makersclaw/blocks/record-form/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
|
|
61
|
+
"meta": {
|
|
62
|
+
"makersclaw": {
|
|
63
|
+
"version": "0.3.0",
|
|
64
|
+
"useWhen": "A screen edits one record's fields: a profile, a product, a team member, account details, a sign-up or application form, a settings page of fields with one Save. Not for a list of records (data-table), for toggles that each save on their own (settings-list), or for sign-in (auth-form)."
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "record-notes",
|
|
4
|
+
"type": "registry:block",
|
|
5
|
+
"title": "RecordNotes",
|
|
6
|
+
"description": "Dated rich-text notes on one record (a deal, a contact, a company), to sit beside record-form: a composer with @mentions and Cmd/Ctrl+Enter adds a note; the notes follow newest first (or oldest), grouped by day, each with author avatar, name and relative time; your own notes edit in place and delete after a confirm. Every save is an async callback answering { ok }; a failure keeps the words and says why where it happened.",
|
|
7
|
+
"registryDependencies": [
|
|
8
|
+
"https://cdn.jsdelivr.net/npm/makersclaw@0.3.0/r/core.json",
|
|
9
|
+
"https://cdn.jsdelivr.net/npm/makersclaw@0.3.0/r/comment-box.json",
|
|
10
|
+
"https://cdn.jsdelivr.net/npm/makersclaw@0.3.0/r/doc-editor.json"
|
|
11
|
+
],
|
|
12
|
+
"dependencies": [],
|
|
13
|
+
"files": [
|
|
14
|
+
{
|
|
15
|
+
"path": "registry/blocks/record-notes/index.js",
|
|
16
|
+
"type": "registry:component",
|
|
17
|
+
"target": "@components/makersclaw/blocks/record-notes/index.js",
|
|
18
|
+
"content": "// The RecordNotes block β `makersclaw add record-notes`. Fixed: compose it\n// through its props (README.md beside this file). Built on comment-box\n// (its composer, body and time) and doc-editor (the editor underneath).\nexport { RecordNotes } from \"./record-notes\";\n"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"path": "registry/blocks/record-notes/record-notes.jsx",
|
|
22
|
+
"type": "registry:component",
|
|
23
|
+
"target": "@components/makersclaw/blocks/record-notes/record-notes.jsx",
|
|
24
|
+
"content": "\"use client\";\n\n// RecordNotes β the dated notes kept on one record (a deal, a contact), to\n// sit beside record-form. A composer adds a note; the notes follow, grouped\n// by day, each with its author and time; your own notes have Edit (in\n// place, the same composer) and Delete (after a Confirm).\n//\n// Every save is an async callback answering { ok }. The block holds no\n// copy of the notes: the page adds, replaces or drops the note in `notes`\n// when its callback succeeds, and the block shows a failure where it\n// happened (under the composer, or on the note).\n//\n// Days (\"Today\", \"Yesterday\") are the reader's, known only in the\n// browser: the server draws the notes as one list, and the day headings\n// appear once mounted β never a mismatch between the two renders.\nimport { useEffect, useRef, useState } from \"react\";\nimport { Avatar, Badge, Card, Confirm, Menu, cx } from \"../../ui\";\nimport { CommentBody, CommentComposer, CommentTime } from \"../comment-box\";\nimport { docToText } from \"../doc-editor\";\n\nconst nameOf = (author) => (typeof author === \"string\" ? author : author?.name) || \"Someone\";\nconst srcOf = (author) => (typeof author === \"object\" && author ? author.src : undefined);\nconst idOf = (author) => (typeof author === \"object\" && author ? author.id : undefined);\n\nconst toMs = (v) => {\n if (v === undefined || v === null || v === \"\") return NaN;\n return (v instanceof Date ? v : new Date(v)).getTime();\n};\n\nfunction useMounted() {\n const [on, setOn] = useState(false);\n useEffect(() => setOn(true), []);\n return on;\n}\n\nfunction dayOf(ms) {\n const d = new Date(ms);\n d.setHours(0, 0, 0, 0);\n return d.getTime();\n}\n\nfunction dayLabel(day, now) {\n const today = dayOf(now);\n if (day === today) return \"Today\";\n if (day === today - 86_400_000) return \"Yesterday\";\n const d = new Date(day);\n const sameYear = d.getFullYear() === new Date(now).getFullYear();\n return new Intl.DateTimeFormat(undefined, { weekday: \"short\", day: \"numeric\", month: \"short\", ...(sameYear ? {} : { year: \"numeric\" }) }).format(d);\n}\n\n/** Split sorted notes into runs of one day each: [{ day, label, notes }]. */\nfunction byDay(notes, now) {\n const out = [];\n for (const n of notes) {\n const ms = toMs(n.createdAt);\n const day = Number.isNaN(ms) ? null : dayOf(ms);\n const last = out[out.length - 1];\n if (last && last.day === day) last.notes.push(n);\n else out.push({ day, label: day === null ? \"Undated\" : dayLabel(day, now), notes: [n] });\n }\n return out;\n}\n\nconst snippet = (doc, max = 120) => {\n const text = docToText(doc).replace(/\\s+/g, \" \").trim();\n return text.length > max ? `${text.slice(0, max - 1)}β¦` : text;\n};\n\nfunction Note({ note, own, editing, onStartEdit, onStopEdit, onEdit, onAskDelete, error, mentions }) {\n const name = nameOf(note.author);\n const self = useRef(null);\n const was = useRef(editing);\n useEffect(() => {\n // Back from editing: the caret was in the composer, which is gone.\n if (was.current && !editing) self.current?.focus();\n was.current = editing;\n }, [editing]);\n\n return (\n <li>\n <article ref={self} tabIndex={-1} aria-label={`Note by ${name}`} className=\"flex gap-3 rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring\">\n <Avatar name={name} src={srcOf(note.author)} size=\"sm\" className=\"mt-0.5 shrink-0\" />\n <div className=\"min-w-0 flex-1\">\n <div className=\"flex items-start gap-2\">\n <p className=\"flex min-w-0 flex-1 flex-wrap items-baseline gap-x-2 pt-1 text-sm\">\n <span className=\"font-medium\">{name}</span>\n <CommentTime date={note.createdAt} />\n {note.updatedAt || note.edited ? <span className=\"text-xs text-muted-foreground\">(edited)</span> : null}\n </p>\n {own && !editing ? (\n <Menu\n size=\"md\"\n label={`Note by ${name}, actions`}\n className=\"-me-2 -mt-2 shrink-0\"\n items={[\n onEdit && { label: \"Edit\", icon: \"pencil\", onSelect: onStartEdit },\n onAskDelete && { label: \"Delete\", icon: \"trash\", tone: \"danger\", onSelect: onAskDelete },\n ]}\n />\n ) : null}\n </div>\n {editing ? (\n <CommentComposer\n className=\"mt-2\"\n defaultValue={note.body}\n onSubmit={(doc) => onEdit(note, doc)}\n onCancel={onStopEdit}\n mentions={mentions}\n submitLabel=\"Save\"\n submitIcon={null}\n busyLabel=\"Savingβ¦\"\n failedText=\"Couldn't save the change. Your edit is still here; try again.\"\n label={`Edit note by ${name}`}\n clearOnSubmit={false}\n autoFocus\n />\n ) : (\n <CommentBody doc={note.body} className=\"mt-0.5\" />\n )}\n {error ? (\n <p role=\"alert\" className=\"mt-1.5 text-xs text-destructive\">\n {error}\n </p>\n ) : null}\n </div>\n </article>\n </li>\n );\n}\n\nexport function RecordNotes({\n notes,\n me,\n onAdd,\n onEdit,\n onDelete,\n canEdit,\n mentions,\n title = \"Notes\",\n description,\n placeholder = \"Add a noteβ¦\",\n addLabel = \"Add note\",\n emptyText = \"No notes yet. The first one starts the record's history.\",\n order = \"newest\",\n frame = \"card\",\n className,\n}) {\n const mounted = useMounted();\n const [editing, setEditing] = useState(null);\n const [asking, setAsking] = useState(null);\n const [deleting, setDeleting] = useState(false);\n const [errors, setErrors] = useState({});\n const listRef = useRef(null);\n\n const list = (Array.isArray(notes) ? notes : [])\n .map((n, i) => ({ n, i, ms: toMs(n.createdAt) }))\n .sort((a, b) => {\n const x = Number.isNaN(a.ms) ? -Infinity : a.ms;\n const y = Number.isNaN(b.ms) ? -Infinity : b.ms;\n return (order === \"oldest\" ? x - y : y - x) || a.i - b.i;\n })\n .map((x) => x.n);\n\n const keyOf = (n, i) => n.id ?? `note-${i}`;\n const isOwn = (n) => (canEdit ? !!canEdit(n) : n.mine === true || (me?.id !== undefined && idOf(n.author) === me.id));\n const setError = (key, msg) => setErrors((e) => ({ ...e, [key]: msg }));\n\n async function saveEdit(note, doc) {\n let res;\n try {\n res = await onEdit(note, doc);\n } catch (e) {\n res = { ok: false, error: e?.message };\n }\n if (res && res.ok === false) return res;\n setEditing(null);\n setError(note.id, null);\n return { ok: true };\n }\n\n async function confirmDelete() {\n const note = asking;\n if (!note) return;\n setDeleting(true);\n let res;\n try {\n res = await onDelete(note);\n } catch (e) {\n res = { ok: false, error: e?.message };\n }\n setDeleting(false);\n setAsking(null);\n if (res && res.ok === false) {\n setError(note.id, res.error || \"Couldn't delete this note. Try again.\");\n return;\n }\n listRef.current?.focus();\n }\n\n const composer = onAdd ? (\n <CommentComposer onSubmit={onAdd} author={me} mentions={mentions} placeholder={placeholder} submitLabel={addLabel} submitIcon=\"plus\" busyLabel=\"Addingβ¦\" failedText=\"Couldn't add the note. It is still here; try again.\" label=\"New note\" />\n ) : null;\n\n const renderNote = (n, i) => {\n const key = keyOf(n, i);\n return (\n <Note\n key={key}\n note={n}\n own={isOwn(n)}\n editing={editing === key}\n onStartEdit={() => {\n setError(n.id, null);\n setEditing(key);\n }}\n onStopEdit={() => setEditing(null)}\n onEdit={onEdit ? saveEdit : null}\n onAskDelete={onDelete ? () => setAsking(n) : null}\n error={errors[n.id]}\n mentions={mentions}\n />\n );\n };\n\n const groups = mounted ? byDay(list, Date.now()) : [{ day: \"all\", label: null, notes: list }];\n const body = (\n <div className=\"grid gap-5\">\n {order !== \"oldest\" ? composer : null}\n {list.length === 0 ? (\n <p className=\"text-sm text-muted-foreground\">{emptyText}</p>\n ) : (\n <div ref={listRef} tabIndex={-1} aria-label={`${title}, ${list.length}`} role=\"region\" className=\"grid gap-5 outline-none\">\n {groups.map((g) => (\n <section key={g.day ?? \"undated\"} aria-label={g.label ?? undefined} className=\"grid gap-3\">\n {g.label ? (\n <h3 className=\"flex items-center gap-3 text-xs font-medium text-muted-foreground\">\n <span className=\"shrink-0\">{g.label}</span>\n <span aria-hidden=\"true\" className=\"h-px flex-1 bg-border\" />\n </h3>\n ) : null}\n <ul className=\"grid gap-4\">{g.notes.map((n) => renderNote(n, list.indexOf(n)))}</ul>\n </section>\n ))}\n </div>\n )}\n {order === \"oldest\" ? composer : null}\n <Confirm\n open={!!asking}\n onClose={() => (deleting ? null : setAsking(null))}\n onConfirm={confirmDelete}\n title=\"Delete this note?\"\n body={asking ? `β${snippet(asking.body) || \"Empty note\"}β will be gone for everyone on this record.` : null}\n confirmLabel=\"Delete note\"\n tone=\"danger\"\n busy={deleting}\n />\n </div>\n );\n\n if (frame === \"none\") return <div className={className}>{body}</div>;\n return (\n <Card title={title} description={description} actions={list.length ? <Badge variant=\"soft\">{list.length}</Badge> : null} className={cx(\"min-w-0\", className)}>\n {body}\n </Card>\n );\n}\n"
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"path": "registry/blocks/record-notes/README.md",
|
|
28
|
+
"type": "registry:file",
|
|
29
|
+
"target": "@components/makersclaw/blocks/record-notes/README.md",
|
|
30
|
+
"content": "# RecordNotes\n\nThe notes kept on one record β a deal, a contact, a company β to sit\nbeside its `record-form`. A composer at the top adds a note (rich text,\n`@` to mention a teammate, Cmd/Ctrl+Enter or Add note). The notes follow,\nnewest first, grouped under \"Today\", \"Yesterday\", \"Mon 21 Sep\", each with\nits author, \"2 h ago\" and the body as written. Your own notes have a β―\nmenu: Edit opens the same composer in place, Delete asks first.\n\nEvery save goes out through an async callback that answers `{ ok }`. The\nblock keeps no copy of the notes: when a callback succeeds, the page puts\nthe change into `notes`. When one fails, the words stay and the block\nsays why β under the composer, or on the note.\n\n```jsx\nimport { RecordNotes } from \"../components/blocks/record-notes\"; // a Makersclaw app (\"./blocks/record-notes\" from components/)\nimport { RecordNotes } from \"@/components/makersclaw/blocks/record-notes\"; // a shadcn project\n```\n\nIt is a client component: render it from a `\"use client\"` component, and\nload the notes in the server surface (README Β§4). `makersclaw add\nrecord-notes` adds `comment-box` and `doc-editor` with it (and the editor's\nnpm packages: run `npm install` after).\n\n## The data\n\nA note in `notes`:\n\n```js\n{\n id: \"n-204\",\n author: { id: \"u-lena\", name: \"Lena Fischer\", src: \"https://β¦/lena.jpg\" },\n body: [ /* the doc onAdd gave you (BlockNote JSON) */ ], // or a plain string\n createdAt: \"2026-09-23T09:12:00Z\", // ISO string, Date or ms\n updatedAt: null, // set β \"(edited)\"\n}\n```\n\nOne table, a row per note: `record_id`, `author_id`, `body jsonb`,\n`body_text` (`docToText(doc)` from `doc-editor`, for search), `created_at`,\n`updated_at`. A note is the author's: by default only notes whose\n`author.id` is `me.id` (or that carry `mine: true`) get Edit and Delete.\n\n## Props\n\n- `notes` β required: the record's notes, any order (the block sorts by\n `createdAt`).\n- `me` β `{ id, name, src? }`, who is writing: their avatar is beside the\n composer, and their notes are editable.\n- `onAdd(doc)` β `async`, returns `{ ok: true }` or `{ ok: false, error? }`.\n Add the note to `notes` before answering ok. Without it there is no\n composer (a read-only log).\n- `onEdit(note, doc)` β `async`, `{ ok }`. Replace the note's body (and set\n `updatedAt`). Without it there is no Edit.\n- `onDelete(note)` β `async`, `{ ok }`, after the reader confirms. Drop the\n note from `notes`. Without it there is no Delete.\n- `canEdit(note)` β who may edit and delete which note, when \"the author\"\n is not the rule (an admin may delete any note). Default: the author.\n- `mentions` β `async (query) => [{ id, label, description? }]`: turns on\n `@` in the composer.\n- `title` β `\"Notes\"`; the card's heading and the list's name.\n- `description` β a line under the title.\n- `placeholder` β `\"Add a noteβ¦\"`.\n- `addLabel` β the composer's button, `\"Add note\"`.\n- `emptyText` β shown when there are no notes.\n- `order` β `\"newest\"` (default: composer on top, newest first) Β·\n `\"oldest\"` (a log read top to bottom, composer at the end).\n- `frame` β `\"card\"` (default: a Card with the title and a count) Β·\n `\"none\"` (just the composer and notes, for a tab or a sheet that has its\n own heading).\n- `className` β layout around it.\n\n## Example\n\n```jsx\n// components/DealNotes.jsx β client\n\"use client\";\nimport { useState } from \"react\";\nimport { deleteRow, insertRow, updateRow } from \"../lib/actions\";\nimport { RecordNotes } from \"./blocks/record-notes\";\nimport { docToText } from \"./blocks/doc-editor\";\n\nexport default function DealNotes({ deal, me, team, initial }) {\n const [notes, setNotes] = useState(initial);\n const findPeople = async (q) =>\n team.filter((p) => p.name.toLowerCase().includes(q.toLowerCase())).map((p) => ({ id: p.id, label: p.name }));\n\n async function add(doc) {\n const res = await insertRow({\n source: \"public.deal_notes\",\n values: { deal_id: deal.id, author_id: me.id, body: doc, body_text: docToText(doc) },\n });\n if (!res.ok) return res;\n setNotes((n) => [{ id: res.id, author: me, body: doc, createdAt: new Date() }, ...n]);\n return { ok: true };\n }\n\n async function edit(note, doc) {\n const updatedAt = new Date();\n const res = await updateRow({ source: \"public.deal_notes\", id: note.id, patch: { body: doc, body_text: docToText(doc), updated_at: updatedAt } });\n if (res.ok) setNotes((n) => n.map((x) => (x.id === note.id ? { ...x, body: doc, updatedAt } : x)));\n return res;\n }\n\n async function remove(note) {\n const res = await deleteRow({ source: \"public.deal_notes\", id: note.id });\n if (res.ok) setNotes((n) => n.filter((x) => x.id !== note.id));\n return res;\n }\n\n return <RecordNotes notes={notes} me={me} onAdd={add} onEdit={edit} onDelete={remove} mentions={findPeople} />;\n}\n```\n\nBeside the form, on a record page:\n\n```jsx\n<div className=\"grid gap-4 lg:grid-cols-[minmax(0,1fr)_24rem]\">\n <RecordForm β¦ />\n <DealNotes β¦ />\n</div>\n```\n\n## What it will not do\n\n- Pin, tag, react to or reply under a note: a note is a dated entry, not\n a thread (`comment-box` for a discussion).\n- Log activity by itself (emails sent, stage changes): pass those in as\n notes if you want them in the list, with no `onEdit`/`onDelete` rights.\n- Page or search the notes: it draws what it is given, sorted. Load the\n latest few dozen and a \"Show older\" of your own.\n- Collaborate live: two people editing one note is last-write-wins.\n- Notify anyone mentioned: read `docMentions(doc)` in `onAdd` and send it\n yourself.\n\nA notes panel it cannot be is your own component in `components/`,\nbuilt from `comment-box`'s `CommentComposer`, `CommentBody` and\n`CommentTime` β not an edit to this block. Its files are fixed: `npm run\ncheck` refuses a change, and `makersclaw add record-notes` restores them.\n"
|
|
31
|
+
}
|
|
32
|
+
],
|
|
33
|
+
"categories": [
|
|
34
|
+
"notes",
|
|
35
|
+
"record",
|
|
36
|
+
"deal",
|
|
37
|
+
"contact",
|
|
38
|
+
"crm",
|
|
39
|
+
"log",
|
|
40
|
+
"history",
|
|
41
|
+
"comment",
|
|
42
|
+
"mention",
|
|
43
|
+
"rich text",
|
|
44
|
+
"blocknote",
|
|
45
|
+
"edit",
|
|
46
|
+
"delete"
|
|
47
|
+
],
|
|
48
|
+
"docs": "Installed to components/makersclaw/blocks/record-notes. Import: import { RecordNotes } from \"@/components/makersclaw/blocks/record-notes\". Props and an example: components/makersclaw/blocks/record-notes/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
|
|
49
|
+
"meta": {
|
|
50
|
+
"makersclaw": {
|
|
51
|
+
"version": "0.3.0",
|
|
52
|
+
"useWhen": "A record page keeps a running log of notes: call notes on a deal, context on a contact, a history on a customer or a candidate. Not a discussion thread without edit and delete (comment-box), not one long document per record (doc-editor or doc-page), not an automatic activity feed."
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|