From 7685f313db51802e546fb1e37950ef9126cf54d7 Mon Sep 17 00:00:00 2001 From: Richard Tan Date: Fri, 9 Oct 2026 19:09:09 +0800 Subject: [PATCH 1/8] Add the Sole Vault blocks The Sole Vault marketplace and collection cards, their commands, the shared collection components, and the Commerce and Fulfilment cards that sat in the same realm closure, each with its Spec and examples. Co-Authored-By: Claude Opus 5.5 (1M context) --- Spec/collection-panel.json | 35 + Spec/collection-shell.json | 34 + Spec/collection-toolbar.json | 35 + .../AuthenticationRecord/cc-2022-88431.json | 46 + .../aj1-lost-and-found.json | 68 + .../CollectibleProduct/yeezy-350-zebra.json | 55 + .../aj1-lost-and-found-105.json | 70 + .../CollectionItem/yeezy-350-zebra-11.json | 75 + .../CompletionSet/travis-scott-nike.json | 38 + .../Listing/yeezy-350-zebra-11.json | 66 + .../Offer/yeezy-350-zebra-185.json | 42 + cards/collectibles/Order/sv-2026-1041.json | 70 + cards/collectibles/Payment/sim-pi-3xkq2r.json | 39 + .../SellerProfile/sneakerking.json | 27 + .../Shipment/sv-2026-1041-inbound.json | 37 + .../SoleVaultPerson/vaultkeeper.json | 27 + cards/collectibles/Spec/acquisition.json | 51 + .../Spec/add-to-collection-command.json | 35 + .../Spec/authenticate-item-command.json | 35 + .../Spec/authentication-record.json | 32 + .../Spec/calculate-completion-command.json | 35 + .../Spec/collectible-product.json | 37 + cards/collectibles/Spec/collection-item.json | 32 + cards/collectibles/Spec/completion-set.json | 32 + .../Spec/create-listing-command.json | 35 + .../collectibles/Spec/create-set-command.json | 35 + cards/collectibles/Spec/listing.json | 32 + .../Spec/mark-delivered-command.json | 35 + .../Spec/place-order-command.json | 35 + .../Spec/process-payment-command.json | 35 + .../Spec/refund-order-command.json | 35 + cards/collectibles/Spec/seller-profile.json | 35 + .../sole-vault-dispatch-shipment-command.json | 35 + cards/collectibles/Spec/sole-vault-offer.json | 32 + cards/collectibles/Spec/sole-vault-order.json | 32 + .../collectibles/Spec/sole-vault-payment.json | 32 + .../collectibles/Spec/sole-vault-person.json | 35 + .../Spec/sole-vault-shipment.json | 32 + cards/collectibles/acquisition.gts | 186 ++ cards/collectibles/authentication-record.gts | 1282 ++++++++++++ cards/collectibles/collectible-product.gts | 1063 ++++++++++ cards/collectibles/collection-item.gts | 1719 +++++++++++++++ .../commands/add-to-collection-command.gts | 125 ++ .../commands/authenticate-item-command.gts | 135 ++ .../commands/calculate-completion-command.gts | 88 + .../commands/create-listing-command.gts | 121 ++ .../commands/create-set-command.gts | 92 + .../commands/mark-delivered-command.gts | 126 ++ .../commands/place-order-command.gts | 149 ++ .../commands/process-payment-command.gts | 108 + .../commands/refund-order-command.gts | 102 + .../sole-vault-dispatch-shipment-command.gts | 114 + cards/collectibles/completion-set.gts | 1201 +++++++++++ cards/collectibles/listing.gts | 1245 +++++++++++ cards/collectibles/money-format.ts | 84 + cards/collectibles/seller-profile.gts | 482 +++++ cards/collectibles/sole-vault-offer.gts | 1409 +++++++++++++ cards/collectibles/sole-vault-order.gts | 1837 +++++++++++++++++ cards/collectibles/sole-vault-payment.gts | 1261 +++++++++++ cards/collectibles/sole-vault-person.gts | 861 ++++++++ cards/collectibles/sole-vault-shipment.gts | 1345 ++++++++++++ .../Price/enterprise-tier-annual.json | 38 + .../ProductReview/enterprise-tier-review.json | 33 + cards/commerce/Spec/price.json | 45 + cards/commerce/Spec/product-review.json | 45 + cards/commerce/price.gts | 275 +++ cards/commerce/product-review.gts | 217 ++ cards/fulfilment/PickList/pk-0031.json | 69 + cards/fulfilment/ProductReturn/rma-0089.json | 54 + .../fulfilment/Spec/fulfilment-pick-list.json | 33 + .../Spec/fulfilment-product-return.json | 33 + cards/fulfilment/pick-list.gts | 1030 +++++++++ cards/fulfilment/product-return.gts | 1112 ++++++++++ components/collection-panel.gts | 690 +++++++ components/collection-shell.gts | 552 +++++ components/collection-toolbar.gts | 241 +++ .../CollectionExample/sneaker-catalogue.json | 20 + components/example/collection-example.gts | 161 ++ .../Spec/authorship-metadata-field.json | 36 + .../Spec/publishing-metadata-field.json | 37 + .../provenance-vocabulary.gts | 240 +++ 81 files changed, 21624 insertions(+) create mode 100644 Spec/collection-panel.json create mode 100644 Spec/collection-shell.json create mode 100644 Spec/collection-toolbar.json create mode 100644 cards/collectibles/AuthenticationRecord/cc-2022-88431.json create mode 100644 cards/collectibles/CollectibleProduct/aj1-lost-and-found.json create mode 100644 cards/collectibles/CollectibleProduct/yeezy-350-zebra.json create mode 100644 cards/collectibles/CollectionItem/aj1-lost-and-found-105.json create mode 100644 cards/collectibles/CollectionItem/yeezy-350-zebra-11.json create mode 100644 cards/collectibles/CompletionSet/travis-scott-nike.json create mode 100644 cards/collectibles/Listing/yeezy-350-zebra-11.json create mode 100644 cards/collectibles/Offer/yeezy-350-zebra-185.json create mode 100644 cards/collectibles/Order/sv-2026-1041.json create mode 100644 cards/collectibles/Payment/sim-pi-3xkq2r.json create mode 100644 cards/collectibles/SellerProfile/sneakerking.json create mode 100644 cards/collectibles/Shipment/sv-2026-1041-inbound.json create mode 100644 cards/collectibles/SoleVaultPerson/vaultkeeper.json create mode 100644 cards/collectibles/Spec/acquisition.json create mode 100644 cards/collectibles/Spec/add-to-collection-command.json create mode 100644 cards/collectibles/Spec/authenticate-item-command.json create mode 100644 cards/collectibles/Spec/authentication-record.json create mode 100644 cards/collectibles/Spec/calculate-completion-command.json create mode 100644 cards/collectibles/Spec/collectible-product.json create mode 100644 cards/collectibles/Spec/collection-item.json create mode 100644 cards/collectibles/Spec/completion-set.json create mode 100644 cards/collectibles/Spec/create-listing-command.json create mode 100644 cards/collectibles/Spec/create-set-command.json create mode 100644 cards/collectibles/Spec/listing.json create mode 100644 cards/collectibles/Spec/mark-delivered-command.json create mode 100644 cards/collectibles/Spec/place-order-command.json create mode 100644 cards/collectibles/Spec/process-payment-command.json create mode 100644 cards/collectibles/Spec/refund-order-command.json create mode 100644 cards/collectibles/Spec/seller-profile.json create mode 100644 cards/collectibles/Spec/sole-vault-dispatch-shipment-command.json create mode 100644 cards/collectibles/Spec/sole-vault-offer.json create mode 100644 cards/collectibles/Spec/sole-vault-order.json create mode 100644 cards/collectibles/Spec/sole-vault-payment.json create mode 100644 cards/collectibles/Spec/sole-vault-person.json create mode 100644 cards/collectibles/Spec/sole-vault-shipment.json create mode 100644 cards/collectibles/acquisition.gts create mode 100644 cards/collectibles/authentication-record.gts create mode 100644 cards/collectibles/collectible-product.gts create mode 100644 cards/collectibles/collection-item.gts create mode 100644 cards/collectibles/commands/add-to-collection-command.gts create mode 100644 cards/collectibles/commands/authenticate-item-command.gts create mode 100644 cards/collectibles/commands/calculate-completion-command.gts create mode 100644 cards/collectibles/commands/create-listing-command.gts create mode 100644 cards/collectibles/commands/create-set-command.gts create mode 100644 cards/collectibles/commands/mark-delivered-command.gts create mode 100644 cards/collectibles/commands/place-order-command.gts create mode 100644 cards/collectibles/commands/process-payment-command.gts create mode 100644 cards/collectibles/commands/refund-order-command.gts create mode 100644 cards/collectibles/commands/sole-vault-dispatch-shipment-command.gts create mode 100644 cards/collectibles/completion-set.gts create mode 100644 cards/collectibles/listing.gts create mode 100644 cards/collectibles/money-format.ts create mode 100644 cards/collectibles/seller-profile.gts create mode 100644 cards/collectibles/sole-vault-offer.gts create mode 100644 cards/collectibles/sole-vault-order.gts create mode 100644 cards/collectibles/sole-vault-payment.gts create mode 100644 cards/collectibles/sole-vault-person.gts create mode 100644 cards/collectibles/sole-vault-shipment.gts create mode 100644 cards/commerce/Price/enterprise-tier-annual.json create mode 100644 cards/commerce/ProductReview/enterprise-tier-review.json create mode 100644 cards/commerce/Spec/price.json create mode 100644 cards/commerce/Spec/product-review.json create mode 100644 cards/commerce/price.gts create mode 100644 cards/commerce/product-review.gts create mode 100644 cards/fulfilment/PickList/pk-0031.json create mode 100644 cards/fulfilment/ProductReturn/rma-0089.json create mode 100644 cards/fulfilment/Spec/fulfilment-pick-list.json create mode 100644 cards/fulfilment/Spec/fulfilment-product-return.json create mode 100644 cards/fulfilment/pick-list.gts create mode 100644 cards/fulfilment/product-return.gts create mode 100644 components/collection-panel.gts create mode 100644 components/collection-shell.gts create mode 100644 components/collection-toolbar.gts create mode 100644 components/example/CollectionExample/sneaker-catalogue.json create mode 100644 components/example/collection-example.gts create mode 100644 fields/provenance-vocabulary/Spec/authorship-metadata-field.json create mode 100644 fields/provenance-vocabulary/Spec/publishing-metadata-field.json create mode 100644 fields/provenance-vocabulary/provenance-vocabulary.gts diff --git a/Spec/collection-panel.json b/Spec/collection-panel.json new file mode 100644 index 00000000..39af0fa6 --- /dev/null +++ b/Spec/collection-panel.json @@ -0,0 +1,35 @@ +{ + "data": { + "type": "card", + "attributes": { + "ref": { + "name": "CollectionPanel", + "module": "../components/collection-panel" + }, + "specType": "component", + "readMe": "## Collection Panel\n\nOne card type as a browsable list that runs its own query: a grid, a strip or a table over the same rows, a search box, and an Add button.\n\n```ts\nimport { CollectionPanel } from '@cardstack/catalog/components/collection-panel';\n\n\n```\n\nThe panel takes the card class, not a display name, and asks the realm for that type. `@sortBy` names the field to sort on; it defaults to `title`, and a field the card does not have returns no rows, so a card labelled by another field must pass it. `@extraFilter` is composed into the panel's one query, so the grid, the strip and the table always agree on which rows exist; `@rowFilter` narrows only the resolved table rows. `@allowCreate={{false}}` hides Add for collections whose rows must not be typed in by hand, such as an audit trail.\n\n### Non-goals\n\nIt does not edit rows inline or page results.", + "cardInfo": { + "name": "Collection Panel", + "summary": null, + "cardThumbnailURL": null, + "notes": null + }, + "containedExamples": [], + "cardTitle": "Collection Panel", + "cardDescription": "A self-querying list of one card type, as a grid, strip or table, with search and an Add action." + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + }, + "relationships": { + "linkedExamples.0": { + "links": { + "self": "../components/example/CollectionExample/sneaker-catalogue" + } + } + } + } +} diff --git a/Spec/collection-shell.json b/Spec/collection-shell.json new file mode 100644 index 00000000..82c956d1 --- /dev/null +++ b/Spec/collection-shell.json @@ -0,0 +1,34 @@ +{ + "data": { + "type": "card", + "attributes": { + "cardTitle": "Collection Shell", + "specType": "component", + "ref": { + "module": "../components/collection-shell", + "name": "CollectionShell" + }, + "readMe": "# Collection Shell\n\n## Summary\nThe academy's collection view: the boxel-search-with-filter concept (live query membership, one toolbar, count badges with All first, grid / list / table sharing one row set, `+ Add `, visibly clickable rows) in this kit's own clothes — a lens strip that leads with the count as a hero figure, a search field with the magnifier, segmented status badges and the `ViewSelector` on the right. Grid and list are prerendered fitted through `@context.searchResultsComponent` (click-to-open comes with the host overlay); the table is the shared Table block with a chevron cue and `viewCard` on the row. The wire filter and the client-side table filter are deliberate mirrors, so toggling views never changes which rows are present.\n\n## Usage\n```gts\n\n```\nConsumed by Academy Desk's Courses, Cohorts, Learners and Certificates tabs. The linked example is the desk instance.\n\n## Non-goals\nDoes not own the query (the host passes instances + a codeRef); no saved views, no column picker.", + "cardInfo": { + "name": "Collection Shell", + "summary": null, + "notes": null, + "cardThumbnailURL": null + }, + "cardDescription": "A collection page from resolved rows: a count, filter badges, grid, list or table views, and an Add action." + }, + "relationships": { + "linkedExamples.0": { + "links": { + "self": "../components/example/CollectionExample/sneaker-catalogue" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} diff --git a/Spec/collection-toolbar.json b/Spec/collection-toolbar.json new file mode 100644 index 00000000..83c6b832 --- /dev/null +++ b/Spec/collection-toolbar.json @@ -0,0 +1,35 @@ +{ + "data": { + "type": "card", + "attributes": { + "ref": { + "name": "CollectionToolbar", + "module": "../components/collection-toolbar" + }, + "specType": "component", + "readMe": "## Collection Toolbar\n\nThe bar above a collection: a search box, one row of filter badges with counts, and a right-hand slot for the view switcher and the Add action.\n\n```ts\nimport { CollectionToolbar } from '@cardstack/catalog/components/collection-toolbar';\n\n\n <:right>…\n\n```\n\nThe toolbar holds no state: the caller owns the search text and the active badge. `'all'` must be the first badge. A collection with more than one filter axis passes them in the `filters` block, which renders inside one Filters dropdown, and `@activeFilterCount` shows how many differ from their defaults.\n\n### Non-goals\n\nIt does not query or filter rows itself.", + "cardInfo": { + "name": "Collection Toolbar", + "summary": null, + "cardThumbnailURL": null, + "notes": null + }, + "containedExamples": [], + "cardTitle": "Collection Toolbar", + "cardDescription": "Search, filter badges with counts and a slot for view and Add controls, above any collection." + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + }, + "relationships": { + "linkedExamples.0": { + "links": { + "self": "../components/example/CollectionExample/sneaker-catalogue" + } + } + } + } +} diff --git a/cards/collectibles/AuthenticationRecord/cc-2022-88431.json b/cards/collectibles/AuthenticationRecord/cc-2022-88431.json new file mode 100644 index 00000000..14ccfb00 --- /dev/null +++ b/cards/collectibles/AuthenticationRecord/cc-2022-88431.json @@ -0,0 +1,46 @@ +{ + "data": { + "type": "card", + "attributes": { + "service": "Check Check", + "outcome": "passed", + "submittedAt": "2022-12-01", + "completedAt": "2022-12-04", + "certificateId": "CC-2022-88431", + "authenticator": "AuthTeam-Mike", + "authenticatorNotes": "Stitching density on the Swoosh matches the retail run. Box label font and factory code consistent with the November 2022 production. Cracked-leather finish is factory, not wear.", + "submittedPhotos": { + "images": [ + { + "url": "https://images.pexels.com/photos/2529148/pexels-photo-2529148.jpeg?auto=compress&cs=tinysrgb&w=800", + "sourceMode": "url" + } + ] + }, + "cardInfo": { + "name": "Check Check \u2014 AJ1 Lost & Found US 10.5", + "summary": "A passed legit check, with the certificate reference the collection item denormalizes.", + "notes": null, + "cardThumbnailURL": null + } + }, + "relationships": { + "item": { + "links": { + "self": "../CollectionItem/aj1-lost-and-found-105" + } + }, + "order": { + "links": { + "self": null + } + } + }, + "meta": { + "adoptsFrom": { + "module": "../authentication-record", + "name": "AuthenticationRecord" + } + } + } +} diff --git a/cards/collectibles/CollectibleProduct/aj1-lost-and-found.json b/cards/collectibles/CollectibleProduct/aj1-lost-and-found.json new file mode 100644 index 00000000..444b72f4 --- /dev/null +++ b/cards/collectibles/CollectibleProduct/aj1-lost-and-found.json @@ -0,0 +1,68 @@ +{ + "data": { + "type": "card", + "attributes": { + "sku": "DZ5485-612", + "colorway": "Chicago Lost & Found", + "releaseDate": "2022-11-19", + "retailPrice": { + "amount": 180, + "currency": { + "code": "USD" + } + }, + "productDescription": "The Air Jordan 1 \"Lost & Found\" reimagines the 1985 Chicago colorway as though a deadstock pair had been found in a forgotten stockroom \u2014 cracked leather, aged midsoles, and a shoebox printed to look water-damaged and taped shut.", + "availableVariants": [ + "7", + "7.5", + "8", + "8.5", + "9", + "9.5", + "10", + "10.5", + "11", + "11.5", + "12", + "13" + ], + "tags": ["retro", "chicago", "vintage"], + "images": { + "images": [ + { + "url": "https://images.pexels.com/photos/2529148/pexels-photo-2529148.jpeg?auto=compress&cs=tinysrgb&w=800", + "sourceMode": "url" + }, + { + "url": "https://images.pexels.com/photos/2529147/pexels-photo-2529147.jpeg?auto=compress&cs=tinysrgb&w=800", + "sourceMode": "url" + } + ] + }, + "cardInfo": { + "name": "Air Jordan 1 Retro High OG", + "summary": "The 2022 reissue of the original Chicago colourway, finished to look aged.", + "notes": null, + "cardThumbnailURL": null + } + }, + "relationships": { + "silhouette": { + "links": { + "self": null + } + }, + "brand": { + "links": { + "self": null + } + } + }, + "meta": { + "adoptsFrom": { + "module": "../collectible-product", + "name": "CollectibleProduct" + } + } + } +} diff --git a/cards/collectibles/CollectibleProduct/yeezy-350-zebra.json b/cards/collectibles/CollectibleProduct/yeezy-350-zebra.json new file mode 100644 index 00000000..97bf4556 --- /dev/null +++ b/cards/collectibles/CollectibleProduct/yeezy-350-zebra.json @@ -0,0 +1,55 @@ +{ + "data": { + "type": "card", + "attributes": { + "sku": "CP9654", + "colorway": "Zebra", + "releaseDate": "2017-02-25", + "retailPrice": { + "amount": 220, + "currency": { + "code": "USD" + } + }, + "productDescription": "Primeknit upper in white and core black with a red \"SPLY-350\" stripe, on a translucent Boost sole. Restocked repeatedly, which is why it trades close to retail despite the hype.", + "availableVariants": ["7", "8", "9", "10", "10.5", "11", "12", "13"], + "tags": ["yeezy", "boost", "general-release"], + "images": { + "images": [ + { + "url": "https://images.pexels.com/photos/4398341/pexels-photo-4398341.jpeg?auto=compress&cs=tinysrgb&w=800", + "sourceMode": "url" + }, + { + "url": "https://images.pexels.com/photos/7193625/pexels-photo-7193625.jpeg?auto=compress&cs=tinysrgb&w=800", + "sourceMode": "url" + } + ] + }, + "cardInfo": { + "name": "adidas Yeezy Boost 350 V2", + "summary": "A repeatedly-restocked GR \u2014 the counterexample to hype pricing.", + "notes": null, + "cardThumbnailURL": null + } + }, + "relationships": { + "silhouette": { + "links": { + "self": null + } + }, + "brand": { + "links": { + "self": null + } + } + }, + "meta": { + "adoptsFrom": { + "module": "../collectible-product", + "name": "CollectibleProduct" + } + } + } +} diff --git a/cards/collectibles/CollectionItem/aj1-lost-and-found-105.json b/cards/collectibles/CollectionItem/aj1-lost-and-found-105.json new file mode 100644 index 00000000..eb45fd39 --- /dev/null +++ b/cards/collectibles/CollectionItem/aj1-lost-and-found-105.json @@ -0,0 +1,70 @@ +{ + "data": { + "type": "card", + "attributes": { + "variant": "10.5", + "variantScale": "US", + "packaging": "OG All", + "condition": { + "code": "DS", + "valueRetention": 100, + "notes": null + }, + "acquisition": { + "price": { + "amount": 185, + "currency": { + "code": "USD" + } + }, + "acquiredOn": "2022-11-19", + "source": "Retail \u2014 SNKRS", + "reference": "C01234567890" + }, + "photos": { + "images": [ + { + "url": "https://images.pexels.com/photos/2529148/pexels-photo-2529148.jpeg?auto=compress&cs=tinysrgb&w=800", + "sourceMode": "url" + } + ] + }, + "verifiedOn": "2022-12-04", + "verifiedBy": "Check Check", + "verificationReference": "CC-2022-88431", + "listedAt": "2026-08-25", + "lastKnownValue": { + "amount": 310, + "currency": { + "code": "USD" + } + }, + "valuedOn": "2026-08-01", + "notes": "Hit on SNKRS release day. Keeping this one \u2014 the cracked-leather upper is the whole point of the release.", + "cardInfo": { + "name": "Air Jordan 1 Retro High OG \"Chicago Lost & Found\" \u2014 US 10.5", + "summary": "Deadstock, OG All, authenticated by Check Check. Acquired at retail on release day.", + "notes": null, + "cardThumbnailURL": null + } + }, + "relationships": { + "item": { + "links": { + "self": "../CollectibleProduct/aj1-lost-and-found" + } + }, + "owner": { + "links": { + "self": "../SellerProfile/sneakerking" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "../collection-item", + "name": "CollectionItem" + } + } + } +} diff --git a/cards/collectibles/CollectionItem/yeezy-350-zebra-11.json b/cards/collectibles/CollectionItem/yeezy-350-zebra-11.json new file mode 100644 index 00000000..4c31bc31 --- /dev/null +++ b/cards/collectibles/CollectionItem/yeezy-350-zebra-11.json @@ -0,0 +1,75 @@ +{ + "data": { + "type": "card", + "attributes": { + "variant": "11", + "variantScale": "US", + "packaging": "Replacement Box", + "condition": { + "code": "VNDS", + "valueRetention": 92, + "notes": "Tried on indoors once. No creasing, Boost still bright white." + }, + "acquisition": { + "price": { + "amount": 240, + "currency": { + "code": "USD" + } + }, + "acquiredOn": "2024-03-02", + "source": "Local meetup", + "reference": null + }, + "photos": { + "images": [ + { + "url": "https://images.pexels.com/photos/4398341/pexels-photo-4398341.jpeg?auto=compress&cs=tinysrgb&w=800", + "sourceMode": "url" + } + ] + }, + "verifiedOn": null, + "verifiedBy": null, + "verificationReference": null, + "listedAt": "2026-08-14", + "lastKnownValue": { + "amount": 215, + "currency": { + "code": "USD" + } + }, + "valuedOn": "2026-08-14", + "notes": "Bought above market at a meetup and it has drifted down since \u2014 the pair that taught me to check comps first. Listing it to clear space.", + "cardInfo": { + "name": "adidas Yeezy Boost 350 V2 \"Zebra\" \u2014 US 11", + "summary": "VNDS, replacement box, not authenticated, currently listed below what was paid.", + "notes": null, + "cardThumbnailURL": null + } + }, + "relationships": { + "cardInfo.theme": { + "links": { + "self": null + } + }, + "item": { + "links": { + "self": "../CollectibleProduct/yeezy-350-zebra" + } + }, + "owner": { + "links": { + "self": "../SellerProfile/sneakerking" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "../collection-item", + "name": "CollectionItem" + } + } + } +} diff --git a/cards/collectibles/CompletionSet/travis-scott-nike.json b/cards/collectibles/CompletionSet/travis-scott-nike.json new file mode 100644 index 00000000..2f2aa8e4 --- /dev/null +++ b/cards/collectibles/CompletionSet/travis-scott-nike.json @@ -0,0 +1,38 @@ +{ + "data": { + "type": "card", + "attributes": { + "goal": "Every Travis Scott collaboration with Nike and Jordan Brand. Retros count; sample pairs and friends-and-family do not \u2014 the line has to be a public release or the set never closes.", + "completionRule": "own-any", + "isPublic": true, + "coverImage": { + "url": "https://images.pexels.com/photos/3651824/pexels-photo-3651824.jpeg?auto=compress&cs=tinysrgb&w=800", + "sourceMode": "url" + }, + "cardInfo": { + "name": "All Travis Scott x Nike", + "summary": "A finite-but-growing collab set \u2014 the classic reason a completion goal exists.", + "notes": null, + "cardThumbnailURL": null + } + }, + "relationships": { + "cardInfo.theme": { + "links": { + "self": null + } + }, + "products.0": { + "links": { + "self": "../CollectibleProduct/aj1-lost-and-found" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "../completion-set", + "name": "CompletionSet" + } + } + } +} diff --git a/cards/collectibles/Listing/yeezy-350-zebra-11.json b/cards/collectibles/Listing/yeezy-350-zebra-11.json new file mode 100644 index 00000000..9e138529 --- /dev/null +++ b/cards/collectibles/Listing/yeezy-350-zebra-11.json @@ -0,0 +1,66 @@ +{ + "data": { + "type": "card", + "attributes": { + "price": { + "amount": 220, + "currency": { + "code": "USD" + } + }, + "shippingPrice": { + "amount": 15, + "currency": { + "code": "USD" + } + }, + "listingStatus": "active", + "shipsFrom": "Los Angeles, CA", + "listingNotes": "VNDS, tried on indoors once. Replacement box \u2014 the original was damaged in storage, disclosed up front. Ships double-boxed.", + "listedAt": "2026-08-14", + "soldAt": null, + "expiresAt": null, + "photos": { + "images": [ + { + "url": "https://images.pexels.com/photos/4398341/pexels-photo-4398341.jpeg?auto=compress&cs=tinysrgb&w=800", + "sourceMode": "url" + }, + { + "url": "https://images.pexels.com/photos/7193625/pexels-photo-7193625.jpeg?auto=compress&cs=tinysrgb&w=800", + "sourceMode": "url" + } + ] + }, + "cardInfo": { + "name": "Yeezy 350 V2 Zebra \u2014 US 11 \u2014 $220", + "summary": "An active listing whose CollectionItem carries the matching listedAt date.", + "notes": null, + "cardThumbnailURL": null + } + }, + "relationships": { + "collectionItem": { + "links": { + "self": "../CollectionItem/yeezy-350-zebra-11" + } + }, + "product": { + "links": { + "self": "../CollectibleProduct/yeezy-350-zebra" + } + }, + "seller": { + "links": { + "self": "../SellerProfile/sneakerking" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "../listing", + "name": "Listing" + } + } + } +} diff --git a/cards/collectibles/Offer/yeezy-350-zebra-185.json b/cards/collectibles/Offer/yeezy-350-zebra-185.json new file mode 100644 index 00000000..c03aa06c --- /dev/null +++ b/cards/collectibles/Offer/yeezy-350-zebra-185.json @@ -0,0 +1,42 @@ +{ + "data": { + "type": "card", + "attributes": { + "amount": { + "amount": 185, + "currency": { + "code": "USD" + } + }, + "offerStatus": "open", + "message": "Would you take 185 shipped? Ready to pay today.", + "offeredAt": "2026-08-24", + "respondedAt": null, + "expiresAt": "2026-08-31", + "cardInfo": { + "name": "Offer $185 \u2014 Yeezy 350 V2 Zebra", + "summary": "An open opening offer, 16% below the $220 asking price. `counterTo` is empty, which is what marks it as the head of a negotiation rather than a reply.", + "notes": null, + "cardThumbnailURL": null + } + }, + "relationships": { + "listing": { + "links": { + "self": "../Listing/yeezy-350-zebra-11" + } + }, + "offeredBy": { + "links": { + "self": "../SoleVaultPerson/vaultkeeper" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "../sole-vault-offer", + "name": "Offer" + } + } + } +} diff --git a/cards/collectibles/Order/sv-2026-1041.json b/cards/collectibles/Order/sv-2026-1041.json new file mode 100644 index 00000000..017f8568 --- /dev/null +++ b/cards/collectibles/Order/sv-2026-1041.json @@ -0,0 +1,70 @@ +{ + "data": { + "type": "card", + "attributes": { + "reference": "SV-2026-1041", + "price": { + "amount": 220, + "currency": { + "code": "USD" + } + }, + "shippingPrice": { + "amount": 15, + "currency": { + "code": "USD" + } + }, + "authFee": { + "amount": 10, + "currency": { + "code": "USD" + } + }, + "platformFee": { + "amount": 6.6, + "currency": { + "code": "USD" + } + }, + "orderStatus": "authenticating", + "placedAt": "2026-08-18", + "paidAt": "2026-08-18", + "shippedAt": "2026-08-19", + "authenticatedAt": null, + "deliveredAt": null, + "completedAt": null, + "trackingNumber": "1Z999AA10123456784", + "paymentReference": "sim_pi_3XkQ2r", + "cardInfo": { + "name": "SV-2026-1041 \u2014 Yeezy 350 V2 Zebra", + "summary": "An escrow order mid-flight: paid, shipped, now under authentication. Funds are held and the verdict decides whether it completes or refunds.", + "notes": null, + "cardThumbnailURL": null + } + }, + "relationships": { + "listing": { + "links": { + "self": "../Listing/yeezy-350-zebra-11" + } + }, + "seller": { + "links": { + "self": "../SellerProfile/sneakerking" + } + }, + "buyer": { + "links": { + "self": "../SoleVaultPerson/vaultkeeper" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "../sole-vault-order", + "name": "Order" + } + } + } +} diff --git a/cards/collectibles/Payment/sim-pi-3xkq2r.json b/cards/collectibles/Payment/sim-pi-3xkq2r.json new file mode 100644 index 00000000..12777c21 --- /dev/null +++ b/cards/collectibles/Payment/sim-pi-3xkq2r.json @@ -0,0 +1,39 @@ +{ + "data": { + "type": "card", + "attributes": { + "amount": { + "amount": 251.6, + "currency": { + "code": "USD" + } + }, + "paymentState": "authorized", + "direction": "charge", + "processorReference": "sim_pi_3XkQ2r", + "processor": "simulated", + "initiatedAt": "2026-08-18", + "settledAt": null, + "reason": null, + "cardInfo": { + "name": "Charge $251.60 \u2014 SV-2026-1041", + "summary": "The escrow hold: authorized, not captured. Funds are reserved with the processor and only settle once the buyer confirms delivery. Processor is 'simulated' \u2014 this realm records payments, it never moves money.", + "notes": null, + "cardThumbnailURL": null + } + }, + "relationships": { + "order": { + "links": { + "self": "../Order/sv-2026-1041" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "../sole-vault-payment", + "name": "Payment" + } + } + } +} diff --git a/cards/collectibles/SellerProfile/sneakerking.json b/cards/collectibles/SellerProfile/sneakerking.json new file mode 100644 index 00000000..2a643716 --- /dev/null +++ b/cards/collectibles/SellerProfile/sneakerking.json @@ -0,0 +1,27 @@ +{ + "data": { + "type": "card", + "attributes": { + "name": "Marcus Idowu", + "email": "marcus@solevault.example", + "phone": null, + "photo": { + "url": "https://images.pexels.com/photos/29811340/pexels-photo-29811340.jpeg?auto=compress&cs=tinysrgb&w=800", + "sourceMode": "url" + }, + "rating": 4, + "cardInfo": { + "name": "Marcus Idowu", + "summary": "The collector who owns both shipped example pairs, and the seller on the active listing.", + "notes": null, + "cardThumbnailURL": null + } + }, + "meta": { + "adoptsFrom": { + "module": "../seller-profile", + "name": "SellerProfile" + } + } + } +} diff --git a/cards/collectibles/Shipment/sv-2026-1041-inbound.json b/cards/collectibles/Shipment/sv-2026-1041-inbound.json new file mode 100644 index 00000000..2a516f4a --- /dev/null +++ b/cards/collectibles/Shipment/sv-2026-1041-inbound.json @@ -0,0 +1,37 @@ +{ + "data": { + "type": "card", + "attributes": { + "leg": "to-authenticator", + "shipmentStatus": "delivered", + "carrier": "UPS", + "trackingNumber": "1Z999AA10123456784", + "trackingUrl": "https://www.ups.com/track?tracknum=1Z999AA10123456784", + "shippedFrom": "Los Angeles, CA", + "shippedTo": "Sole Vault Authentication \u2014 Carlstadt, NJ", + "labelCreatedAt": "2026-08-18", + "shippedAt": "2026-08-19", + "deliveredAt": "2026-08-21", + "estimatedDelivery": "2026-08-21", + "cardInfo": { + "name": "To authenticator \u2014 SV-2026-1041", + "summary": "The inbound leg: seller to authentication centre, delivered. The outbound leg to the buyer does not exist yet, which is exactly why legs are separate cards rather than two tracking fields on one.", + "notes": null, + "cardThumbnailURL": null + } + }, + "relationships": { + "order": { + "links": { + "self": "../Order/sv-2026-1041" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "../sole-vault-shipment", + "name": "Shipment" + } + } + } +} diff --git a/cards/collectibles/SoleVaultPerson/vaultkeeper.json b/cards/collectibles/SoleVaultPerson/vaultkeeper.json new file mode 100644 index 00000000..22433d7e --- /dev/null +++ b/cards/collectibles/SoleVaultPerson/vaultkeeper.json @@ -0,0 +1,27 @@ +{ + "data": { + "type": "card", + "attributes": { + "name": "Mika Tan", + "email": "mika.tan@example.com", + "phone": "+1 415 555 0142", + "photo": { + "url": null, + "sourceMode": "url" + }, + "rating": null, + "cardInfo": { + "name": "Mika Tan", + "summary": "The buyer on order SV-2026-1041. Photo deliberately empty so the fitted initials fallback is exercised by real data.", + "notes": null, + "cardThumbnailURL": null + } + }, + "meta": { + "adoptsFrom": { + "module": "../sole-vault-person", + "name": "SoleVaultPerson" + } + } + } +} diff --git a/cards/collectibles/Spec/acquisition.json b/cards/collectibles/Spec/acquisition.json new file mode 100644 index 00000000..8b2a4999 --- /dev/null +++ b/cards/collectibles/Spec/acquisition.json @@ -0,0 +1,51 @@ +{ + "data": { + "type": "card", + "attributes": { + "ref": { + "name": "Acquisition", + "module": "../acquisition" + }, + "specType": "field", + "readMe": "## Acquisition\n\nHow an item came into someone's possession: what was paid, when, where from, and against which reference.\n\n```ts\nimport { Acquisition } from '@cardstack/catalog/cards/collectibles/acquisition';\n\n@field acquisition = contains(Acquisition);\n```\n\n`price` is the base `AmountWithCurrency`, so an acquisition carries its own currency. `source` is free text (Retail, a resale platform, a local meetup) because the useful list differs per collector; a consumer that wants a select supplies its own options. `reference` holds whatever proves the purchase: an order number, a receipt id or a lot number. The atom and embedded views format the price through the cluster's money formatter, so 11.50 never renders as 11.5.\n\n### Non-goals\n\nIt does not record what an item is worth now; that is a reporting rollup over active listings.", + "cardInfo": { + "name": "Acquisition Field", + "summary": null, + "cardThumbnailURL": null, + "notes": null + }, + "containedExamples": [ + { + "price": { + "amount": 240, + "currency": { + "code": "USD" + } + }, + "acquiredOn": "2024-03-02", + "source": "Local meetup", + "reference": null + }, + { + "price": { + "amount": 185, + "currency": { + "code": "USD" + } + }, + "acquiredOn": "2023-11-17", + "source": "Retail", + "reference": "NK-2023-884120" + } + ], + "cardTitle": "Acquisition Field", + "cardDescription": "What was paid for an item, when, from where and against which reference." + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} diff --git a/cards/collectibles/Spec/add-to-collection-command.json b/cards/collectibles/Spec/add-to-collection-command.json new file mode 100644 index 00000000..a54c47e7 --- /dev/null +++ b/cards/collectibles/Spec/add-to-collection-command.json @@ -0,0 +1,35 @@ +{ + "data": { + "type": "card", + "attributes": { + "cardTitle": "Add To Collection", + "cardDescription": "Catalogue a new owned copy of a product, with its condition and acquisition details.", + "specType": "command", + "ref": { + "module": "../commands/add-to-collection-command", + "name": "default" + }, + "readMe": "## Add To Collection\n\nCreates a new `CollectionItem` linking to a catalogued product, with a `ConditionGrade` and an `Acquisition` compound field populated from the call's inputs.\n\n### Example\n\n`linkedExamples` points at the command's natural target product: `CollectibleProduct/aj1-lost-and-found`.\n\n### The condition scale is the caller's vocabulary\n\n`condition-grade.gts`'s own header note is explicit that the field names no grading scale (DS/VNDS/9-10 is a sneaker convention, not the block's) — this command passes `conditionCode` straight through rather than hardcoding one.", + "containedExamples": [], + "cardInfo": { + "name": "Add To Collection", + "summary": null, + "notes": null, + "cardThumbnailURL": null + } + }, + "relationships": { + "linkedExamples.0": { + "links": { + "self": "../CollectibleProduct/aj1-lost-and-found" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} diff --git a/cards/collectibles/Spec/authenticate-item-command.json b/cards/collectibles/Spec/authenticate-item-command.json new file mode 100644 index 00000000..8d7e1be4 --- /dev/null +++ b/cards/collectibles/Spec/authenticate-item-command.json @@ -0,0 +1,35 @@ +{ + "data": { + "type": "card", + "attributes": { + "cardTitle": "Authenticate Item", + "cardDescription": "Record an authentication verdict for a collection item, and sync the outcome onto the item itself.", + "specType": "command", + "ref": { + "module": "../commands/authenticate-item-command", + "name": "default" + }, + "readMe": "## Authenticate Item\n\nCreates a full `AuthenticationRecord` (service, authenticator, certificate, notes) and — when the verdict is `passed` — syncs `verifiedOn`/`verifiedBy`/`verificationReference` onto the linked `CollectionItem`, so a prerendered fitted tile can show a verified badge without resolving a link.\n\n### Example\n\n`linkedExamples` points at the command's natural target: `CollectionItem/aj1-lost-and-found-105`.\n\n### A failed verdict does not auto-refund\n\n`authentication-record.gts`'s own header says failure \"in an escrow flow this refunds the buyer\" — that is a fact about what should happen next, not something this command does unasked. Refund is money moving, and `RefundOrderCommand` is its own explicit call.", + "containedExamples": [], + "cardInfo": { + "name": "Authenticate Item", + "summary": null, + "notes": null, + "cardThumbnailURL": null + } + }, + "relationships": { + "linkedExamples.0": { + "links": { + "self": "../CollectionItem/aj1-lost-and-found-105" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} diff --git a/cards/collectibles/Spec/authentication-record.json b/cards/collectibles/Spec/authentication-record.json new file mode 100644 index 00000000..eae67314 --- /dev/null +++ b/cards/collectibles/Spec/authentication-record.json @@ -0,0 +1,32 @@ +{ + "data": { + "type": "card", + "attributes": { + "cardTitle": "Authentication Record", + "cardDescription": "One legit-check on one item: who checked it, when, the verdict, and the certificate reference.", + "specType": "card", + "ref": { + "module": "../authentication-record", + "name": "AuthenticationRecord" + }, + "readMe": "## Authentication Record\n\nOne physical inspection of one owned item — the service, the verdict, the submitted photos, the authenticator's notes and the certificate id.\n\n### Why the outcome is duplicated onto CollectionItem\n\nThis card holds the full record — everything a dispute needs. `CollectionItem` keeps only `verifiedOn` / `verifiedBy` / `verificationReference`.\n\nThat duplication is deliberate and has exactly one justification: a collection grid is prerendered fitted, which cannot resolve a link to this record, so a tile could not show a verified badge otherwise. It is denormalization for a **rendering constraint**, not to dodge a query — which is the only case where copying a linked card's values onto another card is the right call.\n\n### turnaroundDays is safe to compute; a verdict is not safe to infer\n\nBoth dates live on this card, so turnaround is bounded and cheap. Note it reads the dates as calendar days rather than instants. What the card deliberately does not do is infer an outcome from the dates — a completed date with no verdict means the check finished without one being recorded, and inventing `passed` there would be the worst possible default.", + "containedExamples": [], + "cardInfo": { + "name": "Authentication Record" + } + }, + "relationships": { + "linkedExamples.0": { + "links": { + "self": "../AuthenticationRecord/cc-2022-88431" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} diff --git a/cards/collectibles/Spec/calculate-completion-command.json b/cards/collectibles/Spec/calculate-completion-command.json new file mode 100644 index 00000000..644532c4 --- /dev/null +++ b/cards/collectibles/Spec/calculate-completion-command.json @@ -0,0 +1,35 @@ +{ + "data": { + "type": "card", + "attributes": { + "cardTitle": "Calculate Completion", + "cardDescription": "Compute a completion set's owned/target counts and percentage from the current collection, without storing the result.", + "specType": "command", + "ref": { + "module": "../commands/calculate-completion-command", + "name": "default" + }, + "readMe": "## Calculate Completion\n\nWraps `completion-set.gts`'s exported pure function `computeCompletion` — a consuming app, this command and any other consumer all call the same implementation, so there is exactly one definition of \"80% complete\".\n\n### Example\n\n`linkedExamples` points at the command's natural target: `CompletionSet/travis-scott-nike`.\n\n### Read-only by design\n\n`CompletionSet` deliberately stores no `progress` field (see its own header note: the numerator is a rollup over `CollectionItem`s and storing it guarantees drift the moment a pair is bought or sold). This command computes and returns the number; it never patches the set.", + "containedExamples": [], + "cardInfo": { + "name": "Calculate Completion", + "summary": null, + "notes": null, + "cardThumbnailURL": null + } + }, + "relationships": { + "linkedExamples.0": { + "links": { + "self": "../CompletionSet/travis-scott-nike" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} diff --git a/cards/collectibles/Spec/collectible-product.json b/cards/collectibles/Spec/collectible-product.json new file mode 100644 index 00000000..b3b1adf0 --- /dev/null +++ b/cards/collectibles/Spec/collectible-product.json @@ -0,0 +1,37 @@ +{ + "data": { + "type": "card", + "attributes": { + "cardTitle": "Collectible Product", + "cardDescription": "A catalogue entry for a manufactured collectible: the thing itself, not anyone's copy of it.", + "specType": "card", + "ref": { + "module": "../collectible-product", + "name": "CollectibleProduct" + }, + "readMe": "## Collectible Product\n\nOne row per SKU, shared by everyone. `sku` is the definitive identifier, `colorway` distinguishes releases of the same silhouette, and `availableVariants` is the manufacturer's size run.\n\n### The split against CollectionItem\n\nThis card is the **catalogue**; `CollectionItem` is the **owned copy**. `retailPrice` lives here because it is a fact about the product. What someone paid lives on their `CollectionItem` because it is a fact about their purchase. Getting this wrong collapses two different questions — \"what did this cost at launch\" and \"what did I pay\" — into one field that answers neither reliably.\n\n### No lowestPrice, no listingCount\n\nBoth are computed. They are deliberately absent: each is an unbounded rollup over active listings, so a live realm query in a metrics component is the right home, not a cached field on the hot catalogue card. The app reads them via a reverse query on `Listing`.", + "containedExamples": [], + "cardInfo": { + "name": "Collectible Product" + } + }, + "relationships": { + "linkedExamples.0": { + "links": { + "self": "../CollectibleProduct/aj1-lost-and-found" + } + }, + "linkedExamples.1": { + "links": { + "self": "../CollectibleProduct/yeezy-350-zebra" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} diff --git a/cards/collectibles/Spec/collection-item.json b/cards/collectibles/Spec/collection-item.json new file mode 100644 index 00000000..07a795dd --- /dev/null +++ b/cards/collectibles/Spec/collection-item.json @@ -0,0 +1,32 @@ +{ + "data": { + "type": "card", + "attributes": { + "cardTitle": "Collection Item", + "cardDescription": "One physical thing someone owns, catalogued — with its condition, how it was acquired, its photos, and whether it has been authenticated.", + "specType": "card", + "ref": { + "module": "../collection-item", + "name": "CollectionItem" + }, + "readMe": "## Collection Item\n\nOne physical thing someone owns, catalogued. `item` links to the catalogued thing, `condition` grades it, `acquisition` records how it arrived, `photos` holds the owner's own photographs, and two event dates carry its authentication and for-sale state.\n\nThe block is **not about sneakers**. `item` is `linksTo(CardDef)`, so the same block catalogues a sneaker, a watch, a first edition or a record; a consumer narrows the link (`linksTo(() => Product)`) and supplies the grading vocabulary and the variant scale. Nothing in the module names a domain.\n\n### Two schema decisions, and why they are not preferences\n\n**Threshold flags are event facts.** `verified` and `forSale` are computed from `verifiedOn` and `listedAt`. Storing either boolean lets it drift from the thing that caused it — the classic failure is a card reading \"not for sale\" while an active listing points straight at it. Dates are also plain attributes, so they survive prerendered fitted views where a `linksTo` would not resolve.\n\n**There is no current-market-value field.** \"What is this worth right now\" is a reporting rollup over active listings: unbounded, and a live realm query belongs in a metrics component a consumer composes, never link-arrayed onto the hot card. `lastKnownValue` + `valuedOn` is the fitted-safe snapshot — an attribute a tile can render without running a query, and dated so a stale figure is visibly stale.\n\n### Reused, not rebuilt\n\n`photos` is the catalog's `multi-image-source` (a pasted URL **or** a file uploaded into the realm, resolving to `resolvedUrls`/`primaryUrl`), not a `containsMany(StringField)` of URLs. `acquisition.price` and `lastKnownValue` are base `amount-with-currency`. `condition.valueRetention` is base `percentage`. `photos.primaryUrl` is the fitted visual anchor.\n\n### `itemTitle` is denormalized on purpose\n\nA collection view draws its grid and list from **prerendered fitted HTML, which cannot resolve `linksTo`**. A tile that read `item.title` directly would render blank — and that is the tile's headline, so it would be blank on every tile in the grid. `itemTitle` is a computed field, so it resolves at index time, lands in the search document, and is a plain attribute by the time fitted renders. Do not \"simplify\" it back to a template-level link traversal. `item` also carries `searchable: true`, which is what lets a collection view filter and sort by the thing owned rather than only by this card's own attributes.\n\n### Non-goals\n\nThis card does not own the marketplace: a listing's price, its shipping terms and its status belong to the `Listing` block, and this card records only the date it was listed. It does not own the authentication *record* — the certificate, the authenticator's notes and the submitted photos belong to an `Authentication` block; this card keeps the outcome (`verifiedOn`, `verifiedBy`, `verificationReference`) so a fitted tile can show a badge without resolving a link. It does not own set completion: which items belong to which goal, and the progress arithmetic, belong to the `Set` block. And it does not own the catalogue — what the thing *is* (SKU, colourway, release date, silhouette) belongs to whatever card `item` points at.\n\n### Open questions for the team\n\n1. `variant` + `variantScale` is two strings where a compound field may be right. It exists because nothing reusable fit: catalog `quantity` is a bounded count, catalog `discrete-range-field` is a range (`startValue`/`endValue`/`interval`), and a single graded value on a named scale is neither. Whoever needs the second consumer should decide whether this graduates into its own field block.\n2. `item` is `linksTo(CardDef)`. That is what keeps the block domain-neutral, and it is also the weakest typing in the module. A consumer narrowing it is the intended path; if two consumers narrow it the same way, that shared supertype is the real answer.\n\n### Example status — read before trusting the Examples section\n\nThe linked example is fully populated across every attribute, but two parts of it are deliberately incomplete and are **named blocked follow-ups, not oversights**:\n\n- `photos.url` is empty. Photo URLs were not written because they could not be verified in this session (no network access), and writing stock-photo IDs from memory is confabulation — a documented earlier pass produced eight plausible IDs of which seven showed the wrong subject. The field is wired; populating it needs a search, a contact sheet read as an image, and a `curl` check per URL.\n\nUntil the photo lands, every fitted tier falls back to its glyph, and the reuse of `multi-image-source` has not yet landed in the data.", + "containedExamples": [], + "cardInfo": { + "name": "Collection Item" + } + }, + "relationships": { + "linkedExamples.0": { + "links": { + "self": "../CollectionItem/aj1-lost-and-found-105" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} diff --git a/cards/collectibles/Spec/completion-set.json b/cards/collectibles/Spec/completion-set.json new file mode 100644 index 00000000..22cd949a --- /dev/null +++ b/cards/collectibles/Spec/completion-set.json @@ -0,0 +1,32 @@ +{ + "data": { + "type": "card", + "attributes": { + "cardTitle": "Completion Set", + "cardDescription": "A curated collecting goal, plus the arithmetic for how far along it is.", + "specType": "card", + "ref": { + "module": "../completion-set", + "name": "CompletionSet" + }, + "readMe": "## Completion Set\n\n\"All Travis Scott x Nike\", \"every Chicago colourway\", \"my top 10 grails\". A goal, the products that would complete it, and the rule for what counts.\n\n### Membership is DATA, progress is DERIVED\n\nThese are opposite answers to a question that looks the same, so both are spelled out.\n\n`products` is a **curated `linksToMany`, not a query**. A collecting set is not \"everything matching a rule\" — sneaker sets are emergent and personal, and the user hand-confirms the list. Ordering is meaningful. That is curated membership, which is what links are for.\n\n`progress` / `ownedCount` / `missingProducts` are **not fields and are not stored**. They are a rollup over the viewer's `CollectionItem`s intersected with this list — unbounded, owner-dependent, and *different for every viewer of a public set*. Storing them guarantees drift the moment a pair is bought or sold, and would make a public set show the author's progress to a stranger.\n\n### One implementation of the arithmetic\n\nThe module exports `computeCompletion(setProductIds, ownedProductIds)`, pure and store-free, so an app, a command and a test all call the same function instead of three subtly different ones. It guards the two cases that produce a wrong number rather than an error: an empty set (percent must be 0, not NaN) and dead link slots (a deleted product leaves `undefined` in the array while `.length` still counts it, which would inflate the denominator).\n\n`targetCount` **is** a stored computed field, and the asymmetry is the point: the denominator depends only on this card's own link array, while the numerator depends on someone's collection. Only one of the two is safe to cache here.\n\n### Why the embedded view has no progress bar\n\nAn embedded `CompletionSet` has no owner context — whose collection would it measure? A bar drawn there would be asserting a number this card cannot know. The consuming app draws progress, because it knows who is looking.\n\n### Non-goals\n\nThis card does not own the catalogue (`CollectibleProduct`), does not know what anyone owns (`CollectionItem`), and does not source the missing pairs — \"find missing\" is a query a consuming app runs against `Listing` using `missingProductIds`.", + "containedExamples": [], + "cardInfo": { + "name": "Completion Set" + } + }, + "relationships": { + "linkedExamples.0": { + "links": { + "self": "../CompletionSet/travis-scott-nike" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} diff --git a/cards/collectibles/Spec/create-listing-command.json b/cards/collectibles/Spec/create-listing-command.json new file mode 100644 index 00000000..297fcef3 --- /dev/null +++ b/cards/collectibles/Spec/create-listing-command.json @@ -0,0 +1,35 @@ +{ + "data": { + "type": "card", + "attributes": { + "cardTitle": "Create Listing", + "cardDescription": "List an owned collection item for sale at a given price, denormalizing the seller and product from it.", + "specType": "command", + "ref": { + "module": "../commands/create-listing-command", + "name": "default" + }, + "readMe": "## Create Listing\n\nMarks an owned `CollectionItem` for sale by writing `listedAt` (the event-fact `forSale` derives from), and creates the `Listing` itself with price/shipping and the seller/product denormalized from the item.\n\n### Example\n\n`linkedExamples` points at the command's natural target: `CollectionItem/aj1-lost-and-found-105`.\n\n### `forSale` is an event fact, not a boolean this command sets directly\n\nWrites `listedAt`, never a stored flag — matching `collection-item.gts`'s own rule for `verified`: a threshold flag is a computed read of the date something happened, so it cannot drift from the listing that caused it.", + "containedExamples": [], + "cardInfo": { + "name": "Create Listing", + "summary": null, + "notes": null, + "cardThumbnailURL": null + } + }, + "relationships": { + "linkedExamples.0": { + "links": { + "self": "../CollectionItem/aj1-lost-and-found-105" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} diff --git a/cards/collectibles/Spec/create-set-command.json b/cards/collectibles/Spec/create-set-command.json new file mode 100644 index 00000000..b8ceb804 --- /dev/null +++ b/cards/collectibles/Spec/create-set-command.json @@ -0,0 +1,35 @@ +{ + "data": { + "type": "card", + "attributes": { + "cardTitle": "Create Set", + "cardDescription": "Create a completion goal from a curated list of products, with a name and a completion rule.", + "specType": "command", + "ref": { + "module": "../commands/create-set-command", + "name": "default" + }, + "readMe": "## Create Set\n\nResolves a caller-supplied list of product ids into real `linksToMany` entries on a new `CompletionSet`.\n\n### Example\n\nThis command has no single natural input card — it takes a list of product ids, not one target instance. `linkedExamples` links `CompletionSet/travis-scott-nike` illustratively, as the SHAPE of what this command produces, not as a literal input it was run against; noted here rather than left for the next reader to assume otherwise.\n\n### Membership is curated, not a query\n\nMatches `completion-set.gts`'s own header note: sneaker sets are emergent and personal, so `products` is a hand-confirmed `linksToMany`, never a saved filter. `targetCount` is a computed field on the card itself — this command never writes it directly.", + "containedExamples": [], + "cardInfo": { + "name": "Create Set", + "summary": null, + "notes": null, + "cardThumbnailURL": null + } + }, + "relationships": { + "linkedExamples.0": { + "links": { + "self": "../CompletionSet/travis-scott-nike" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} diff --git a/cards/collectibles/Spec/listing.json b/cards/collectibles/Spec/listing.json new file mode 100644 index 00000000..282f97fb --- /dev/null +++ b/cards/collectibles/Spec/listing.json @@ -0,0 +1,32 @@ +{ + "data": { + "type": "card", + "attributes": { + "cardTitle": "Listing", + "cardDescription": "One seller offering one owned item for sale.", + "specType": "card", + "ref": { + "module": "../listing", + "name": "Listing" + }, + "readMe": "## Listing\n\nOne seller, one owned copy, one asking price. Links to the `CollectionItem` being sold and — denormalized — to the `CollectibleProduct`, so \"every listing for this SKU\" is one query rather than a walk through the collection item.\n\n### Every field a tile needs is an attribute\n\nA marketplace's entire UI is grids of listings. Grids are prerendered fitted. **Prerendered fitted cannot resolve `linksTo`.** So `productTitle`, `variantLabel`, `conditionCode` and `verified` are computed fields — resolved at index time and stored in the search document — rather than template reads through `collectionItem.*`.\n\nWithout that, every tile in the marketplace renders blank. It is the same denormalization rule as `CollectionItem.itemTitle`, and this is the most expensive place to get it wrong because listing grids are a marketplace's busiest surface.\n\n### Lifecycle as event facts\n\n`listedAt` / `soldAt` / `expiresAt` are dates; any boolean is derived from them. Same rule as `CollectionItem` — a stored flag drifts from the thing that caused it, and dates are attributes so they survive prerendering.\n\nNote the invariant this creates with `CollectionItem`: an active `Listing` and its item's `listedAt` must agree. The shipped example is consistent on purpose — `Listing/yeezy-350-zebra-11` and `CollectionItem/yeezy-350-zebra-11` carry the same date. The first-created example deliberately does **not** have a listing, because its item is marked kept.\n\n### One source for status colour\n\n`LISTING_STATUSES` is exported from the module that owns the enum, so blocks and app consume one map instead of each inventing a class list.", + "containedExamples": [], + "cardInfo": { + "name": "Listing" + } + }, + "relationships": { + "linkedExamples.0": { + "links": { + "self": "../Listing/yeezy-350-zebra-11" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} diff --git a/cards/collectibles/Spec/mark-delivered-command.json b/cards/collectibles/Spec/mark-delivered-command.json new file mode 100644 index 00000000..e2c9bf7e --- /dev/null +++ b/cards/collectibles/Spec/mark-delivered-command.json @@ -0,0 +1,35 @@ +{ + "data": { + "type": "card", + "attributes": { + "cardTitle": "Mark Delivered", + "cardDescription": "Record a shipment leg as delivered and advance the order status appropriately for that leg.", + "specType": "command", + "ref": { + "module": "../commands/mark-delivered-command", + "name": "default" + }, + "readMe": "## Mark Delivered\n\nLeg-aware: a `seller-to-auth` delivery advances `Order.orderStatus` to `authenticating` (item arrived at the auth centre, not yet with the buyer); an `auth-to-buyer` delivery advances it to `delivered`.\n\n### Example\n\n`linkedExamples` points at the command's natural target: `Shipment/sv-2026-1041-inbound`.\n\n### Honest gap, stated in the command's own header comment\n\n`delivered → completed` (the buyer's own confirmation that releases funds to the seller) has no command — no command marks it. Left as an explicit, undone edge rather than silently folded into a carrier-scan event.", + "containedExamples": [], + "cardInfo": { + "name": "Mark Delivered", + "summary": null, + "notes": null, + "cardThumbnailURL": null + } + }, + "relationships": { + "linkedExamples.0": { + "links": { + "self": "../Shipment/sv-2026-1041-inbound" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} diff --git a/cards/collectibles/Spec/place-order-command.json b/cards/collectibles/Spec/place-order-command.json new file mode 100644 index 00000000..8fb050f4 --- /dev/null +++ b/cards/collectibles/Spec/place-order-command.json @@ -0,0 +1,35 @@ +{ + "data": { + "type": "card", + "attributes": { + "cardTitle": "Place Order", + "cardDescription": "Create an order from an active listing, snapshotting price and fees, and mark the listing sold.", + "specType": "command", + "ref": { + "module": "../commands/place-order-command", + "name": "default" + }, + "readMe": "## Place Order\n\na buyer commits to a listing. Snapshots price, shipping price, a flat authentication fee, and a percentage platform fee onto a new `Order`, then flips the source `Listing`'s status to `sold`.\n\n### Example, per block-factory's command-example rule\n\nA command has no `containedExamples` slot of its own — `linkedExamples` points at the command's natural TARGET instance instead: the active `Listing` this command would act on (`Listing/yeezy-350-zebra-11`).\n\n### Fee snapshot, not a re-derived rate\n\n`authFee` ($10 flat) and `platformFee` (3% of price) are computed once at order-creation time and stored — never re-derived from a rate later, so a fee-schedule change never rewrites the receipt of a past order.\n\n### Non-goals\n\nDoes not charge anything (that is `ProcessPayment`'s job) and does not create a `Shipment` (that is `DispatchShipment`'s job).", + "containedExamples": [], + "cardInfo": { + "name": "Place Order", + "summary": null, + "notes": null, + "cardThumbnailURL": null + } + }, + "relationships": { + "linkedExamples.0": { + "links": { + "self": "../Listing/yeezy-350-zebra-11" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} diff --git a/cards/collectibles/Spec/process-payment-command.json b/cards/collectibles/Spec/process-payment-command.json new file mode 100644 index 00000000..45d38d73 --- /dev/null +++ b/cards/collectibles/Spec/process-payment-command.json @@ -0,0 +1,35 @@ +{ + "data": { + "type": "card", + "attributes": { + "cardTitle": "Process Payment", + "cardDescription": "Charge the order total and move the order from pending-payment to paid, recording a Payment.", + "specType": "command", + "ref": { + "module": "../commands/process-payment-command", + "name": "default" + }, + "readMe": "## Process Payment\n\nCharges `Order.total` (an escrow simulation — see `order.gts`'s own header note that a realm cannot hold real funds), records a `captured` `Payment`, and advances `Order.orderStatus` to `paid`.\n\n### Example\n\n`linkedExamples` points at the command's natural target: `Order/sv-2026-1041`.\n\n### State-machine guarded\n\nChecks `canOrderTransition(order.orderStatus, 'paid')` before writing anything — an order not in `pending-payment` is refused, not silently re-charged.\n\n### Non-goals\n\nDoes not model a real processor's `pending → authorized → captured` round trip; the effect (order moves to `paid`, funds now held) is what matters here, not a faithful gateway simulation.", + "containedExamples": [], + "cardInfo": { + "name": "Process Payment", + "summary": null, + "notes": null, + "cardThumbnailURL": null + } + }, + "relationships": { + "linkedExamples.0": { + "links": { + "self": "../Order/sv-2026-1041" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} diff --git a/cards/collectibles/Spec/refund-order-command.json b/cards/collectibles/Spec/refund-order-command.json new file mode 100644 index 00000000..f9b35fa7 --- /dev/null +++ b/cards/collectibles/Spec/refund-order-command.json @@ -0,0 +1,35 @@ +{ + "data": { + "type": "card", + "attributes": { + "cardTitle": "Refund Order", + "cardDescription": "Record a refund payment for the order total and move the order to refunded.", + "specType": "command", + "ref": { + "module": "../commands/refund-order-command", + "name": "default" + }, + "readMe": "## Refund Order\n\nRecords a new `Payment` with `direction: 'refund'` for the order's total, and moves `Order.orderStatus` to `refunded`.\n\n### Example\n\n`linkedExamples` points at the command's natural target: `Order/sv-2026-1041`.\n\n### A refund is a new ledger row, never an edit\n\nMatches `PaymentStateField`'s own transition graph (`captured` only ever leads to `refunded`, one-way) and `payment.gts`'s `signedAmount`, which renders a refund with a minus sign rather than mutating the original charge's amount. Two rows, not one row that changed its mind.\n\n### Non-goals\n\nDoes not decide WHEN a refund is warranted (a failed authentication, a dispute resolution) — that judgment belongs to the caller (e.g. `AuthenticateItem`'s own header note says a failed verdict does not auto-refund).", + "containedExamples": [], + "cardInfo": { + "name": "Refund Order", + "summary": null, + "notes": null, + "cardThumbnailURL": null + } + }, + "relationships": { + "linkedExamples.0": { + "links": { + "self": "../Order/sv-2026-1041" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} diff --git a/cards/collectibles/Spec/seller-profile.json b/cards/collectibles/Spec/seller-profile.json new file mode 100644 index 00000000..fa80c19a --- /dev/null +++ b/cards/collectibles/Spec/seller-profile.json @@ -0,0 +1,35 @@ +{ + "data": { + "type": "card", + "attributes": { + "cardTitle": "Seller Profile", + "cardDescription": "A genuinely reusable marketplace-seller building block — identity plus a numeric rating — not a Sole Vault–specific concept. Extends the app's own SoleVaultPerson (itself a subclass of the pulled matrix Person block) so it inherits the dark-luxury views for free.", + "specType": "card", + "ref": { + "module": "../seller-profile", + "name": "SellerProfile" + }, + "readMe": "### Why a subclass of SoleVaultPerson, not of PersonBase directly\n\nThe reusable *essence* of this block is the schema (person identity + rating), not these specific views. Extending `SoleVaultPerson` inherits the dark-luxury isolated/edit/fitted templates for free rather than duplicating ~400 lines of near-identical markup; a consuming app in a different visual world would override the formats the same way any card override works — same as `SoleVaultPerson` itself overrides `PersonBase`'s plain `embedded`.\n\n### Split from SoleVaultPerson on purpose\n\n`rating` has no meaning on a buyer. `Order.buyer`, `Offer.offeredBy` and `CollectionItem.owner` narrow to plain `SoleVaultPerson`; only `Order.seller` and `Listing.seller` narrow to `SellerProfile`. One real-world person can still be linked from both kinds of field on different cards — a `SellerProfile` instance satisfies a `SoleVaultPerson`-typed link too, since it is one via inheritance.\n\n### Non-goals\n\nThis card does not carry review text, only the aggregate numeric rating (written reviews are a separate concept). It does not distinguish a permanent seller identity from a one-off listing — a person becomes \"a seller\" simply by being the target of a `seller` link, not by any flag on this card itself.", + "containedExamples": [], + "cardInfo": { + "name": "Seller Profile", + "summary": null, + "notes": null, + "cardThumbnailURL": null + } + }, + "relationships": { + "linkedExamples.0": { + "links": { + "self": "../SellerProfile/sneakerking" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} diff --git a/cards/collectibles/Spec/sole-vault-dispatch-shipment-command.json b/cards/collectibles/Spec/sole-vault-dispatch-shipment-command.json new file mode 100644 index 00000000..a0b2f46d --- /dev/null +++ b/cards/collectibles/Spec/sole-vault-dispatch-shipment-command.json @@ -0,0 +1,35 @@ +{ + "data": { + "type": "card", + "attributes": { + "cardTitle": "Sole Vault Dispatch Shipment", + "cardDescription": "Create a shipment leg for a paid order, and move the order from paid to shipped.", + "specType": "command", + "ref": { + "module": "../commands/sole-vault-dispatch-shipment-command", + "name": "default" + }, + "readMe": "## Dispatch Shipment\n\nCreates a `Shipment` (`label-created`) linked to the order and moves `Order.orderStatus` to `shipped`.\n\n### Example\n\n`linkedExamples` points at the command's natural target: `Order/sv-2026-1041`.\n\n### Leg-aware\n\n`leg` defaults to `seller-to-auth` — the first of two shipment legs (seller → authentication centre → buyer). See Mark Delivered for how the second leg (`auth-to-buyer`) is distinguished when it lands.", + "containedExamples": [], + "cardInfo": { + "name": "Sole Vault Dispatch Shipment", + "summary": null, + "notes": null, + "cardThumbnailURL": null + } + }, + "relationships": { + "linkedExamples.0": { + "links": { + "self": "../Order/sv-2026-1041" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} diff --git a/cards/collectibles/Spec/sole-vault-offer.json b/cards/collectibles/Spec/sole-vault-offer.json new file mode 100644 index 00000000..57c5a0fe --- /dev/null +++ b/cards/collectibles/Spec/sole-vault-offer.json @@ -0,0 +1,32 @@ +{ + "data": { + "type": "card", + "attributes": { + "cardTitle": "Sole Vault Offer", + "cardDescription": "A price proposed below asking, and the negotiation chain that follows.", + "specType": "card", + "ref": { + "module": "../sole-vault-offer", + "name": "Offer" + }, + "readMe": "## Offer1 Offer System — offer, counter, negotiate.\n\n### One card per offer, not one per negotiation\n\nA counter-offer is a **new** Offer linked back through `counterTo`, never an edit to the original. Two reasons, and the first is the one that matters:\n\n1. An offer is a commitment someone made at a moment. Overwriting `amount` when the seller counters destroys the record of what the buyer actually offered — which is precisely what both parties argue about later.\n2. It makes the thread queryable. \"Every offer on this listing\" and \"the chain that produced this deal\" are both plain queries over links, with no array of embedded revisions to walk.\n\n`counterTo` is the negotiation's spine: null means an opening offer, and following the chain backwards reconstructs the whole exchange in order.\n\n### `open` is the only non-terminal state\n\nThat is the whole design. Every other value closes the card permanently:\n\n- A counter does not reopen this offer — it terminates it and starts a new one, so `countered` leads nowhere.\n- `accepted` leads nowhere because the next step is an **Order**, a different card with its own graph. Letting an accepted offer be un-accepted would orphan that order.\n- `declined` / `withdrawn` / `expired` are terminal for the same reason: re-offering is a new offer, and the history of the refused one is evidence.\n\n### The discount is computed at render time, never stored\n\n`listingPrice` is denormalized onto the card so a prerendered tile can show offer-vs-asking without resolving the listing — but the **difference** is a render-time getter. A stored discount goes stale the moment the seller re-prices, and a confident \"16% below asking\" that is no longer true is worse than no badge at all. The embedded row also drops that ratio a step in size and weight, so the money figure stays the thing read first.\n\n### Roles swap down the chain\n\nThe field is `offeredBy`, not `buyer`: on a counter it is the seller who is offering.", + "containedExamples": [], + "cardInfo": { + "name": "Sole Vault Offer" + } + }, + "relationships": { + "linkedExamples.0": { + "links": { + "self": "../Offer/yeezy-350-zebra-185" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} diff --git a/cards/collectibles/Spec/sole-vault-order.json b/cards/collectibles/Spec/sole-vault-order.json new file mode 100644 index 00000000..dd519cd3 --- /dev/null +++ b/cards/collectibles/Spec/sole-vault-order.json @@ -0,0 +1,32 @@ +{ + "data": { + "type": "card", + "attributes": { + "cardTitle": "Sole Vault Order", + "cardDescription": "One escrow-protected purchase of one Listing, as a state machine holding money.", + "specType": "card", + "ref": { + "module": "../sole-vault-order", + "name": "Order" + }, + "readMe": "## Order\n\nWithout an order the marketplace can only be browsed, never transacted.\n\n### The escrow graph is the point\n\nAn order is not a record with a status string on it — it is a state machine holding someone's money. `OrderStatusField` is built with `statusField`, so it carries the transition graph and can answer *may I?* (`canOrderTransition`) rather than only *what are the choices?* (`nextOrderStatuses`). A command that writes a status directly defeats the entire block.\n\n**The absent edges carry the meaning:**\n\n- Nothing returns to `pending-payment` — un-charging a card is a refund, its own terminal state with its own money movement.\n- `completed` and `refunded` both lead nowhere. Once funds have moved, this graph holds nothing; a later problem is a new dispute record, not an edit to history.\n- Authentication failure is `authenticating → refunded`, and there is deliberately **no** `authenticating → delivered` shortcut. A failed legit check refunds the buyer, so no path may ship a fake onward.\n- Every money-holding state can reach `disputed`, because a problem can surface at any point while funds are held.\n\n### Money: four snapshots, one computed total\n\nItem price, shipping, auth fee and platform fee are each their own `AmountWithCurrency`. `total` is **computed** from them, never stored — a stored total survives a change to any component and then disagrees with the sum of its own parts, with nothing in the UI revealing which is right.\n\nFees are **snapshots, not rates**: `platformFee` holds the money actually charged, not \"3%\". A rate re-derived at read time silently rewrites every past order the day the rate changes, and a buyer's receipt must not move.\n\n### Derived, never stored\n\n`fundsHeld` and `isPaid` are computed from the status and the event dates, matching the rule the rest of this family uses (`verified` from `verifiedOn`, `forSale` from `listedAt`). A stored boolean drifts from the thing that caused it.\n\n### Denormalized for prerendered fitted\n\n`productTitle`, `sellerName`, `buyerName` and `conditionCode` are computed fields, because an Orders tab is a grid, a grid is prerendered fitted, and prerendered fitted **cannot resolve `linksTo`**. A tile reading `listing.productTitle` directly renders blank — which would be every tile in the tab, since the product name is the headline. These resolve at index time into the search document.\n\n### What this block does not do\n\nCharge anything. A realm has no processor and no balance, so `Payment` records what an external processor reported and the commands that move this graph are simulations. Stated plainly rather than implied: a card that looks like it takes money and does not is worse than one that never pretended.", + "containedExamples": [], + "cardInfo": { + "name": "Sole Vault Order" + } + }, + "relationships": { + "linkedExamples.0": { + "links": { + "self": "../Order/sv-2026-1041" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} diff --git a/cards/collectibles/Spec/sole-vault-payment.json b/cards/collectibles/Spec/sole-vault-payment.json new file mode 100644 index 00000000..9dc92322 --- /dev/null +++ b/cards/collectibles/Spec/sole-vault-payment.json @@ -0,0 +1,32 @@ +{ + "data": { + "type": "card", + "attributes": { + "cardTitle": "Sole Vault Payment", + "cardDescription": "The record of one money movement against one Order. Records, never moves, money.", + "specType": "card", + "ref": { + "module": "../sole-vault-payment", + "name": "Payment" + }, + "readMe": "## Payment\n\n**Read this before extending it: this card does not move money and cannot.** A realm has no processor, no card vault and no balance. Everything here records what an external processor reported, and the commands that write it are simulations. That is a deliberate boundary, not an unfinished feature — escrow belongs to a payment processor such as Stripe Connect, which a card does not impersonate. The sample instance sets `processor: \"simulated\"` for exactly that reason.\n\n### Why it is separate from Order\n\nAn order has one lifecycle; its money can have several movements — an authorization, a capture, a partial refund, a chargeback reversal. Folding them into `Order` forces one status field to describe two different things, and the moment a refund is partial the model breaks. One Order links to many Payment records; each is immutable history.\n\n### The state graph mirrors a processor, it does not invent one\n\n- `authorized` **is** the escrow state: funds reserved with the processor, not yet taken. This is what \"held in escrow\" actually means technically.\n- `captured → refunded` is the only edge out of a settled payment, and it is one-way. A refund is never un-refunded; that would be a new charge.\n- `failed` is **terminal**, because a retry is a NEW payment record. Reusing this one would erase the evidence that the first attempt was declined.\n\n### Direction, not a negative number\n\n`direction` says `charge` or `refund` and `amount` is always positive. A negative money amount renders as `-$175.00` in some formatters and `($175.00)` in others, and then every aggregate has to remember which convention it is summing. `signedAmount` is the one computed display string, so the atom, the row and any future ledger view cannot disagree about how a refund is written.\n\n### Denormalized for prerendered fitted\n\n`orderReference` and `productTitle` are computed, because prerendered fitted cannot resolve `linksTo`.", + "containedExamples": [], + "cardInfo": { + "name": "Sole Vault Payment" + } + }, + "relationships": { + "linkedExamples.0": { + "links": { + "self": "../Payment/sim-pi-3xkq2r" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} diff --git a/cards/collectibles/Spec/sole-vault-person.json b/cards/collectibles/Spec/sole-vault-person.json new file mode 100644 index 00000000..ec52faac --- /dev/null +++ b/cards/collectibles/Spec/sole-vault-person.json @@ -0,0 +1,35 @@ +{ + "data": { + "type": "card", + "attributes": { + "cardTitle": "Sole Vault Person", + "cardDescription": "The Sole Vault app's own generic person profile — a subclass of the pulled matrix Person block (PersonBase), extended with the app's isolated/edit/fitted views. Used for a buyer, or anyone not specifically a seller.", + "specType": "card", + "ref": { + "module": "../sole-vault-person", + "name": "SoleVaultPerson" + }, + "readMe": "### Why a subclass, not an edit\n\nA pulled building block is meant to stay swappable and re-pullable: if `person-base.gts` gets a fix or a new field upstream, re-pulling it should be a clean file replace, not a merge against app-specific additions. Every format override (`isolated`, `edit`, `fitted`, a recoloured `embedded`) is declared here. `person-base.gts` itself carries zero Sole-Vault-specific code.\n\n### Not the seller profile\n\nThis card has no `rating` field — that lives on the sibling `SellerProfile` (`seller-profile.gts`), a separate, genuinely reusable marketplace building block, not a subclass of this file. `Order.buyer`, `Offer.offeredBy` and `CollectionItem.owner` narrow to plain `SoleVaultPerson`; only `Order.seller` and `Listing.seller` narrow to `SellerProfile`. Splitting them keeps a buyer from carrying a meaningless, always-unset rating field.\n\n### Reused, not rebuilt\n\n`photo` is the catalog's `image-source` field (inherited from `PersonBase`).\n\n### Non-goals\n\nThis card does not carry a seller rating, review count, or any trust signal — see `SellerProfile` for that. It does not distinguish roles by its own type; a buyer is simply whichever `SoleVaultPerson` instance a link field points at.", + "containedExamples": [], + "cardInfo": { + "name": "Sole Vault Person", + "summary": null, + "notes": null, + "cardThumbnailURL": null + } + }, + "relationships": { + "linkedExamples.0": { + "links": { + "self": "../SoleVaultPerson/vaultkeeper" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} diff --git a/cards/collectibles/Spec/sole-vault-shipment.json b/cards/collectibles/Spec/sole-vault-shipment.json new file mode 100644 index 00000000..4838b1c1 --- /dev/null +++ b/cards/collectibles/Spec/sole-vault-shipment.json @@ -0,0 +1,32 @@ +{ + "data": { + "type": "card", + "attributes": { + "cardTitle": "Sole Vault Shipment", + "cardDescription": "One leg of an item's physical journey, with its own carrier and tracking.", + "specType": "card", + "ref": { + "module": "../sole-vault-shipment", + "name": "Shipment" + }, + "readMe": "### The two-leg shape is the domain, not an implementation detail\n\nIn an authenticated marketplace the item does **not** go seller → buyer. It goes seller → authentication centre → buyer, and those are two different shipments with two tracking numbers, two carriers and two failure modes. An item ships to the authentication centre and then to the buyer, so `leg` distinguishes them and an Order links to several.\n\nCollapsing them into one card with two tracking-number fields is the tempting shortcut, and it breaks immediately: the inbound leg can be delivered while the outbound leg does not exist yet, and one status field cannot describe that. The sample instance is the inbound leg, delivered, with no outbound leg created — which is the state that proves the point.\n\n### `exception` is deliberately not terminal\n\nMost carrier exceptions resolve and the parcel moves again, so `exception → in-transit` exists. Only `delivered` and `lost` end a leg. There is no edge out of `delivered`: a parcel that comes back is the `return-to-seller` leg — a different card — not this one reversing.\n\n`lost` on a held order is a refund trigger, not a status tidy-up.\n\n### Two field decisions worth not undoing\n\n- **`trackingUrl` is a `StringField`, not base `url`.** Carrier tracking URLs carry query strings, and `UrlField` silently drops a URL that has one — the whole field vanishes from the search doc with no error. Verified behaviour in this repo, not a guess.\n- **`trackingNumber` is treated as an identifier** everywhere it renders: monospace, tabular, never ellipsised. It gets read aloud and typed into a carrier's site, so a truncated one is useless.\n\n### No carrier API\n\n`carrier` and `trackingNumber` are recorded facts; `trackingUrl` is a link a human clicks. This realm does not poll UPS. A command can update the status from a webhook payload someone else received; nothing here reaches out.\n\n### Derived\n\n`isDelivered` from `deliveredAt`, `needsAttention` from the `exception` status, and `legLabel` so no consumer hand-maps the three leg values and drifts from the others.", + "containedExamples": [], + "cardInfo": { + "name": "Sole Vault Shipment" + } + }, + "relationships": { + "linkedExamples.0": { + "links": { + "self": "../Shipment/sv-2026-1041-inbound" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} diff --git a/cards/collectibles/acquisition.gts b/cards/collectibles/acquisition.gts new file mode 100644 index 00000000..5b0ee471 --- /dev/null +++ b/cards/collectibles/acquisition.gts @@ -0,0 +1,186 @@ +import { + FieldDef, + field, + contains, + StringField, + Component, +} from '@cardstack/base/card-api'; +import DateField from '@cardstack/base/date'; +import AmountWithCurrency from '@cardstack/base/amount-with-currency'; +import ReceiptIcon from '@cardstack/boxel-icons/receipt'; +import { formatMoney } from './money-format'; + +// Acquisition — how an item entered someone's possession: what was paid, when, +// from where, and against which reference. +// +// Reused rather than rebuilt: `price` is base `amount-with-currency` +// ({ amount, currency }), not a NumberField plus a separate code string. + +/** + * Format a Date as a calendar day (`YYYY-MM-DD`) using its LOCAL components. + * + * `new Date('9/30/2026').toISOString()` yields the 29th anywhere east of UTC, + * so an acquisition date round-tripped through ISO silently moves back a day. + * An acquisition happened on a calendar day, not at an instant — read + * getFullYear/getMonth/getDate and build the string. + */ +export function toCalendarDay(date: Date | null | undefined): string { + if (!date) { + return ''; + } + let year = date.getFullYear(); + let month = String(date.getMonth() + 1).padStart(2, '0'); + let day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +export class Acquisition extends FieldDef { + static displayName = 'Acquisition'; + static icon = ReceiptIcon; + + @field price = contains(AmountWithCurrency); + @field acquiredOn = contains(DateField); + + // Where it came from: 'Retail', 'StockX', 'Local meetup', 'Estate sale'. + // A fixed-ish list — the consumer supplies enum options if it wants a select. + @field source = contains(StringField); + + // Order number, receipt id, lot number — whatever proves the purchase. + @field reference = contains(StringField); + + @field acquiredOnDay = contains(StringField, { + computeVia: function (this: Acquisition) { + return toCalendarDay(this.acquiredOn); + }, + }); + + static atom = class Atom extends Component { + // Routed through the one formatter, NOT `<@fields.price @format='atom'/>`. + // The field atom does no number formatting at all, so it prints 11.5 for + // 11.50 — a price missing a digit is not a price. + get paid() { + return formatMoney(this.args.model?.price); + } + + + }; + + static embedded = class Embedded extends Component { + get paid() { + return formatMoney(this.args.model?.price); + } + + + }; +} + +export default Acquisition; diff --git a/cards/collectibles/authentication-record.gts b/cards/collectibles/authentication-record.gts new file mode 100644 index 00000000..2ee8dbdf --- /dev/null +++ b/cards/collectibles/authentication-record.gts @@ -0,0 +1,1282 @@ +import { + CardDef, + field, + contains, + linksTo, + StringField, + Component, +} from '@cardstack/base/card-api'; +import DateField from '@cardstack/base/date'; +import TextAreaField from '@cardstack/base/text-area'; +import { + statusField, + statusOption, +} from '@cardstack/catalog/fields/status/status'; +import MultiImageSourceField from '@cardstack/catalog/fields/multi-image-source/multi-image-source'; +import { CollectionItem } from './collection-item'; +import { Order as SoleVaultOrder } from './sole-vault-order'; +import ShieldCheckIcon from '@cardstack/boxel-icons/shield-check'; +import CalendarIcon from '@cardstack/boxel-icons/calendar'; +import CalendarClockIcon from '@cardstack/boxel-icons/calendar-clock'; +import FileTextIcon from '@cardstack/boxel-icons/file-text'; +import NotebookPenIcon from '@cardstack/boxel-icons/notebook-pen'; +import ArchiveIcon from '@cardstack/boxel-icons/archive'; +import { tracked } from '@glimmer/tracking'; +import { + FittedCard, + FieldContainer, + Accordion, +} from '@cardstack/boxel-ui/components'; + +// AuthenticationRecord — one legit-check on one item: who checked it, when, the +// verdict, and the certificate reference. +// +// It is the inspection itself, not the certifying authority, a cryptographic +// signature, or trust metadata on another record. +// +// THE SPLIT AGAINST CollectionItem is deliberate and is the reason both exist: +// this card is the full RECORD (submitted photos, authenticator notes, the +// service, the certificate id — everything a dispute needs), while +// CollectionItem keeps only the OUTCOME (`verifiedOn`, `verifiedBy`, +// `verificationReference`). The outcome is duplicated on purpose: a collection +// grid is prerendered fitted, which cannot resolve a link to this record, so a +// tile could not show a verified badge otherwise. That is denormalization for the +// one legitimate reason — a rendering constraint — not to avoid a query. + +export type AuthOutcome = 'pending' | 'passed' | 'failed'; + +// An authentication outcome is a genuine lifecycle, so the transition graph is +// the point: a verdict must not be edited from `passed` to `failed` in a +// dropdown, because a reversal is a new inspection with its own certificate, +// not a correction to this record. Both verdicts are therefore terminal. +export const AuthOutcomeField = statusField({ + displayName: 'Authentication Outcome', + options: [ + { + value: 'pending', + label: 'Pending', + hue: 'amber', + meaning: 'Submitted and awaiting a verdict.', + }, + { + value: 'passed', + label: 'Passed', + hue: 'green', + terminal: true, + holds: true, + meaning: 'Verified genuine. This is the badge a listing shows.', + }, + { + value: 'failed', + label: 'Failed', + hue: 'red', + terminal: true, + holds: true, + meaning: 'Judged not genuine. In an escrow flow this refunds the buyer.', + }, + ], + transitions: { + pending: ['passed', 'failed'], + passed: [], + failed: [], + }, +}); + +class AuthenticationRecordEdit extends Component { + @tracked timelineOpen = true; + @tracked certificateOpen = false; + @tracked evidenceOpen = false; + + toggleTimeline = () => (this.timelineOpen = !this.timelineOpen); + toggleCertificate = () => (this.certificateOpen = !this.certificateOpen); + toggleEvidence = () => (this.evidenceOpen = !this.evidenceOpen); + + +} + +export class AuthenticationRecord extends CardDef { + static displayName = 'Authentication Record'; + static icon = ShieldCheckIcon; + + @field item = linksTo(() => CollectionItem, { searchable: true }); + + // The transaction this check belongs to, when it is part of an escrow flow. + @field order = linksTo(() => SoleVaultOrder, { searchable: true }); + + // 'Check Check', 'GOAT', 'In-house'. The consumer supplies enum options if it + // wants a select; the block does not name anyone's list of services. + @field service = contains(StringField); + + @field outcome = contains(AuthOutcomeField); + + @field submittedAt = contains(DateField); + @field completedAt = contains(DateField); + + @field certificateId = contains(StringField); + @field authenticator = contains(StringField); + @field authenticatorNotes = contains(TextAreaField); + @field submittedPhotos = contains(MultiImageSourceField); + + // Denormalized for fitted, same reason as everywhere else in this family. + @field itemTitle = contains(StringField, { + computeVia: function (this: AuthenticationRecord) { + return this.item?.itemTitle ?? this.cardInfo?.name ?? ''; + }, + }); + + // Turnaround is a real fact a queue view wants, and it is cheap and bounded — + // both dates are on this card, so it is safe as a computed field. Calendar-day + // arithmetic, not instants: both sides are read as local Y/M/D by DateField. + @field turnaroundDays = contains(StringField, { + computeVia: function (this: AuthenticationRecord) { + let a = this.submittedAt; + let b = this.completedAt; + if (!a || !b) { + return ''; + } + let ms = b.getTime() - a.getTime(); + if (ms < 0) { + return ''; + } + let days = Math.round(ms / 86400000); + return days === 1 ? '1 day' : `${days} days`; + }, + }); + + // EDIT — compact identity row (item / order / outcome, Rule 0's "glanced at + // every time" set), everything else grouped into independently collapsible + // sections. Timeline opens by default (the fields most often touched while a + // check is in flight); certificate and evidence start collapsed. + static edit = AuthenticationRecordEdit; + + // ISOLATED — the record's landing page. + // + // DOMAIN QUESTION: "did this pass, and how long did it take?" A dispute or a + // buyer opens this card for the verdict and the paper trail, so the verdict is + // the hero and the certificate id is the thing they copy into another system. + // + // Direction: Instrument (the family's operational register) — hairline rules, + // tabular figures, status as colour. The signature element is the family's + // vault plaque: a gold hairline over the headline value, matching + // CollectionItem's worth figure and the fitted tiles' gold edge. + static isolated = class Isolated extends Component< + typeof AuthenticationRecord + > { + // The check's own lifecycle, drawn from the two dates this card owns. Same + // rail shape as CollectionItem's provenance section — one signature element + // reused across the family, not a new marquee per card. + // + // In review = submitted but not yet concluded. Reading it off the dates + // rather than storing it keeps it from drifting, the same rule the schema + // applies to `verified` / `forSale` on CollectionItem. + get isUnderReview() { + let m = this.args.model; + return Boolean(m?.submittedAt) && !m?.completedAt; + } + + get verdictLabel() { + let outcome = this.args.model?.outcome; + if (outcome === 'passed') { + return 'Verdict — passed'; + } + if (outcome === 'failed') { + return 'Verdict — failed'; + } + return 'Verdict'; + } + + // The plaque's own big label + colour, derived from the SAME option list + // AuthOutcomeField already exports — no second hand-mapped status→colour + // list to drift from it. `passed` gets the family's gold treatment + // (the brand's positive colour, reused everywhere else); `failed` gets + // the rose Payment's refund plaque uses, so "negative" reads the same + // hue across the whole app; `pending` stays a neutral ink panel, since + // an undecided verdict has not earned a colour commitment yet. + get verdictOption() { + return statusOption(AuthOutcomeField, this.args.model?.outcome); + } + + get verdictModifierClass() { + let outcome = this.args.model?.outcome; + if (outcome === 'passed') { + return 'plaque--passed'; + } + if (outcome === 'failed') { + return 'plaque--failed'; + } + return 'plaque--pending'; + } + + get hasPaperTrail() { + let m = this.args.model; + return Boolean(m?.certificateId || m?.authenticator || m?.service); + } + + + }; + + // FITTED — Step 0 fork: FittedCard, not hand-rolled. This is a standard + // composition (image/placeholder + eyebrow + title + badge + footer) and the + // three fitted templates already shipped in this family use it, so forking a + // fourth idiom here would break the family for no gain. + // + // SLOT DISCIPLINE — four distinct facts, four slots, zero repeats. + // `<:subtitle>` and `<:meta>` are deliberately NOT rendered: the only values + // left to put in them are the service (already the eyebrow) and the outcome + // (already the badge), and a slot filled with a value shown elsewhere is the + // documented failure that survives source review because the repeat only + // appears at the quanta where both slots happen to be visible. + static fitted = class Fitted extends Component { + + }; + + static atom = class Atom extends Component { + + }; + + static embedded = class Embedded extends Component< + typeof AuthenticationRecord + > { + + }; +} + +export default AuthenticationRecord; diff --git a/cards/collectibles/collectible-product.gts b/cards/collectibles/collectible-product.gts new file mode 100644 index 00000000..939adaca --- /dev/null +++ b/cards/collectibles/collectible-product.gts @@ -0,0 +1,1063 @@ +import { + CardDef, + field, + contains, + containsMany, + linksTo, + StringField, + Component, +} from '@cardstack/base/card-api'; +import DateField from '@cardstack/base/date'; +import MarkdownField from '@cardstack/base/markdown'; +import AmountWithCurrency from '@cardstack/base/amount-with-currency'; +import Tag from '@cardstack/base/tag'; +import MultiImageSourceField from '@cardstack/catalog/fields/multi-image-source/multi-image-source'; +import { tracked } from '@glimmer/tracking'; +import { + FittedCard, + FieldContainer, + Accordion, +} from '@cardstack/boxel-ui/components'; +import PackageIcon from '@cardstack/boxel-icons/package'; +import ImageOffIcon from '@cardstack/boxel-icons/image-off'; +import TagIcon from '@cardstack/boxel-icons/tag'; +import CalendarIcon from '@cardstack/boxel-icons/calendar'; +import RulerIcon from '@cardstack/boxel-icons/ruler'; +import NotebookPenIcon from '@cardstack/boxel-icons/notebook-pen'; +import { formatMoney } from './money-format'; + +// CollectibleProduct — a catalogue entry for a manufactured collectible: the +// thing itself, not anyone's copy of it. +// +// NAMED `CollectibleProduct`, NOT `Product`, because `experiments-realm/product.gts` +// already exports a `Product` (an e-commerce demo card with seller/lead-time +// fields). Two `Product`s at one realm root would be a coin-flip for any importer. +// +// The split against CollectionItem is the whole point and is worth stating: this +// card is the CATALOGUE (one row per SKU, shared by everyone), CollectionItem is +// the OWNED COPY (one row per person per pair, with condition and acquisition). +// Retail price lives here because it is a fact about the product; what someone +// paid lives on their CollectionItem because it is a fact about their purchase. + +class CollectibleProductEdit extends Component { + @tracked pricingOpen = true; + @tracked mediaOpen = false; + @tracked descriptionOpen = false; + @tracked specificsOpen = false; + + togglePricing = () => (this.pricingOpen = !this.pricingOpen); + toggleMedia = () => (this.mediaOpen = !this.mediaOpen); + toggleDescription = () => (this.descriptionOpen = !this.descriptionOpen); + toggleSpecifics = () => (this.specificsOpen = !this.specificsOpen); + + +} + +export class CollectibleProduct extends CardDef { + static displayName = 'Collectible Product'; + static icon = PackageIcon; + // Image-led catalogue entry with a wide spec grid — a layout surface for a + // single record, same call as the rest of the family (isolated-card Rule 1b). + + // The definitive identifier — a UPC-like code. Kept as its own field rather + // than folded into the title because it is looked up, read aloud and typed + // into other systems. + // + @field sku = contains(StringField); + + @field colorway = contains(StringField); + + // Taxonomy, deliberately links rather than strings: 'Air Jordan 1 High' and + // 'Nike' are shared vocabulary, so Silhouette and Brand ride on Tag rather + // than being cards of their own. + @field silhouette = linksTo(Tag, { searchable: true }); + @field brand = linksTo(Tag, { searchable: true }); + + @field releaseDate = contains(DateField); + @field retailPrice = contains(AmountWithCurrency); + + @field images = contains(MultiImageSourceField); + @field productDescription = contains(MarkdownField); + + // The manufacturer's size run. containsMany of a plain string because the + // scale is the consumer's — see CollectionItem.variantScale for the same call. + @field availableVariants = containsMany(StringField); + + @field collabPartner = contains(StringField); + @field tags = containsMany(StringField); + + // NO `lowestPrice` AND NO `listingCount`. They are unbounded rollups over + // active listings: a live query belongs in a metrics component the consumer + // composes, never link-arrayed or cached onto the hot catalogue card. A + // consumer reads them with a reverse query on Listing. + + @field displayTitle = contains(StringField, { + computeVia: function (this: CollectibleProduct) { + let base = this.cardInfo?.name ?? ''; + return this.colorway ? `${base} "${this.colorway}"` : base; + }, + }); + + // ISOLATED — the catalogue record's landing page. + // + // DOMAIN QUESTION: "what exactly is this, and what did it retail for?" This + // card is the shared catalogue row (see the header note against + // CollectionItem), so the answer is the product's own facts — never a rollup + // over anyone's listings, which is deliberately absent (see the header note). + // + // Direction: Object (image-led — the photo carries the hero). Signature + // element: the family's gold plaque (same device the app shell uses for its + // collection-value figure), here filled with retail price instead of a + // collector's own valuation, so the catalogue reads as the same app. + static isolated = class Isolated extends Component< + typeof CollectibleProduct + > { + get retail() { + return formatMoney(this.args.model?.retailPrice); + } + + get hasSpecifics() { + let m = this.args.model; + return Boolean( + m?.collabPartner || (m?.availableVariants ?? []).filter(Boolean).length, + ); + } + + + }; + + // EDIT — compact identity row (Rule 0: the fields glanced at every time), + // everything else grouped into independently collapsible sections. Pricing + // opens by default (the field most often changed); media/description/tags + // start collapsed. + static edit = CollectibleProductEdit; + + static atom = class Atom extends Component { + + }; + + static embedded = class Embedded extends Component< + typeof CollectibleProduct + > { + get retail() { + return formatMoney(this.args.model?.retailPrice); + } + + + }; + + static fitted = class Fitted extends Component { + get retail() { + return formatMoney(this.args.model?.retailPrice); + } + + + }; +} + +export default CollectibleProduct; diff --git a/cards/collectibles/collection-item.gts b/cards/collectibles/collection-item.gts new file mode 100644 index 00000000..0447756b --- /dev/null +++ b/cards/collectibles/collection-item.gts @@ -0,0 +1,1719 @@ +import { + CardDef, + field, + contains, + linksTo, + StringField, + Component, +} from '@cardstack/base/card-api'; +import BooleanField from '@cardstack/base/boolean'; +import DateField from '@cardstack/base/date'; +import MarkdownField from '@cardstack/base/markdown'; +import AmountWithCurrency from '@cardstack/base/amount-with-currency'; +// NOTE (unverified in this realm): the `@cardstack/catalog/*` prefix is a +// registered realm alias resolved at runtime by the loader/fetcher +// (see runtime-common/fetcher.ts), NOT a package.json dependency. No module in +// experiments-realm imports it yet, so this is the first use here — confirm it +// resolves in the `type='instance'` index rows before building on it. If the +// prefix is not registered for this realm, the fallback is the absolute realm URL +// of the catalog module. +import MultiImageSourceField from '@cardstack/catalog/fields/multi-image-source/multi-image-source'; +import { SoleVaultPerson } from './sole-vault-person'; +import { ConditionGrade } from '@cardstack/catalog/fields/condition-grade/condition-grade'; +import { Acquisition } from './acquisition'; +import ArchiveIcon from '@cardstack/boxel-icons/archive'; +import BadgeCheckIcon from '@cardstack/boxel-icons/badge-check'; +import CalendarIcon from '@cardstack/boxel-icons/calendar'; +import ReceiptIcon from '@cardstack/boxel-icons/receipt'; +import CoinsIcon from '@cardstack/boxel-icons/coins'; +import RulerIcon from '@cardstack/boxel-icons/ruler'; +import NotebookPenIcon from '@cardstack/boxel-icons/notebook-pen'; +import TrendingUpIcon from '@cardstack/boxel-icons/trending-up'; +import TrendingDownIcon from '@cardstack/boxel-icons/trending-down'; +import ImageOffIcon from '@cardstack/boxel-icons/image-off'; +import ShoppingBagIcon from '@cardstack/boxel-icons/shopping-bag'; +import { tracked } from '@glimmer/tracking'; +import { + Accordion, + FieldContainer, + FittedCard, + Pill, + ProgressBar, +} from '@cardstack/boxel-ui/components'; +import { eq } from '@cardstack/boxel-ui/helpers'; +import { formatMoney, formatMoneyDelta } from './money-format'; + +// CollectionItem — one physical thing someone owns, catalogued. +// +// The block is deliberately NOT about sneakers. `item` links to any CardDef, so +// the same block catalogues a sneaker, a watch, a record, or a first edition; the +// consumer narrows the link type and supplies the grading vocabulary. Nothing in +// this module names a domain. +// +// Two schema decisions worth reading before changing them: +// +// 1. THRESHOLD FLAGS ARE EVENT FACTS. `verified` and `forSale` are computed from +// `verifiedOn` and `listedAt`. Storing either boolean lets it drift from the +// thing that caused it — the classic failure is a card that reads "not for +// sale" while an active listing points at it. Dates are also attributes, so +// they survive prerendered fitted views, where a linksTo would not resolve. +// +// 2. NO CURRENT-MARKET-VALUE FIELD. "What is this worth right now" is a +// reporting rollup over active listings — unbounded, and a live realm query +// belongs in a metrics component a consumer composes, never link-arrayed onto +// the hot card. `lastKnownValue` + `valuedOn` is the fitted-safe snapshot: an +// attribute a tile can render without running a query. + +class CollectionItemEdit extends Component { + @tracked valuationOpen = true; + @tracked conditionOpen = true; + @tracked acquisitionOpen = false; + @tracked authOpen = false; + @tracked photosOpen = false; + @tracked notesOpen = false; + + toggleValuation = () => (this.valuationOpen = !this.valuationOpen); + toggleCondition = () => (this.conditionOpen = !this.conditionOpen); + toggleAcquisition = () => (this.acquisitionOpen = !this.acquisitionOpen); + toggleAuth = () => (this.authOpen = !this.authOpen); + togglePhotos = () => (this.photosOpen = !this.photosOpen); + toggleNotes = () => (this.notesOpen = !this.notesOpen); + + +} + +export class CollectionItem extends CardDef { + static displayName = 'Collection Item'; + // Image-led hero + wide detail grid genuinely wants the full stack width, + // not the ~800px single-record cap — see isolated-card Rule 1b. + static icon = ArchiveIcon; + + // The catalogued thing. Consumers narrow this (linksTo(() => Product)). + // + // `searchable: true` pulls the linked card's fields into this card's search + // document, which is what lets a collection view filter and sort by the thing + // owned ("all my Jordan 1s") instead of only by this card's own attributes. + @field item = linksTo(CardDef, { searchable: true }); + + // The size/edition/variant label, and the scale it is expressed on. + // Checked and rejected as substitutes: catalog `quantity` is a bounded count, + // catalog `discrete-range-field` is a range. A single graded value on a named + // scale is neither, and the scale is the consumer's ('US', 'UK', 'EU', 'cm'). + @field variant = contains(StringField); + @field variantScale = contains(StringField); + + @field condition = contains(ConditionGrade); + + // 'OG All', 'Replacement Box', 'No Box' — a fixed list per domain, so the + // consumer supplies enum options on its own override of this field. + @field packaging = contains(StringField); + + @field acquisition = contains(Acquisition); + + // The owner's own photos. Takes a pasted URL or a file uploaded into the + // realm; `photos.primaryUrl` is the fitted visual anchor. + @field photos = contains(MultiImageSourceField); + + // --- authentication, as event facts --- + @field verifiedOn = contains(DateField); + @field verifiedBy = contains(StringField); + @field verificationReference = contains(StringField); + @field verified = contains(BooleanField, { + computeVia: function (this: CollectionItem) { + return this.verifiedOn != null; + }, + }); + + // --- for-sale state, as an event fact --- + @field listedAt = contains(DateField); + @field forSale = contains(BooleanField, { + computeVia: function (this: CollectionItem) { + return this.listedAt != null; + }, + }); + + // Fitted-safe valuation snapshot. See note 2 above. + @field lastKnownValue = contains(AmountWithCurrency); + @field valuedOn = contains(DateField); + + // Whose copy this is. + @field owner = linksTo(() => SoleVaultPerson, { searchable: true }); + + @field notes = contains(MarkdownField); + + // DENORMALIZED FOR PRERENDERED FITTED — do not "simplify" this to + // `@model.item.title` in a template. + // + // A collection view draws its grid and list from prerendered fitted HTML, and + // prerendered fitted cannot resolve linksTo/linksToMany. A tile that reads the + // linked card directly renders blank — which is every tile in the grid, since + // the thing owned is the tile's headline. A computed field is resolved at INDEX + // time and stored in the search document, so it is a plain attribute by the time + // fitted renders and survives the trip. + // + // Defensive on purpose: `item` may be unlinked (the block is neutral, the link + // is optional) or not yet loaded, so this optional-chains and falls back to the + // card's own title rather than rendering 'undefined'. + @field itemTitle = contains(StringField, { + computeVia: function (this: CollectionItem) { + return this.item?.cardInfo?.name ?? this.cardInfo?.name ?? ''; + }, + }); + + @field variantLabel = contains(StringField, { + computeVia: function (this: CollectionItem) { + if (!this.variant) { + return ''; + } + return this.variantScale + ? `${this.variantScale} ${this.variant}` + : this.variant; + }, + }); + + // EDIT — grouped sections, not the schema wearing input boxes (edit-card + // Rule 0). The fields glanced at every time sit compact up top; everything + // else is an independently collapsible section. `verified`, `forSale`, + // `itemTitle`, `variantLabel` are computed and deliberately absent. + static edit = CollectionItemEdit; + + static atom = class Atom extends Component { + + }; + + // Row-shaped embedded, for lists a consumer builds. + // + // Every trailing data slot is ALWAYS rendered at a constant width, em-dash when + // empty, so a consumer's list column-aligns regardless of which rows happen to + // carry a value or a per-row action. + static embedded = class Embedded extends Component { + + }; + + // THE DOMAIN QUESTION this view answers, above the fold: + // "Is it genuine, and what is it worth against what I paid?" + // + // Not "show me the item". A collector opens an owned item to check its + // authentication state and its position versus cost: the collection IS the + // inventory and the storefront. Both halves of that + // answer are attributes already on the card (`verifiedOn`, `lastKnownValue`, + // `acquisition.price`), so the hero needs no reverse query. + // + // DIRECTION: Object (image-led: the thing itself first), as across the + // Sole Vault family. + // SIGNATURE ELEMENT for the family: the provenance rail. It is drawn from the + // three lifecycle dates this schema deliberately stores as event facts + // (`acquisition.acquiredOn`, `verifiedOn`, `listedAt`), so it is the shape of + // this particular record made visible rather than decoration laid over it — and + // every card in the Sole Vault family has those dates, which is what makes it a + // family signature instead of a per-card marquee. + static isolated = class Isolated extends Component { + get worthNow() { + return formatMoney(this.args.model?.lastKnownValue); + } + + get paid() { + return formatMoney(this.args.model?.acquisition?.price); + } + + get delta() { + return formatMoneyDelta( + this.args.model?.lastKnownValue, + this.args.model?.acquisition?.price, + ); + } + + get retention() { + let v = this.args.model?.condition?.valueRetention; + return v == null ? null : Math.max(0, Math.min(100, v)); + } + + get hasProvenance() { + let m = this.args.model; + return Boolean( + m.acquisition?.acquiredOnDay || m.verifiedOn || m.listedAt, + ); + } + + + }; + + // STEP 0 FORK — FittedCard, not hand-rolled. This reverses the instinct the + // design brief creates ("dark identity-bearing card → hand-roll"), and it is + // reversed on evidence read out of the component, not on taste: + // + // 1. `FittedCard`'s root is `
`, so + // a wrapper class of ours lands on the real root — the dark ground and + // every `--fc-*` override apply without forking anything. + // 2. It declares NO `container-type`/`container-name` — it queries the host's + // `fitted-card` container, which is exactly the rule a hand-rolled + // template has to obey anyway. + // 3. It already carries ~20 debugged `@container fitted-card` rules covering + // the whole aspect-ratio × width × height matrix. Hand-rolling means + // reimplementing the 16-size quanta that already exist and work. + // + // The content here IS the standard composition (image + eyebrow + title + badge + // + footer), which is precisely the case the fork rule sends to FittedCard. The + // identity we need is palette plus the photo carrying real width, and both are + // `--fc-*` knobs. + // + // SLOT DISCIPLINE — six distinct facts, six slots, zero repeats. + // `<:subtitle>` and `<:meta>` are deliberately NOT rendered. They are the two + // slots that would have to be padded with a value already shown elsewhere + // (variant, or the condition code), which is the documented failure mode: one + // nine-card family had a value duplicated across slots in 6 of 9 templates, + // and it survives source review because the repeat only appears at the quanta + // where both slots happen to be visible. A slot with nothing distinct to say is + // deleted, not filled. + // + // ONE BADGE, NOT TWO. `verified` and `forSale` are both badge-worthy, but two + // badges at the 150×40 quantum is two loud things and therefore no anchor. The + // split: `[FOR SALE]` is the tile marker, while `DS ✓Auth` sits inline in the text row — so for-sale takes the + // badge and verification rides as a glyph beside the condition in the footer. + static fitted = class Fitted extends Component { + get worth() { + return formatMoney(this.args.model?.lastKnownValue); + } + + + }; +} + +export default CollectionItem; diff --git a/cards/collectibles/commands/add-to-collection-command.gts b/cards/collectibles/commands/add-to-collection-command.gts new file mode 100644 index 00000000..cb3c6e5a --- /dev/null +++ b/cards/collectibles/commands/add-to-collection-command.gts @@ -0,0 +1,125 @@ +import { Command } from '@cardstack/runtime-common'; +import { + CardDef, + StringField, + contains, + field, + realmURL, +} from '@cardstack/base/card-api'; +import NumberField from '@cardstack/base/number'; +import AmountWithCurrency from '@cardstack/base/amount-with-currency'; +import CurrencyField from '@cardstack/base/currency'; +import GetCardCommand from '@cardstack/boxel-host/commands/get-card'; +import SaveCardCommand from '@cardstack/boxel-host/commands/save-card'; +import { CollectionItem } from '../collection-item'; +import { ConditionGrade } from '@cardstack/catalog/fields/condition-grade/condition-grade'; +import { Acquisition } from '../acquisition'; + +export class AddToCollectionInput extends CardDef { + @field productId = contains(StringField); + @field ownerId = contains(StringField); + @field variant = contains(StringField); + @field variantScale = contains(StringField); + @field packaging = contains(StringField); + // condition + @field conditionCode = contains(StringField); + @field conditionValueRetention = contains(NumberField); + @field conditionNotes = contains(StringField); + // acquisition + @field acquisitionPrice = contains(NumberField); + @field acquisitionCurrency = contains(StringField); + @field acquisitionSource = contains(StringField); + @field acquisitionReference = contains(StringField); +} + +export class AddToCollectionResult extends CardDef { + @field collectionItemId = contains(StringField); +} + +// Add To Collection (AC) — a new owned copy enters someone's vault. +// +// The condition scale is the CALLER'S vocabulary, never hardcoded here — +// `condition-grade.gts`'s own header note is explicit that this field names +// no grading scale (DS/VNDS/9-10 is a sneaker convention, not the block's). +// This command just carries whatever `conditionCode` the caller supplies +// straight through. +export default class AddToCollectionCommand extends Command< + typeof AddToCollectionInput, + typeof AddToCollectionResult +> { + static actionVerb = 'Add to collection'; + description = + 'Catalogue a new owned copy of a product, with its condition and acquisition details.'; + + async getInputType() { + return AddToCollectionInput; + } + + protected async run( + input: AddToCollectionInput, + ): Promise { + let productId = input.productId?.trim(); + let ownerId = input.ownerId?.trim(); + if (!productId) { + throw new Error('productId is required'); + } + if (!ownerId) { + throw new Error('ownerId is required'); + } + + let product = await new GetCardCommand(this.toolContext).execute({ + cardId: productId, + }); + if (!product) { + throw new Error(`Product not found: ${productId}`); + } + let owner = await new GetCardCommand(this.toolContext).execute({ + cardId: ownerId, + }); + if (!owner) { + throw new Error(`Owner not found: ${ownerId}`); + } + + let item = new CollectionItem(); + item.item = product; + item.owner = owner as any; + item.variant = input.variant?.trim() || ''; + item.variantScale = input.variantScale?.trim() || ''; + item.packaging = input.packaging?.trim() || ''; + + let condition = new ConditionGrade(); + condition.code = input.conditionCode?.trim() || ''; + if (input.conditionValueRetention != null) { + condition.valueRetention = input.conditionValueRetention; + } + condition.notes = input.conditionNotes?.trim() || ''; + item.condition = condition; + + let acquisition = new Acquisition(); + if (input.acquisitionPrice != null) { + acquisition.price = new AmountWithCurrency({ + amount: input.acquisitionPrice, + currency: new CurrencyField({ + code: input.acquisitionCurrency?.trim() || 'USD', + }), + }); + } + acquisition.acquiredOn = new Date(); + acquisition.source = input.acquisitionSource?.trim() || ''; + acquisition.reference = input.acquisitionReference?.trim() || ''; + item.acquisition = acquisition; + + // Save into the SOURCE card's own realm. Without `realm`, SaveCard + // defaults to the base realm and the write 401s — verified live when a + // ProcessPayment run tried to save its Payment to cardstack.com/base/. + let realm = (product as any)?.[realmURL]?.href; + let saved = (await new SaveCardCommand(this.toolContext).execute({ + card: item, + realm, + })) as CollectionItem; + + let result = new AddToCollectionResult(); + result.collectionItemId = saved.id; + return result; + } +} diff --git a/cards/collectibles/commands/authenticate-item-command.gts b/cards/collectibles/commands/authenticate-item-command.gts new file mode 100644 index 00000000..cc6ed2c9 --- /dev/null +++ b/cards/collectibles/commands/authenticate-item-command.gts @@ -0,0 +1,135 @@ +import { Command } from '@cardstack/runtime-common'; +import { + CardDef, + StringField, + contains, + field, + realmURL, +} from '@cardstack/base/card-api'; +import GetCardCommand from '@cardstack/boxel-host/commands/get-card'; +import PatchCardInstanceCommand from '@cardstack/boxel-host/commands/patch-card-instance'; +import SaveCardCommand from '@cardstack/boxel-host/commands/save-card'; +import { CollectionItem } from '../collection-item'; +import { + AuthenticationRecord, + AuthOutcomeField, +} from '../authentication-record'; +import { canTransition } from '@cardstack/catalog/fields/status/status'; + +export class AuthenticateItemInput extends CardDef { + @field itemId = contains(StringField); + @field orderId = contains(StringField); + @field service = contains(StringField); + @field authenticator = contains(StringField); + @field certificateId = contains(StringField); + @field notes = contains(StringField); + // 'passed' | 'failed' — the verdict this call resolves the check to. A + // fresh `AuthenticationRecord` is created in `pending` and immediately + // moved to this value in the same call, rather than modelling a real + // asynchronous submit-then-poll round trip this realm cannot run. + @field outcome = contains(StringField); +} + +export class AuthenticateItemResult extends CardDef { + @field authenticationRecordId = contains(StringField); + @field outcome = contains(StringField); +} + +// Authenticate Item (AI) — runs a legit check and records the verdict. +// +// TWO CARDS UPDATE, NOT ONE, AND FOR A STATED REASON. +// `AuthenticationRecord` is the full record (service, authenticator, the +// certificate, submitted photos) — everything a dispute needs. But a +// collection grid is prerendered fitted, which cannot resolve a link to this +// record, so `CollectionItem` keeps only the OUTCOME (`verifiedOn`, +// `verifiedBy`, `verificationReference`) so a tile can show a verified badge +// without resolving anything. See `authentication-record.gts`'s own header +// note — this command is what keeps the two in sync rather than leaving the +// second write to whoever remembers. +// +// A FAILED VERDICT DOES NOT AUTO-REFUND. `authentication-record.gts` says +// failure "in an escrow flow this refunds the buyer" — that is a fact about +// what SHOULD happen next, not something this command does for you. Refund +// is money moving, and `RefundOrderCommand` is its own explicit call, so a +// failed check is never silently followed by an unrequested charge reversal. +export default class AuthenticateItemCommand extends Command< + typeof AuthenticateItemInput, + typeof AuthenticateItemResult +> { + static actionVerb = 'Authenticate item'; + description = + 'Record an authentication verdict for a collection item, and sync the outcome onto the item itself.'; + + async getInputType() { + return AuthenticateItemInput; + } + + protected async run( + input: AuthenticateItemInput, + ): Promise { + let itemId = input.itemId?.trim(); + if (!itemId) { + throw new Error('itemId is required'); + } + let outcome = input.outcome?.trim(); + if (outcome !== 'passed' && outcome !== 'failed') { + throw new Error("outcome must be 'passed' or 'failed'"); + } + if (!canTransition(AuthOutcomeField, 'pending', outcome)) { + throw new Error(`Authentication outcome cannot resolve to ${outcome}`); + } + + let item = (await new GetCardCommand(this.toolContext).execute({ + cardId: itemId, + })) as CollectionItem; + if (!item) { + throw new Error(`CollectionItem not found: ${itemId}`); + } + + let now = new Date(); + + let record = new AuthenticationRecord(); + record.item = item; + if (input.orderId?.trim()) { + record.order = (await new GetCardCommand(this.toolContext).execute({ + cardId: input.orderId.trim(), + })) as any; + } + record.service = input.service?.trim() || 'In-house'; + record.outcome = outcome as 'passed' | 'failed'; + record.submittedAt = now; + record.completedAt = now; + record.certificateId = input.certificateId?.trim() || ''; + record.authenticator = input.authenticator?.trim() || ''; + record.authenticatorNotes = input.notes?.trim() || ''; + + // Save into the SOURCE card's own realm. Without `realm`, SaveCard + // defaults to the base realm and the write 401s — verified live when a + // ProcessPayment run tried to save its Payment to cardstack.com/base/. + let realm = (item as any)?.[realmURL]?.href; + let saved = (await new SaveCardCommand(this.toolContext).execute({ + card: record, + realm, + })) as AuthenticationRecord; + + if (outcome === 'passed') { + await new PatchCardInstanceCommand(this.toolContext, { + cardType: CollectionItem, + }).execute({ + cardId: itemId, + patch: { + attributes: { + verifiedOn: now.toISOString().slice(0, 10), + verifiedBy: record.authenticator, + verificationReference: record.certificateId, + }, + }, + }); + } + + let result = new AuthenticateItemResult(); + result.authenticationRecordId = saved.id; + result.outcome = outcome; + return result; + } +} diff --git a/cards/collectibles/commands/calculate-completion-command.gts b/cards/collectibles/commands/calculate-completion-command.gts new file mode 100644 index 00000000..768e22eb --- /dev/null +++ b/cards/collectibles/commands/calculate-completion-command.gts @@ -0,0 +1,88 @@ +import { Command, identifyCard } from '@cardstack/runtime-common'; +import { + CardDef, + StringField, + contains, + field, +} from '@cardstack/base/card-api'; +import NumberField from '@cardstack/base/number'; +import GetCardCommand from '@cardstack/boxel-host/commands/get-card'; +import { SearchCardsByQueryCommand } from '@cardstack/boxel-host/commands/search-cards'; +import type { CompletionSet } from '../completion-set'; +import { computeCompletion } from '../completion-set'; +import { CollectionItem } from '../collection-item'; + +export class CalculateCompletionInput extends CardDef { + @field completionSetId = contains(StringField); +} + +export class CalculateCompletionResult extends CardDef { + @field completionSetId = contains(StringField); + @field ownedCount = contains(NumberField); + @field targetCount = contains(NumberField); + @field percent = contains(NumberField); +} + +// Calculate Completion (CC) — the app's own reverse query, wrapped as a +// callable command so an assistant or a "refresh" affordance can ask for the +// number without duplicating `completion-set.gts`'s isolated view logic. +// +// READ-ONLY BY DESIGN. `CompletionSet` deliberately stores no `progress` +// field — see the header note in `completion-set.gts`: the numerator is a +// rollup over CollectionItems and storing it guarantees drift the moment a +// pair is bought or sold. This command computes and returns the same number +// the isolated view shows; it never patches the set. +// +// There is exactly ONE implementation of the arithmetic — `computeCompletion` +// in `completion-set.gts` — imported here, not re-derived. The app, this +// command, and any future consumer all call the same function. +export default class CalculateCompletionCommand extends Command< + typeof CalculateCompletionInput, + typeof CalculateCompletionResult +> { + static actionVerb = 'Calculate completion'; + description = + 'Compute a completion set’s owned/target counts and percentage from the current collection, without storing the result.'; + + async getInputType() { + return CalculateCompletionInput; + } + + protected async run( + input: CalculateCompletionInput, + ): Promise { + let completionSetId = input.completionSetId?.trim(); + if (!completionSetId) { + throw new Error('completionSetId is required'); + } + + let set = (await new GetCardCommand(this.toolContext).execute({ + cardId: completionSetId, + })) as CompletionSet; + if (!set) { + throw new Error(`CompletionSet not found: ${completionSetId}`); + } + + let setProductIds = (set.products ?? []).map((p) => p?.id); + + let itemRef = identifyCard(CollectionItem); + let items = itemRef + ? await new SearchCardsByQueryCommand(this.toolContext).execute({ + query: { filter: { type: itemRef } }, + }) + : undefined; + + let ownedProductIds = ( + (items?.instances ?? []) as CollectionItem[] + ).flatMap((i) => (i?.item?.id ? [i.item.id] : [])); + + let computed = computeCompletion(setProductIds, ownedProductIds); + + let result = new CalculateCompletionResult(); + result.completionSetId = completionSetId; + result.ownedCount = computed.ownedCount; + result.targetCount = computed.targetCount; + result.percent = computed.percent; + return result; + } +} diff --git a/cards/collectibles/commands/create-listing-command.gts b/cards/collectibles/commands/create-listing-command.gts new file mode 100644 index 00000000..e1b87eff --- /dev/null +++ b/cards/collectibles/commands/create-listing-command.gts @@ -0,0 +1,121 @@ +import { Command } from '@cardstack/runtime-common'; +import { + CardDef, + StringField, + contains, + field, + realmURL, +} from '@cardstack/base/card-api'; +import NumberField from '@cardstack/base/number'; +import AmountWithCurrency from '@cardstack/base/amount-with-currency'; +import CurrencyField from '@cardstack/base/currency'; +import GetCardCommand from '@cardstack/boxel-host/commands/get-card'; +import PatchCardInstanceCommand from '@cardstack/boxel-host/commands/patch-card-instance'; +import SaveCardCommand from '@cardstack/boxel-host/commands/save-card'; +import { CollectionItem } from '../collection-item'; +import { Listing } from '../listing'; +import { SellerProfile } from '../seller-profile'; +import { CollectibleProduct } from '../collectible-product'; + +export class CreateListingInput extends CardDef { + @field collectionItemId = contains(StringField); + @field price = contains(NumberField); + @field currency = contains(StringField); + @field shippingPrice = contains(NumberField); + @field shipsFrom = contains(StringField); +} + +export class CreateListingResult extends CardDef { + @field listingId = contains(StringField); +} + +// Create Listing (CL) — marks an owned copy for sale. +// +// THE COLLECTION ITEM'S `forSale` FLAG IS A COMPUTED READ OF `listedAt`, so +// this command writes `listedAt`, never a boolean — same rule +// `collection-item.gts`'s header applies to `verified`: a threshold flag is +// an event fact, and storing the boolean separately is how it drifts from +// the listing that actually caused it. +export default class CreateListingCommand extends Command< + typeof CreateListingInput, + typeof CreateListingResult +> { + static actionVerb = 'Create listing'; + description = + 'List an owned collection item for sale at a given price, denormalizing the seller and product from it.'; + + async getInputType() { + return CreateListingInput; + } + + protected async run(input: CreateListingInput): Promise { + let collectionItemId = input.collectionItemId?.trim(); + if (!collectionItemId) { + throw new Error('collectionItemId is required'); + } + if (input.price == null) { + throw new Error('price is required'); + } + + let collectionItem = (await new GetCardCommand(this.toolContext).execute({ + cardId: collectionItemId, + })) as CollectionItem; + if (!collectionItem) { + throw new Error(`CollectionItem not found: ${collectionItemId}`); + } + if (!collectionItem.owner) { + throw new Error( + `CollectionItem ${collectionItemId} has no owner to list as seller`, + ); + } + if (!(collectionItem.owner instanceof SellerProfile)) { + throw new Error( + `CollectionItem ${collectionItemId}'s owner is not a Seller Profile, so it cannot be listed`, + ); + } + if (!(collectionItem.item instanceof CollectibleProduct)) { + throw new Error( + `CollectionItem ${collectionItemId} is not a Collectible Product, so it cannot be listed`, + ); + } + if (collectionItem.forSale) { + throw new Error(`CollectionItem ${collectionItemId} is already for sale`); + } + + let currency = new CurrencyField({ code: input.currency?.trim() || 'USD' }); + let now = new Date(); + + let listing = new Listing(); + listing.collectionItem = collectionItem; + listing.product = collectionItem.item; + listing.price = new AmountWithCurrency({ amount: input.price, currency }); + listing.shippingPrice = new AmountWithCurrency({ + amount: input.shippingPrice ?? 0, + currency, + }); + listing.listingStatus = 'active'; + listing.shipsFrom = input.shipsFrom?.trim() || ''; + listing.seller = collectionItem.owner; + listing.listedAt = now; + + // Save into the SOURCE card's own realm. Without `realm`, SaveCard + // defaults to the base realm and the write 401s — verified live when a + // ProcessPayment run tried to save its Payment to cardstack.com/base/. + let realm = (collectionItem as any)?.[realmURL]?.href; + let saved = (await new SaveCardCommand(this.toolContext).execute({ + card: listing, + realm, + })) as Listing; + + await new PatchCardInstanceCommand(this.toolContext, { + cardType: CollectionItem, + }).execute({ + cardId: collectionItemId, + patch: { attributes: { listedAt: now.toISOString().slice(0, 10) } }, + }); + + let result = new CreateListingResult(); + result.listingId = saved.id; + return result; + } +} diff --git a/cards/collectibles/commands/create-set-command.gts b/cards/collectibles/commands/create-set-command.gts new file mode 100644 index 00000000..f81b747b --- /dev/null +++ b/cards/collectibles/commands/create-set-command.gts @@ -0,0 +1,92 @@ +import { Command } from '@cardstack/runtime-common'; +import { + CardDef, + StringField, + contains, + field, + realmURL, +} from '@cardstack/base/card-api'; +import BooleanField from '@cardstack/base/boolean'; +import GetCardCommand from '@cardstack/boxel-host/commands/get-card'; +import SaveCardCommand from '@cardstack/boxel-host/commands/save-card'; +import { CompletionSet } from '../completion-set'; + +export class CreateSetInput extends CardDef { + @field name = contains(StringField); + @field goal = contains(StringField); + // Comma-separated product ids — a Command's input schema is a plain CardDef, + // and `containsMany` on an input card is a heavier ask than this needs; the + // command itself does the split/resolve. + @field productIds = contains(StringField); + // 'own-any' | 'own-all' — see completion-set.gts's own CompletionRuleField. + @field completionRule = contains(StringField); + @field isPublic = contains(BooleanField); +} + +export class CreateSetResult extends CardDef { + @field completionSetId = contains(StringField); + @field targetCount = contains(StringField); +} + +// Create Set (CS) — a collector names a completion goal. +// +// MEMBERSHIP IS CURATED, NOT A QUERY. `completion-set.gts`'s own header note +// is explicit: sneaker sets are emergent and personal, so `products` is a +// hand-confirmed `linksToMany`, never a saved filter. This command's whole +// job is resolving the caller's chosen product ids into real links — +// `targetCount` is then a computed field on the card itself, not something +// this command writes. +export default class CreateSetCommand extends Command< + typeof CreateSetInput, + typeof CreateSetResult +> { + static actionVerb = 'Create set'; + description = + 'Create a completion goal from a curated list of products, with a name and a completion rule.'; + + async getInputType() { + return CreateSetInput; + } + + protected async run(input: CreateSetInput): Promise { + let name = input.name?.trim(); + if (!name) { + throw new Error('name is required'); + } + let ids = (input.productIds ?? '') + .split(',') + .map((id) => id.trim()) + .filter(Boolean); + if (ids.length === 0) { + throw new Error('productIds must contain at least one product id'); + } + + let products = await Promise.all( + ids.map((id) => + new GetCardCommand(this.toolContext).execute({ cardId: id }), + ), + ); + + let set = new CompletionSet(); + set.cardInfo.name = name; + set.goal = input.goal?.trim() || ''; + set.products = products as any; + set.completionRule = (input.completionRule?.trim() || + 'own-any') as CompletionSet['completionRule']; + set.isPublic = Boolean(input.isPublic); + + // Save into the SOURCE card's own realm. Without `realm`, SaveCard + // defaults to the base realm and the write 401s — verified live when a + // ProcessPayment run tried to save its Payment to cardstack.com/base/. + let realm = (products[0] as any)?.[realmURL]?.href; + let saved = (await new SaveCardCommand(this.toolContext).execute({ + card: set, + realm, + })) as CompletionSet; + + let result = new CreateSetResult(); + result.completionSetId = saved.id; + result.targetCount = String(saved.targetCount ?? products.length); + return result; + } +} diff --git a/cards/collectibles/commands/mark-delivered-command.gts b/cards/collectibles/commands/mark-delivered-command.gts new file mode 100644 index 00000000..faaf6791 --- /dev/null +++ b/cards/collectibles/commands/mark-delivered-command.gts @@ -0,0 +1,126 @@ +import { Command } from '@cardstack/runtime-common'; +import { + CardDef, + StringField, + contains, + field, +} from '@cardstack/base/card-api'; +import GetCardCommand from '@cardstack/boxel-host/commands/get-card'; +import PatchCardInstanceCommand from '@cardstack/boxel-host/commands/patch-card-instance'; +import { Order, canOrderTransition } from '../sole-vault-order'; +import { Shipment, canShipmentTransition } from '../sole-vault-shipment'; + +export class MarkDeliveredInput extends CardDef { + @field shipmentId = contains(StringField); +} + +export class MarkDeliveredResult extends CardDef { + @field shipmentId = contains(StringField); + @field orderId = contains(StringField); + @field orderStatus = contains(StringField); +} + +// Mark Delivered (MD) — a carrier scan lands this leg. +// +// LEG-AWARE, BECAUSE THE ORDER GRAPH IS. The two-leg route (seller → +// auth centre → buyer) means "delivered" means two different things to the +// Order depending on WHICH leg just arrived: +// * the seller-to-auth leg landing is "item received at the auth centre" — +// the Order moves `shipped` → `authenticating`, NOT to `delivered`. +// * the auth-to-buyer leg landing is delivery to the actual buyer — the +// Order moves `authenticating` → `delivered`. +// Reading `shipment.leg` rather than always advancing to the "next" status is +// what keeps a first-leg scan from wrongly reporting the whole order as +// delivered to the buyer. +// +// HONEST GAP: `delivered` → `completed` (the buyer's own "I received it and +// it's fine" confirmation that releases funds) has no command. It is left an +// explicit, undone edge rather than silently folded into a +// carrier-scan event, which is not the same fact as a buyer confirming +// receipt. +export default class MarkDeliveredCommand extends Command< + typeof MarkDeliveredInput, + typeof MarkDeliveredResult +> { + static actionVerb = 'Mark delivered'; + description = + 'Record a shipment leg as delivered and advance the order status appropriately for that leg.'; + + async getInputType() { + return MarkDeliveredInput; + } + + protected async run(input: MarkDeliveredInput): Promise { + let shipmentId = input.shipmentId?.trim(); + if (!shipmentId) { + throw new Error('shipmentId is required'); + } + + let shipment = (await new GetCardCommand(this.toolContext).execute({ + cardId: shipmentId, + })) as Shipment; + if (!shipment) { + throw new Error(`Shipment not found: ${shipmentId}`); + } + if (!canShipmentTransition(shipment.shipmentStatus, 'delivered')) { + throw new Error( + `Shipment ${shipmentId} cannot move from ${shipment.shipmentStatus} to delivered`, + ); + } + + let now = new Date(); + await new PatchCardInstanceCommand(this.toolContext, { + cardType: Shipment, + }).execute({ + cardId: shipmentId, + patch: { + attributes: { + shipmentStatus: 'delivered', + deliveredAt: now.toISOString().slice(0, 10), + }, + }, + }); + + let result = new MarkDeliveredResult(); + result.shipmentId = shipmentId; + + let orderId = shipment.order?.id; + if (!orderId) { + // A shipment with no order is a real state — nothing more to advance. + return result; + } + result.orderId = orderId; + + let order = (await new GetCardCommand(this.toolContext).execute({ + cardId: orderId, + })) as Order; + + let nextOrderStatus = + shipment.leg === 'auth-to-buyer' ? 'delivered' : 'authenticating'; + + if (!canOrderTransition(order.orderStatus, nextOrderStatus)) { + // The shipment leg still gets recorded above; the order simply does not + // move (e.g. a second scan on an already-advanced order). + result.orderStatus = order.orderStatus ?? undefined; + return result; + } + + await new PatchCardInstanceCommand(this.toolContext, { + cardType: Order, + }).execute({ + cardId: orderId, + patch: { + attributes: + nextOrderStatus === 'authenticating' + ? { orderStatus: 'authenticating' } + : { + orderStatus: 'delivered', + deliveredAt: now.toISOString().slice(0, 10), + }, + }, + }); + + result.orderStatus = nextOrderStatus; + return result; + } +} diff --git a/cards/collectibles/commands/place-order-command.gts b/cards/collectibles/commands/place-order-command.gts new file mode 100644 index 00000000..fa83143f --- /dev/null +++ b/cards/collectibles/commands/place-order-command.gts @@ -0,0 +1,149 @@ +import { Command } from '@cardstack/runtime-common'; +import { + CardDef, + StringField, + contains, + field, + realmURL, +} from '@cardstack/base/card-api'; +import AmountWithCurrency from '@cardstack/base/amount-with-currency'; +import GetCardCommand from '@cardstack/boxel-host/commands/get-card'; +import PatchCardInstanceCommand from '@cardstack/boxel-host/commands/patch-card-instance'; +import SaveCardCommand from '@cardstack/boxel-host/commands/save-card'; +import { Listing } from '../listing'; +import { Order } from '../sole-vault-order'; + +export class PlaceOrderInput extends CardDef { + @field listingId = contains(StringField); + @field buyerId = contains(StringField); +} + +export class PlaceOrderResult extends CardDef { + @field orderId = contains(StringField); + @field reference = contains(StringField); +} + +// Place Order (PO) — a buyer commits to a listing. +// +// FEES ARE SNAPSHOTS, PICKED HERE, NOT A RATE RE-DERIVED LATER. The auth fee +// and platform-fee percentage are the checkout's own numbers, applied once at +// order-creation time (a flat $10 authentication fee and a 3% platform fee) — +// see `order.gts`'s header note on +// why `Order.platformFee` holds the money actually charged, never "3%". +// +// LISTING GOES `sold` HERE, NOT ON PAYMENT. `ListingStatusField`'s own +// `active` meaning is "visible to buyers and accepting offers"; `sold` means +// "a buyer committed — the item is spoken for". That commitment happens the +// moment an order is placed, before money moves, which is why this command — +// not ProcessPayment — is the one that flips it. +// `AmountWithCurrency.amount` is a decimal figure in the currency's own major +// unit (dollars, not cents — see `money-format.ts`'s header note on why this +// cluster never scales to minor units), so $10.00 is the literal number 10. +const AUTH_FEE_AMOUNT = 10; // $10.00, flat +const PLATFORM_FEE_RATE = 0.03; // 3% + +export default class PlaceOrderCommand extends Command< + typeof PlaceOrderInput, + typeof PlaceOrderResult +> { + static actionVerb = 'Place order'; + description = + 'Create an order from an active listing, snapshotting price and fees, and mark the listing sold.'; + + async getInputType() { + return PlaceOrderInput; + } + + protected async run(input: PlaceOrderInput): Promise { + let listingId = input.listingId?.trim(); + let buyerId = input.buyerId?.trim(); + if (!listingId) { + throw new Error('listingId is required'); + } + if (!buyerId) { + throw new Error('buyerId is required'); + } + + let listing = (await new GetCardCommand(this.toolContext).execute({ + cardId: listingId, + })) as Listing; + if (!listing) { + throw new Error(`Listing not found: ${listingId}`); + } + if (listing.listingStatus !== 'active') { + throw new Error( + `Listing ${listingId} is not active (status: ${listing.listingStatus}) — cannot place an order against it`, + ); + } + if (!listing.price?.amount || !listing.price?.currency) { + throw new Error(`Listing ${listingId} has no price set`); + } + let sellerId = listing.seller?.id; + if (!sellerId) { + throw new Error(`Listing ${listingId} has no seller`); + } + + let currency = listing.price.currency; + let priceAmount = listing.price.amount; + let shippingAmount = listing.shippingPrice?.amount ?? 0; + let authFeeAmount = AUTH_FEE_AMOUNT; + // Rounded to the nearest cent, not the nearest dollar — `amount` carries + // two decimal places. + let platformFeeAmount = + Math.round(priceAmount * PLATFORM_FEE_RATE * 100) / 100; + + let now = new Date(); + let reference = `SV-${now.getFullYear()}-${Math.floor( + 1000 + Math.random() * 9000, + )}`; + + let order = new Order(); + order.reference = reference; + order.listing = listing; + order.buyer = (await new GetCardCommand(this.toolContext).execute({ + cardId: buyerId, + })) as any; + order.seller = listing.seller; + // Real AmountWithCurrency instances, not `{ amount, currency }` literals + // — a plain object throws when the searchable walker's peekAtField + // reaches it (see order.gts's `total` computeVia for the same note). + order.price = new AmountWithCurrency({ amount: priceAmount, currency }); + order.shippingPrice = new AmountWithCurrency({ + amount: shippingAmount, + currency, + }); + order.authFee = new AmountWithCurrency({ + amount: authFeeAmount, + currency, + }); + order.platformFee = new AmountWithCurrency({ + amount: platformFeeAmount, + currency, + }); + order.orderStatus = 'pending-payment'; + order.placedAt = now; + + let realm = listing[realmURL]?.href; + let saved = (await new SaveCardCommand(this.toolContext).execute({ + card: order, + realm, + })) as Order; + + await new PatchCardInstanceCommand(this.toolContext, { + cardType: Listing, + }).execute({ + cardId: listingId, + patch: { + attributes: { + listingStatus: 'sold', + soldAt: now.toISOString().slice(0, 10), + }, + }, + }); + + let result = new PlaceOrderResult(); + result.orderId = saved.id; + result.reference = reference; + return result; + } +} diff --git a/cards/collectibles/commands/process-payment-command.gts b/cards/collectibles/commands/process-payment-command.gts new file mode 100644 index 00000000..84f06e03 --- /dev/null +++ b/cards/collectibles/commands/process-payment-command.gts @@ -0,0 +1,108 @@ +import { Command } from '@cardstack/runtime-common'; +import { + CardDef, + StringField, + contains, + field, + realmURL, +} from '@cardstack/base/card-api'; +import GetCardCommand from '@cardstack/boxel-host/commands/get-card'; +import PatchCardInstanceCommand from '@cardstack/boxel-host/commands/patch-card-instance'; +import SaveCardCommand from '@cardstack/boxel-host/commands/save-card'; +import { Order, canOrderTransition } from '../sole-vault-order'; +import { Payment } from '../sole-vault-payment'; + +export class ProcessPaymentInput extends CardDef { + @field orderId = contains(StringField); + @field processor = contains(StringField); +} + +export class ProcessPaymentResult extends CardDef { + @field paymentId = contains(StringField); + @field orderStatus = contains(StringField); +} + +// Process Payment (PP) — charges the buyer's escrow-held total. +// +// A SIMULATION, STATED PLAINLY. `order.gts`'s own header note says it: a +// realm cannot hold funds, so this records a `Payment` in the `captured` +// state directly rather than modelling `pending`→`authorized`→`captured` as +// a real processor round-trip. What matters for the escrow graph is the +// EFFECT — the order moves to `paid` and the total is now held — not a +// faithful simulation of a payment gateway's own async state machine. +export default class ProcessPaymentCommand extends Command< + typeof ProcessPaymentInput, + typeof ProcessPaymentResult +> { + static actionVerb = 'Process payment'; + description = + 'Charge the order total and move the order from pending-payment to paid, recording a Payment.'; + + async getInputType() { + return ProcessPaymentInput; + } + + protected async run( + input: ProcessPaymentInput, + ): Promise { + let orderId = input.orderId?.trim(); + if (!orderId) { + throw new Error('orderId is required'); + } + + let order = (await new GetCardCommand(this.toolContext).execute({ + cardId: orderId, + })) as Order; + if (!order) { + throw new Error(`Order not found: ${orderId}`); + } + if (!canOrderTransition(order.orderStatus, 'paid')) { + throw new Error( + `Order ${orderId} cannot move from ${order.orderStatus} to paid`, + ); + } + if (!order.total?.amount) { + throw new Error(`Order ${orderId} has no total to charge`); + } + + let now = new Date(); + let processorReference = `sim-pi-${Math.random().toString(36).slice(2, 10)}`; + + let payment = new Payment(); + payment.order = order; + payment.amount = order.total; + payment.paymentState = 'captured'; + payment.direction = 'charge'; + payment.processor = input.processor?.trim() || 'Simulated Processor'; + payment.processorReference = processorReference; + payment.initiatedAt = now; + payment.settledAt = now; + + // Save into the SOURCE card's own realm. Without `realm`, SaveCard + // defaults to the base realm and the write 401s — verified live when a + // ProcessPayment run tried to save its Payment to cardstack.com/base/. + let realm = (order as any)?.[realmURL]?.href; + let saved = (await new SaveCardCommand(this.toolContext).execute({ + card: payment, + realm, + })) as Payment; + + await new PatchCardInstanceCommand(this.toolContext, { + cardType: Order, + }).execute({ + cardId: orderId, + patch: { + attributes: { + orderStatus: 'paid', + paidAt: now.toISOString().slice(0, 10), + paymentReference: processorReference, + }, + }, + }); + + let result = new ProcessPaymentResult(); + result.paymentId = saved.id; + result.orderStatus = 'paid'; + return result; + } +} diff --git a/cards/collectibles/commands/refund-order-command.gts b/cards/collectibles/commands/refund-order-command.gts new file mode 100644 index 00000000..0042eda2 --- /dev/null +++ b/cards/collectibles/commands/refund-order-command.gts @@ -0,0 +1,102 @@ +import { Command } from '@cardstack/runtime-common'; +import { + CardDef, + StringField, + contains, + field, + realmURL, +} from '@cardstack/base/card-api'; +import GetCardCommand from '@cardstack/boxel-host/commands/get-card'; +import PatchCardInstanceCommand from '@cardstack/boxel-host/commands/patch-card-instance'; +import SaveCardCommand from '@cardstack/boxel-host/commands/save-card'; +import { Order, canOrderTransition } from '../sole-vault-order'; +import { Payment } from '../sole-vault-payment'; + +export class RefundOrderInput extends CardDef { + @field orderId = contains(StringField); + @field reason = contains(StringField); +} + +export class RefundOrderResult extends CardDef { + @field paymentId = contains(StringField); + @field orderStatus = contains(StringField); +} + +// Refund Order (RO) — unwinds a held order, most often an authentication +// failure. `AuthenticateItem`'s own comment says a failed verdict "refunds +// the buyer" — this is the command that actually moves the money. +// +// A REFUND IS A NEW PAYMENT RECORD, NEVER AN EDIT TO THE ORIGINAL CHARGE. +// `PaymentStateField`'s own transition graph says the same thing: `captured` +// only ever leads to `refunded`, one-way, and `payment.gts`'s `signedAmount` +// renders a refund's `direction: 'refund'` with a minus sign rather than +// mutating the charge's own amount. Two ledger rows, not one row that +// changed its mind. +export default class RefundOrderCommand extends Command< + typeof RefundOrderInput, + typeof RefundOrderResult +> { + static actionVerb = 'Refund order'; + description = + 'Record a refund payment for the order total and move the order to refunded.'; + + async getInputType() { + return RefundOrderInput; + } + + protected async run(input: RefundOrderInput): Promise { + let orderId = input.orderId?.trim(); + if (!orderId) { + throw new Error('orderId is required'); + } + + let order = (await new GetCardCommand(this.toolContext).execute({ + cardId: orderId, + })) as Order; + if (!order) { + throw new Error(`Order not found: ${orderId}`); + } + if (!canOrderTransition(order.orderStatus, 'refunded')) { + throw new Error( + `Order ${orderId} cannot move from ${order.orderStatus} to refunded`, + ); + } + if (!order.total?.amount) { + throw new Error(`Order ${orderId} has no total to refund`); + } + + let now = new Date(); + + let payment = new Payment(); + payment.order = order; + payment.amount = order.total; + payment.paymentState = 'refunded'; + payment.direction = 'refund'; + payment.processor = 'Simulated Processor'; + payment.processorReference = `sim-re-${Math.random().toString(36).slice(2, 10)}`; + payment.initiatedAt = now; + payment.settledAt = now; + payment.reason = input.reason?.trim() || ''; + + // Save into the SOURCE card's own realm. Without `realm`, SaveCard + // defaults to the base realm and the write 401s — verified live when a + // ProcessPayment run tried to save its Payment to cardstack.com/base/. + let realm = (order as any)?.[realmURL]?.href; + let saved = (await new SaveCardCommand(this.toolContext).execute({ + card: payment, + realm, + })) as Payment; + + await new PatchCardInstanceCommand(this.toolContext, { + cardType: Order, + }).execute({ + cardId: orderId, + patch: { attributes: { orderStatus: 'refunded' } }, + }); + + let result = new RefundOrderResult(); + result.paymentId = saved.id; + result.orderStatus = 'refunded'; + return result; + } +} diff --git a/cards/collectibles/commands/sole-vault-dispatch-shipment-command.gts b/cards/collectibles/commands/sole-vault-dispatch-shipment-command.gts new file mode 100644 index 00000000..9418d2f1 --- /dev/null +++ b/cards/collectibles/commands/sole-vault-dispatch-shipment-command.gts @@ -0,0 +1,114 @@ +import { Command } from '@cardstack/runtime-common'; +import { + CardDef, + StringField, + contains, + field, + realmURL, +} from '@cardstack/base/card-api'; +import GetCardCommand from '@cardstack/boxel-host/commands/get-card'; +import PatchCardInstanceCommand from '@cardstack/boxel-host/commands/patch-card-instance'; +import SaveCardCommand from '@cardstack/boxel-host/commands/save-card'; +import { Order, canOrderTransition } from '../sole-vault-order'; +import { Shipment } from '../sole-vault-shipment'; + +export class DispatchShipmentInput extends CardDef { + @field orderId = contains(StringField); + @field carrier = contains(StringField); + @field trackingNumber = contains(StringField); + @field trackingUrl = contains(StringField); + @field shippedFrom = contains(StringField); + @field shippedTo = contains(StringField); + // 'seller-to-auth' | 'auth-to-buyer': the two legs of the authentication- + // centre route. Freeform on purpose, same call as + // `shipment.gts`'s own `leg` field: the block names no fixed vocabulary. + @field leg = contains(StringField); +} + +export class DispatchShipmentResult extends CardDef { + @field shipmentId = contains(StringField); + @field orderStatus = contains(StringField); +} + +// Dispatch Shipment (DS) — the seller hands a parcel to a carrier. +// +// It works on Sole Vault's Order and Shipment. The fulfilment cluster's own +// dispatch command patches a Fulfilment Order and quotes through a Carrier, +// which this marketplace does not have. +export default class DispatchShipmentCommand extends Command< + typeof DispatchShipmentInput, + typeof DispatchShipmentResult +> { + static actionVerb = 'Dispatch shipment'; + description = + 'Create a shipment leg for a paid order, and move the order from paid to shipped.'; + + async getInputType() { + return DispatchShipmentInput; + } + + protected async run( + input: DispatchShipmentInput, + ): Promise { + let orderId = input.orderId?.trim(); + if (!orderId) { + throw new Error('orderId is required'); + } + let trackingNumber = input.trackingNumber?.trim(); + if (!trackingNumber) { + throw new Error('trackingNumber is required'); + } + + let order = (await new GetCardCommand(this.toolContext).execute({ + cardId: orderId, + })) as Order; + if (!order) { + throw new Error(`Order not found: ${orderId}`); + } + if (!canOrderTransition(order.orderStatus, 'shipped')) { + throw new Error( + `Order ${orderId} cannot move from ${order.orderStatus} to shipped`, + ); + } + + let now = new Date(); + + let shipment = new Shipment(); + shipment.order = order; + shipment.leg = input.leg?.trim() || 'seller-to-auth'; + shipment.shipmentStatus = 'label-created'; + shipment.carrier = input.carrier?.trim() || ''; + shipment.trackingNumber = trackingNumber; + shipment.trackingUrl = input.trackingUrl?.trim() || ''; + shipment.shippedFrom = input.shippedFrom?.trim() || ''; + shipment.shippedTo = input.shippedTo?.trim() || ''; + shipment.labelCreatedAt = now; + + // Save into the SOURCE card's own realm. Without `realm`, SaveCard + // defaults to the base realm and the write 401s — verified live when a + // ProcessPayment run tried to save its Payment to cardstack.com/base/. + let realm = (order as any)?.[realmURL]?.href; + let saved = (await new SaveCardCommand(this.toolContext).execute({ + card: shipment, + realm, + })) as Shipment; + + await new PatchCardInstanceCommand(this.toolContext, { + cardType: Order, + }).execute({ + cardId: orderId, + patch: { + attributes: { + orderStatus: 'shipped', + shippedAt: now.toISOString().slice(0, 10), + trackingNumber, + }, + }, + }); + + let result = new DispatchShipmentResult(); + result.shipmentId = saved.id; + result.orderStatus = 'shipped'; + return result; + } +} diff --git a/cards/collectibles/completion-set.gts b/cards/collectibles/completion-set.gts new file mode 100644 index 00000000..d68493ff --- /dev/null +++ b/cards/collectibles/completion-set.gts @@ -0,0 +1,1201 @@ +import { + CardDef, + field, + contains, + linksToMany, + StringField, + Component, + realmURL, +} from '@cardstack/base/card-api'; +import BooleanField from '@cardstack/base/boolean'; +import MarkdownField from '@cardstack/base/markdown'; +import NumberField from '@cardstack/base/number'; +import enumField from '@cardstack/base/enum'; +import ImageSourceField from '@cardstack/catalog/fields/image-source/image-source'; +import { CollectibleProduct } from './collectible-product'; +import TargetArrowIcon from '@cardstack/boxel-icons/target-arrow'; +import BadgeCheckIcon from '@cardstack/boxel-icons/badge-check'; +import FileTextIcon from '@cardstack/boxel-icons/file-text'; +import NotebookPenIcon from '@cardstack/boxel-icons/notebook-pen'; +import { tracked } from '@glimmer/tracking'; +import { + FittedCard, + LoadingIndicator, + Pill, + ProgressBar, + FieldContainer, + Accordion, +} from '@cardstack/boxel-ui/components'; +import { eq } from '@cardstack/boxel-ui/helpers'; +import { identifyCard } from '@cardstack/runtime-common'; +// CollectionItem is read ONLY inside the isolated component's methods, never at +// module-evaluation time. That is what keeps this import cycle legal — +// collection-item.gts does not import this module today, but the reverse query +// below is the shape that creates one, so do not hoist this usage to the top +// level "for clarity". +import { CollectionItem } from './collection-item'; + +// CompletionSet — a curated collecting goal and the arithmetic for how far along +// it is. "All Travis Scott x Nike", "every Chicago colourway", "my top 10 grails". +// +// THE ONE DECISION THAT SHAPES THIS BLOCK: membership is DATA, progress is +// DERIVED. Those are opposite answers to the same-looking question, so both are +// spelled out: +// +// `products` is a curated linksToMany, NOT a query. A collecting set is not +// "everything matching a rule": sneaker sets are emergent and personal, and +// the user hand-confirms the list. Ordering is +// meaningful. That is textbook curated membership, so it is a link. +// +// `progress` / `ownedCount` / `missingProducts` are NOT stored and are NOT +// fields. They are a rollup over the owner's CollectionItems intersected with +// this list — unbounded, owner-dependent, and different for every viewer of a +// public set. Storing them guarantees drift the moment a pair is bought or +// sold. The arithmetic ships as the exported pure function below so the app, +// the card and any future consumer share ONE implementation instead of three +// subtly different ones. + +export type CompletionRule = 'own-any' | 'own-all'; + +export const COMPLETION_RULES = [ + { + value: 'own-any', + label: 'Own any variant', + }, + { + value: 'own-all', + label: 'Own every variant', + }, +]; + +const CompletionRuleField = enumField(StringField, { + options: COMPLETION_RULES, + displayName: 'Completion Rule', +}); + +export interface CompletionResult { + ownedCount: number; + targetCount: number; + /** 0–100, rounded. 0 when the set is empty — never NaN. */ + percent: number; + /** Product ids in the set that the owner does not have. Order preserved. */ + missingProductIds: string[]; +} + +/** + * The single implementation of set completion. + * + * Pure on purpose: it takes the ids rather than the cards, so it needs no store, + * no query and no realm — which is what lets the app, a command and a test all + * call the same function. `ownedProductIds` is whatever the caller's own query + * resolved (typically `CollectionItem.item.id` for one owner). + * + * Guards the two cases that silently produce a wrong number rather than an error: + * a set with no products (percent must be 0, not NaN from a divide-by-zero), and + * dead link slots (a deleted product leaves an `undefined` in the array while + * `.length` still counts it, so a raw length would inflate the denominator). + */ +export function computeCompletion( + setProductIds: (string | undefined | null)[], + ownedProductIds: Iterable, +): CompletionResult { + let target = setProductIds.filter(Boolean) as string[]; + let owned = new Set(ownedProductIds); + let missing = target.filter((id) => !owned.has(id)); + let ownedCount = target.length - missing.length; + return { + ownedCount, + targetCount: target.length, + percent: + target.length === 0 ? 0 : Math.round((ownedCount / target.length) * 100), + missingProductIds: missing, + }; +} + +class CompletionSetEdit extends Component { + @tracked goalOpen = true; + @tracked membersOpen = true; + + toggleGoal = () => (this.goalOpen = !this.goalOpen); + toggleMembers = () => (this.membersOpen = !this.membersOpen); + + +} + +export class CompletionSet extends CardDef { + static displayName = 'Completion Set'; + static icon = TargetArrowIcon; + + @field goal = contains(MarkdownField); + @field coverImage = contains(ImageSourceField); + + // Curated membership. See the header note — this is deliberately a link. + @field products = linksToMany(() => CollectibleProduct, { searchable: true }); + + @field completionRule = contains(CompletionRuleField); + @field isPublic = contains(BooleanField); + + // The DENOMINATOR is safe to store as a computed field: it depends only on this + // card's own link array, not on any owner's collection. The numerator is not, + // which is exactly why only one of the two lives here. + @field targetCount = contains(NumberField, { + computeVia: function (this: CompletionSet) { + return (this.products ?? []).filter(Boolean).length; + }, + }); + + // ISOLATED — the goal's landing page. + // + // DOMAIN QUESTION: "how close am I, and what is still missing?" Neither half + // is a field on this card, and that is deliberate — see the header note: + // progress is a rollup over the owner's CollectionItems, so storing it here + // would let it drift from the collection it describes. + // + // So the answer is a REVERSE QUERY, which is what an isolated view can do and + // the embedded view cannot. The embedded template's comment says a bar drawn + // without owner context "would be asserting a number this card cannot know" — + // that remains true there. Here the card asks, gets the collection, and + // intersects it through the block's OWN exported `computeCompletion`, so there + // is still exactly one definition of "80% complete" in the realm. + // + // Direction: Instrument, same as the rest of the family. Signature element: + // the vault plaque (gold hairline over the headline figure) plus the shared + // ProgressBar meter the app already uses for this same number. + // EDIT — only five editable fields total, so Rule 0's grouping stays light: + // one compact identity row (name lives on cardInfo, rendered by the default + // schema chrome) plus two sections, both open by default since there is + // nothing here dense enough to want collapsing away. + static edit = CompletionSetEdit; + + static isolated = class Isolated extends Component { + get realms() { + let realmUrl = this.args.model?.[realmURL]; + return realmUrl ? [realmUrl.href] : []; + } + + // The reverse query: every CollectionItem in the realm, intersected against + // this set's curated product links below. + // + // A plain `type` filter, so it needs no `on:` anchor — that requirement + // belongs to eq/contains/range filters, where an unanchored field path + // resolves against CardDef and 500s. Worth stating because the obvious + // "optimisation" here is to push the intersection into the query as + // `eq: { 'item.id': ... }`, and THAT form does need the anchor. + // + // `isLive` so acquiring or selling a pair moves the percentage without a + // reload — the number is only trustworthy if it cannot go stale. + private itemsQuery = this.args.context?.getCards( + this, + () => { + let ref = identifyCard(CollectionItem); + return ref ? { filter: { type: ref } } : undefined; + }, + () => this.realms, + { isLive: true }, + ); + + // Read `.instances` at the point of use and filter dead slots: a deleted + // target leaves its slot in place, so a raw iteration renders an empty row + // and a raw `.length` counts members that no longer exist. + get ownedProductIds(): Set { + let ids = new Set(); + for (let item of (this.itemsQuery?.instances ?? []) as CollectionItem[]) { + let id = item?.item?.id; + if (id) { + ids.add(id); + } + } + return ids; + } + + get products(): CollectibleProduct[] { + return ((this.args.model?.products ?? []) as CollectibleProduct[]).filter( + Boolean, + ); + } + + get result() { + return computeCompletion( + this.products.map((p) => p?.id), + this.ownedProductIds, + ); + } + + get owned(): CollectibleProduct[] { + let ids = this.ownedProductIds; + return this.products.filter((p) => p?.id && ids.has(p.id)); + } + + get missing(): CollectibleProduct[] { + let ids = this.ownedProductIds; + return this.products.filter((p) => !p?.id || !ids.has(p.id)); + } + + // The query is absent when there is no `context` (some render modes have + // none) and empty before it resolves. Those are different states and only + // one of them should paint a "0% complete" claim. + get isLoading() { + return Boolean(this.itemsQuery) && !this.itemsQuery?.instances; + } + + + }; + + // FITTED — FittedCard, same fork and same knobs as the rest of the family. + // + // NO PROGRESS BAR HERE, and this is the important decision rather than an + // omission. Progress needs the owner's collection, which means a query, and a + // prerendered fitted tile can neither run a query nor resolve a link — so any + // percentage drawn here would be an invented number. The isolated view runs + // that query; the tile shows only what this card actually owns. + // + // SLOT DISCIPLINE — three distinct facts, three slots. `<:subtitle>` and + // `<:meta>` are not rendered: the remaining values are the rule (already the + // eyebrow) and the count (already the footer). + static fitted = class Fitted extends Component { + get countLabel() { + let n = this.args.model?.targetCount ?? 0; + return n === 1 ? '1 product' : `${n} products`; + } + + + }; + + static atom = class Atom extends Component { + + }; + + static embedded = class Embedded extends Component { + + }; +} + +export default CompletionSet; diff --git a/cards/collectibles/listing.gts b/cards/collectibles/listing.gts new file mode 100644 index 00000000..e93f710a --- /dev/null +++ b/cards/collectibles/listing.gts @@ -0,0 +1,1245 @@ +import { + CardDef, + field, + contains, + linksTo, + StringField, + Component, + realmURL, + getComponent, +} from '@cardstack/base/card-api'; +import DateField from '@cardstack/base/date'; +import BooleanField from '@cardstack/base/boolean'; +import MarkdownField from '@cardstack/base/markdown'; +import AmountWithCurrency from '@cardstack/base/amount-with-currency'; +import { statusField } from '@cardstack/catalog/fields/status/status'; +import MultiImageSourceField from '@cardstack/catalog/fields/multi-image-source/multi-image-source'; +import { tracked } from '@glimmer/tracking'; +import { + Accordion, + FieldContainer, + FittedCard, + LoadingIndicator, + Pill, +} from '@cardstack/boxel-ui/components'; +import { identifyCard } from '@cardstack/runtime-common'; +import { CollectionItem } from './collection-item'; +import { CollectibleProduct } from './collectible-product'; +import { SellerProfile } from './seller-profile'; +// CYCLE-SAFE IMPORT. offer.gts imports THIS module, so this is circular by +// construction. It is legal only because `identifyCard(Offer)` is read inside +// the isolated view's reverse-query getter and never at module-evaluation +// time. Do not lift it into a top-level constant. +import { Offer } from './sole-vault-offer'; +import TagIcon from '@cardstack/boxel-icons/tag'; +import HandshakeIcon from '@cardstack/boxel-icons/handshake'; +import ArchiveIcon from '@cardstack/boxel-icons/archive'; +import TruckIcon from '@cardstack/boxel-icons/truck'; +import NotebookIcon from '@cardstack/boxel-icons/notebook'; +import AlertTriangleIcon from '@cardstack/boxel-icons/alert-triangle'; +import { formatMoney } from './money-format'; + +// Listing — one seller offering one owned item for sale. +// +// Not the catalog app's Listing (a catalog entry) and not a Property Listing: +// this is a marketplace offer of one physical item. +// +// EVERY FIELD A TILE NEEDS IS AN ATTRIBUTE. A marketplace's whole UI is grids of +// listings, grids are prerendered fitted, and prerendered fitted cannot resolve +// linksTo — so `productTitle`, `variantLabel` and `conditionCode` are computed +// (resolved at index time, stored in the search doc) rather than read through +// `collectionItem.*` in a template. Without that every tile in the marketplace +// renders blank, which is the single most expensive version of this mistake. + +export type ListingStatus = 'active' | 'sold' | 'cancelled' | 'expired'; + +// A `statusField`, not a bare `enumField`: a dropdown would happily move a +// listing from `sold` back to `active`, or `cancelled` straight to `sold`. The +// catalog's Status block carries the part that matters: +// the transition graph, so the field can answer "may I?" rather than only +// "what are the choices?". It also carries `hue` as data and renders through +// StatePill, which is the self-diluting chip pattern — so status colour stops +// being a class list in each consumer's CSS. +// +// `nextStatuses(ListingStatusField, current)` is what an action menu should +// offer; `canTransition(...)` is what a command should check before writing. +export const ListingStatusField = statusField({ + displayName: 'Listing Status', + options: [ + { + value: 'active', + label: 'Active', + hue: 'green', + meaning: 'Visible to buyers and accepting offers.', + }, + { + value: 'sold', + label: 'Sold', + hue: 'teal', + terminal: true, + holds: true, + meaning: 'A buyer committed. The item is spoken for.', + }, + { + value: 'cancelled', + label: 'Cancelled', + hue: 'slate', + terminal: true, + holds: true, + meaning: 'The seller pulled it. Nothing is owed either way.', + }, + { + value: 'expired', + label: 'Expired', + hue: 'amber', + terminal: true, + holds: true, + meaning: 'Ran past its own expiry date without selling.', + }, + ], + // A sale is not reversible by editing a dropdown — unwinding one is a refund, + // which is a command with money in it, not a status change. So `sold` leads + // nowhere here. `cancelled` and `expired` may be relisted. + transitions: { + active: ['sold', 'cancelled', 'expired'], + cancelled: ['active'], + expired: ['active'], + sold: [], + }, +}); + +class ListingEdit extends Component { + @tracked datesOpen = true; + @tracked fulfilmentOpen = false; + @tracked photosOpen = false; + @tracked notesOpen = false; + + toggleDates = () => (this.datesOpen = !this.datesOpen); + toggleFulfilment = () => (this.fulfilmentOpen = !this.fulfilmentOpen); + togglePhotos = () => (this.photosOpen = !this.photosOpen); + toggleNotes = () => (this.notesOpen = !this.notesOpen); + + +} + +export class Listing extends CardDef { + static displayName = 'Listing'; + static icon = TagIcon; + + // The specific owned copy being sold. + @field collectionItem = linksTo(() => CollectionItem, { searchable: true }); + + // Denormalized link to the catalogue row, so "all listings for this SKU" is one + // query rather than a walk through collectionItem. + @field product = linksTo(() => CollectibleProduct, { searchable: true }); + + @field price = contains(AmountWithCurrency); + @field shippingPrice = contains(AmountWithCurrency); + + @field listingStatus = contains(ListingStatusField); + @field shipsFrom = contains(StringField); + @field photos = contains(MultiImageSourceField); + @field listingNotes = contains(MarkdownField); + + // SellerProfile (a SoleVaultPerson with a rating) rather than a bare + // CardDef: it gives a listing row the seller's name, avatar and rating + // without this card modelling a person. + @field seller = linksTo(() => SellerProfile, { searchable: true }); + + // Lifecycle as event facts, same rule as CollectionItem: store the date the + // thing happened, derive any boolean from it. + @field listedAt = contains(DateField); + @field soldAt = contains(DateField); + @field expiresAt = contains(DateField); + + // --- denormalized for prerendered fitted; see the header note --- + @field productTitle = contains(StringField, { + computeVia: function (this: Listing) { + return ( + this.product?.displayTitle ?? + this.collectionItem?.itemTitle ?? + this.cardInfo?.name ?? + '' + ); + }, + }); + + @field variantLabel = contains(StringField, { + computeVia: function (this: Listing) { + return this.collectionItem?.variantLabel ?? ''; + }, + }); + + @field conditionCode = contains(StringField, { + computeVia: function (this: Listing) { + return this.collectionItem?.condition?.code ?? ''; + }, + }); + + // Boolean, not a string. `CollectionItem.verified` models the identical concept + // as a BooleanField, and two cards disagreeing about the type of one concept is + // how a consumer ends up rendering the literal text "verified" in a badge. Only + // consumed via `{{#if}}`, and computed rather than stored, so this costs no + // migration. + @field verified = contains(BooleanField, { + computeVia: function (this: Listing) { + return Boolean(this.collectionItem?.verified); + }, + }); + + // ISOLATED — the listing's landing page. Object direction: the photo leads, + // full-bleed, because a marketplace listing IS the thing being shown for + // sale. Crossed with Instrument for the price: the plaque is a real gold + // surface, not a hairline accent, because the asking price is the reason + // anyone opened this card. + // + // Domain question: "what is being sold, for how much, and is anyone biting?" + // The first two are the hero (photo + plaque price + status); the third is a + // REVERSE QUERY over Offers — an offer links to its listing, not the other + // way round, so the negotiation activity on this listing is a query, not a + // field. Same shape and same cycle caveat as Order's shipments/payments. + static isolated = class Isolated extends Component { + get realms() { + let realmUrl = this.args.model?.[realmURL]; + return realmUrl ? [realmUrl.href] : []; + } + + // `identifyCard(Offer)` only ever runs inside this getter — never at + // module-evaluation time — which is what makes the circular import legal. + private offersQuery = this.args.context?.getCards( + this, + () => { + let ref = identifyCard(Offer); + let id = this.args.model?.id; + return ref && id + ? { filter: { on: ref, every: [{ eq: { 'listing.id': id } }] } } + : undefined; + }, + () => this.realms, + { isLive: true }, + ); + + get offers() { + return (this.offersQuery?.instances ?? []).filter(Boolean); + } + + get offersLoading() { + return Boolean(this.offersQuery) && !this.offersQuery?.instances; + } + + get offersError() { + return (this.offersQuery as any)?.error; + } + + get price() { + return formatMoney(this.args.model?.price); + } + + get shipping() { + return formatMoney(this.args.model?.shippingPrice); + } + + + }; + + static atom = class Atom extends Component { + get price() { + return formatMoney(this.args.model?.price); + } + + + }; + + // Row-shaped: the "listings for size 10.5, sorted by price" row of a product + // page. Trailing slots are constant-width with an em-dash + // when empty so a consumer's list column-aligns. + static embedded = class Embedded extends Component { + get price() { + return formatMoney(this.args.model?.price); + } + + get shipping() { + return formatMoney(this.args.model?.shippingPrice); + } + + + }; + + // EDIT — grouped, not the schema in declaration order (edit-card Rule 0). + // What's listed and for how much sits up top; dates, fulfilment, photos and + // notes are collapsible. `productTitle`, `variantLabel`, `conditionCode` and + // `verified` are computed and deliberately absent. + static edit = ListingEdit; + + static fitted = class Fitted extends Component { + get price() { + return formatMoney(this.args.model?.price); + } + + + }; +} + +export default Listing; diff --git a/cards/collectibles/money-format.ts b/cards/collectibles/money-format.ts new file mode 100644 index 00000000..c161dc1a --- /dev/null +++ b/cards/collectibles/money-format.ts @@ -0,0 +1,84 @@ +// The single money formatter for the Sole Vault cards. Import this everywhere an +// amount is rendered — isolated, embedded, fitted, atom, table cell, CSV — so no +// two views of the same card can disagree about a price. +// +// WHY THIS EXISTS: base `amount-with-currency.gts` performs no number formatting +// at all (grep it: no Intl, no toFixed, no minimumFractionDigits). Rendering its +// atom therefore prints the raw JS number, so 11.50 renders as "11.5" and 3.10 as +// "3.1". A price missing a digit is not a price — a reader cannot tell $11.50 from +// $11.05 — and the bug is invisible on any amount whose minor units happen to be +// non-zero, which is most test data. +// +// WHAT THIS IS NOT: the formatting itself. That lives in the Commerce cluster's +// `line-item-totals`, and this file DELEGATES to it rather than carrying a second +// Intl call. Two things follow from that split: +// +// 1. `line-item-totals` passes no explicit fraction-digit options, so Intl +// applies each currency's OWN minor units: 2 for USD, 0 for JPY and KRW. +// Pinning minimumFractionDigits: 2 would render ¥100 as "¥100.00", a +// quantity that does not exist in that currency. +// 2. Its no-code branch uses maximumFractionDigits: 2 with no minimum, which +// DOES drop minor units ("11.5"). This file never reaches it, because it +// always resolves a currency code before delegating. +// +// The two `formatMoney` exports are distinguishable by signature: this one takes +// an `{amount, currency}`, `line-item-totals` takes `(amount, code)`. + +import { formatMoney as formatAmount } from '@cardstack/catalog/cards/commerce/line-item-totals'; + +export interface MoneyLike { + amount?: number | null; + currency?: { code?: string | null } | null; +} + +const DEFAULT_CURRENCY = 'USD'; + +/** + * Format an amount in its own currency's minor units: `$310.00`, `¥310`. + * + * Falls back to a plain fixed-2 rendering with the raw code prefixed if Intl does + * not recognise the currency — a realm may carry a code Intl has never heard of, + * and `./money` would silently drop the code there and print a bare number. + */ +export function formatMoney(value: MoneyLike | null | undefined): string { + if (!value || value.amount == null) { + return ''; + } + let code = value.currency?.code?.trim() || DEFAULT_CURRENCY; + try { + // Probe the code before delegating: `./money` swallows an unknown-currency + // throw and degrades to a bare number, losing the code the reader needs. + new Intl.NumberFormat('en-US', { style: 'currency', currency: code }); + } catch { + return `${code} ${value.amount.toFixed(2)}`; + } + return formatAmount(value.amount, code); +} + +/** + * Signed difference between two amounts, formatted with an explicit sign. + * + * Returns null — not '0' and not '' — when either side is missing, so a caller can + * tell "no comparison possible" from "no change". A card that renders a 0 it never + * computed is asserting something it does not know. + */ +export function formatMoneyDelta( + now: MoneyLike | null | undefined, + then: MoneyLike | null | undefined, +): { text: string; direction: 'up' | 'down' | 'flat'; percent: string } | null { + if (now?.amount == null || then?.amount == null) { + return null; + } + let diff = now.amount - then.amount; + let direction = diff > 0 ? 'up' : diff < 0 ? 'down' : 'flat'; + let sign = diff > 0 ? '+' : diff < 0 ? '−' : ''; + let magnitude = formatMoney({ + amount: Math.abs(diff), + currency: now.currency ?? then.currency, + }); + let percent = + then.amount === 0 + ? '' + : `${sign}${Math.abs((diff / then.amount) * 100).toFixed(0)}%`; + return { text: `${sign}${magnitude}`, direction: direction as any, percent }; +} diff --git a/cards/collectibles/seller-profile.gts b/cards/collectibles/seller-profile.gts new file mode 100644 index 00000000..ad887aa1 --- /dev/null +++ b/cards/collectibles/seller-profile.gts @@ -0,0 +1,482 @@ +import { + Component, + field, + contains, + getComponent, + type BaseDefComponent, +} from '@cardstack/base/card-api'; +import UserIcon from '@cardstack/boxel-icons/user'; +import MailIcon from '@cardstack/boxel-icons/mail'; +import PhoneIcon from '@cardstack/boxel-icons/phone'; +import TagIcon from '@cardstack/boxel-icons/tag'; +import AlertTriangleIcon from '@cardstack/boxel-icons/alert-triangle'; +import StarIcon from '@cardstack/boxel-icons/star'; +import { + FieldContainer, + LoadingIndicator, +} from '@cardstack/boxel-ui/components'; +import { SoleVaultPerson, SoleVaultPersonIsolated } from './sole-vault-person'; +import ScoreField from '@cardstack/catalog/fields/rating/rating'; + +// SellerProfile — a genuinely reusable marketplace-seller building block, not +// a Sole Vault–specific concept. Split out from `SoleVaultPerson` on purpose: +// `rating` has no meaning on a buyer, and `sole-vault-person.gts` is used for +// both roles (`Order.buyer` as well as `Order.seller`). Every other Sole +// Vault "person" link keeps using plain `SoleVaultPerson`; only +// `Order.seller` / `Listing.seller` narrow to this. +// +// Not the procurement Vendor: a vendor is a supplier under a contract, while +// this is a person selling to other people on a marketplace. +// +// Extends `SoleVaultPerson`, not `PersonBase` directly, to inherit the +// dark-luxury isolated/edit/fitted views for free rather than duplicating +// ~400 lines of near-identical template — the reusable ESSENCE of this block +// is the schema (PersonBase + rating), and a consuming app in a different +// visual world would override these formats the same way any card override +// works, same as `SoleVaultPerson` itself overrides `PersonBase`'s plain +// `embedded`. +export class SellerProfile extends SoleVaultPerson { + static displayName = 'Seller Profile'; + static icon = UserIcon; + + // The seller's rating. An unrated + // seller (no reviews yet) renders an em-dash, per ScoreField's own + // contract — never a fabricated default score. + @field rating = contains(ScoreField); + + // ISOLATED — same layout as SoleVaultPerson's, with the rating added to the + // hero as the one additional fact a seller's landing page needs over a + // plain person's. Glimmer templates cannot call a parent's ` @@ -1023,20 +1009,14 @@ export class Listing extends CardDef { align-items: baseline; padding: 0.5rem 0.75rem; font-size: 0.875rem; - font-family: var( - --font-sans, - 'Inter', - system-ui, - -apple-system, - sans-serif - ); - color: var(--foreground, oklch(0.985 0.001 106.42)); + font-family: var(--font-sans); + color: var(--foreground); } .l-price { font-weight: 700; font-variant-numeric: tabular-nums; white-space: nowrap; - color: var(--accent, oklch(0.828 0.189 84.43)); + color: var(--accent); } .l-cond { white-space: nowrap; @@ -1047,14 +1027,14 @@ export class Listing extends CardDef { font-weight: 700; } .l-from { - color: var(--muted-foreground, oklch(0.709 0.01 56.26)); + color: var(--muted-foreground); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .l-ship { text-align: right; - color: var(--muted-foreground, oklch(0.709 0.01 56.26)); + color: var(--muted-foreground); font-variant-numeric: tabular-nums; white-space: nowrap; } @@ -1091,7 +1071,12 @@ export class Listing extends CardDef { <:title>{{@model.productTitle}} <:badgeRight> {{#if @model.verified}} - Verified + {{/if}} <:footer> @@ -1114,8 +1099,8 @@ export class Listing extends CardDef { --ink-800: var(--card); --ink-700: color-mix( in oklch, - var(--card, oklch(0.216 0.006 56.04)) 80%, - var(--foreground, white) 20% + var(--card) 80%, + var(--foreground) 20% ); --foreground: oklch(0.147 0.004 49.25); --paper: var(--foreground); @@ -1141,13 +1126,7 @@ export class Listing extends CardDef { --gold-bright: var(--accent); background: var(--ink-800); color: var(--paper); - font-family: var( - --font-sans, - 'Inter', - system-ui, - -apple-system, - sans-serif - ); + font-family: var(--font-sans); /* Miniature vault plaque: 2px gold edge as an inset shadow, never a border — the host draws the chrome. */ box-shadow: inset 2px 0 0 0 var(--gold); diff --git a/cards/collectibles/seller-profile.gts b/cards/collectibles/seller-profile.gts index ad887aa1..d4378977 100644 --- a/cards/collectibles/seller-profile.gts +++ b/cards/collectibles/seller-profile.gts @@ -11,12 +11,12 @@ import PhoneIcon from '@cardstack/boxel-icons/phone'; import TagIcon from '@cardstack/boxel-icons/tag'; import AlertTriangleIcon from '@cardstack/boxel-icons/alert-triangle'; import StarIcon from '@cardstack/boxel-icons/star'; -import { - FieldContainer, - LoadingIndicator, -} from '@cardstack/boxel-ui/components'; +import { FieldContainer } from '@cardstack/boxel-ui/components'; import { SoleVaultPerson, SoleVaultPersonIsolated } from './sole-vault-person'; import ScoreField from '@cardstack/catalog/fields/rating/rating'; +import { Spinner } from '@cardstack/pretui/components/spinner'; +import { EmptyState } from '@cardstack/pretui/components/empty-state'; +import { COMPACT_EMPTY_STYLE } from '@cardstack/catalog/components/pretui-helpers'; // SellerProfile — a genuinely reusable marketplace-seller building block, not // a Sole Vault–specific concept. Split out from `SoleVaultPerson` on purpose: @@ -132,7 +132,7 @@ export class SellerProfile extends SoleVaultPerson { />Could not load listings.

{{else if this.listingsLoading}} -

Looking for listings…

+

Looking for listings…

{{else if this.listings.length}} {{else}} -

-

+ {{/if}}
@@ -158,7 +159,7 @@ export class SellerProfile extends SoleVaultPerson { height: 100%; overflow-y: auto; - --ink-950: var(--primary-foreground, oklch(0.216 0.006 56.04)); + --ink-950: var(--primary-foreground); --background: oklch(0.985 0.001 106.42); --ink-900: var(--background); --card: oklch(1 0 0); @@ -190,17 +191,11 @@ export class SellerProfile extends SoleVaultPerson { --gold-bright: var(--accent); --shadow-2: 0 8px 24px -8px oklch(0.05 0 0 / 0.14); - --font-display: var(--font-serif, 'Playfair Display', Georgia, serif); + --font-display: var(--font-serif); background: var(--ink-900); color: var(--paper); - font-family: var( - --font-sans, - 'Inter', - system-ui, - -apple-system, - sans-serif - ); + font-family: var(--font-sans); padding: 2rem; display: flex; flex-direction: column; @@ -437,13 +432,7 @@ export class SellerProfile extends SoleVaultPerson { background: var(--ink-900); color: var(--paper); - font-family: var( - --font-sans, - 'Inter', - system-ui, - -apple-system, - sans-serif - ); + font-family: var(--font-sans); height: 100%; overflow-y: auto; padding: 1.5rem; diff --git a/cards/collectibles/sole-vault-offer.gts b/cards/collectibles/sole-vault-offer.gts index 772b14c4..9dbe2b7b 100644 --- a/cards/collectibles/sole-vault-offer.gts +++ b/cards/collectibles/sole-vault-offer.gts @@ -24,15 +24,14 @@ import CalendarIcon from '@cardstack/boxel-icons/calendar'; import MessageCircleIcon from '@cardstack/boxel-icons/message-circle'; import AlertTriangleIcon from '@cardstack/boxel-icons/alert-triangle'; import { tracked } from '@glimmer/tracking'; -import { - Accordion, - FieldContainer, - FittedCard, - LoadingIndicator, -} from '@cardstack/boxel-ui/components'; +import { FieldContainer, FittedCard } from '@cardstack/boxel-ui/components'; import { identifyCard } from '@cardstack/runtime-common'; import { StatePill } from '@cardstack/catalog/components/state-pill'; import { formatMoney } from './money-format'; +import { Accordion } from '@cardstack/pretui/components/accordion'; +import { Spinner } from '@cardstack/pretui/components/spinner'; +import { EmptyState } from '@cardstack/pretui/components/empty-state'; +import { COMPACT_EMPTY_STYLE } from '@cardstack/catalog/components/pretui-helpers'; // Offer — a buyer proposing a price below asking, and the negotiation that // follows: offer, counter, negotiate. @@ -270,11 +269,7 @@ class OfferEdit extends Component { --accent: oklch(0.769 0.188 70.08); --accent-foreground: oklch(0.216 0.006 56.04); --ink-800: var(--card); - --ink-700: color-mix( - in oklch, - var(--card, oklch(0.216 0.006 56.04)) 80%, - var(--foreground, white) 20% - ); + --ink-700: color-mix(in oklch, var(--card) 80%, var(--foreground) 20%); --foreground: oklch(0.147 0.004 49.25); --paper: var(--foreground); --muted: oklch(0.97 0.001 106.42); @@ -301,13 +296,7 @@ class OfferEdit extends Component { background: var(--ink-900); color: var(--paper); - font-family: var( - --font-sans, - 'Inter', - system-ui, - -apple-system, - sans-serif - ); + font-family: var(--font-sans); height: 100%; overflow-y: auto; padding: 1.5rem; @@ -594,7 +583,7 @@ export class Offer extends CardDef { />Could not load counters.

{{else if this.countersLoading}} -

Looking for counters…

+

Looking for counters…

{{else if this.counters.length}} {{else}} -

-

+ {{/if}} @@ -627,10 +616,11 @@ export class Offer extends CardDef {
  • <@fields.listing @format='embedded' />
  • {{else}}
  • -

    -

    +
  • {{/if}} {{#if @model.offeredBy}} @@ -684,7 +674,7 @@ export class Offer extends CardDef { overflow-y: auto; box-sizing: border-box; - --ink-950: var(--primary-foreground, oklch(0.216 0.006 56.04)); + --ink-950: var(--primary-foreground); --background: oklch(0.985 0.001 106.42); --ink-900: var(--background); --card: oklch(1 0 0); @@ -692,8 +682,8 @@ export class Offer extends CardDef { --ink-800: var(--card); --ink-700: color-mix( in oklch, - var(--card, oklch(0.216 0.006 56.04)) 80%, - var(--foreground, white) 20% + var(--card) 80%, + var(--foreground) 20% ); --foreground: oklch(0.147 0.004 49.25); --paper: var(--foreground); @@ -723,19 +713,13 @@ export class Offer extends CardDef { --shadow-2: 0 8px 24px -8px oklch(0.05 0 0 / 0.14); --shadow-3: 0 20px 48px -16px oklch(0.05 0 0 / 0.18); - --font-display: var(--font-serif, 'Playfair Display', Georgia, serif); + --font-display: var(--font-serif); --font-mono: ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace; background: var(--ink-900); color: var(--paper); - font-family: var( - --font-sans, - 'Inter', - system-ui, - -apple-system, - sans-serif - ); + font-family: var(--font-sans); padding: 2rem; display: flex; flex-direction: column; @@ -1134,8 +1118,8 @@ export class Offer extends CardDef { --ink-800: var(--card); --ink-700: color-mix( in oklch, - var(--card, oklch(0.216 0.006 56.04)) 80%, - var(--foreground, white) 20% + var(--card) 80%, + var(--foreground) 20% ); --foreground: oklch(0.147 0.004 49.25); --paper: var(--foreground); @@ -1162,13 +1146,7 @@ export class Offer extends CardDef { background: var(--ink-800); color: var(--paper); - font-family: var( - --font-sans, - 'Inter', - system-ui, - -apple-system, - sans-serif - ); + font-family: var(--font-sans); box-shadow: inset 2px 0 0 0 var(--gold); --fc-image-width: 34cqh; @@ -1352,15 +1330,9 @@ export class Offer extends CardDef { gap: 0.75rem; align-items: center; padding: 0.5rem 0.75rem; - background: var(--card, oklch(0.216 0.006 56.04)); - color: var(--foreground, oklch(0.985 0.001 106.42)); - font-family: var( - --font-sans, - 'Inter', - system-ui, - -apple-system, - sans-serif - ); + background: var(--card); + color: var(--foreground); + font-family: var(--font-sans); } .of-main { display: flex; @@ -1389,7 +1361,7 @@ export class Offer extends CardDef { ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace; font-weight: 700; font-variant-numeric: tabular-nums; - color: var(--accent, oklch(0.828 0.189 84.43)); + color: var(--accent); white-space: nowrap; } /* A ratio is not an amount: it steps DOWN in size and weight so the @@ -1398,7 +1370,7 @@ export class Offer extends CardDef { font-family: ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace; font-size: 0.75rem; - color: var(--muted-foreground, oklch(0.709 0.01 56.26)); + color: var(--muted-foreground); white-space: nowrap; } diff --git a/cards/collectibles/sole-vault-order.gts b/cards/collectibles/sole-vault-order.gts index 80e2182c..b1e4b140 100644 --- a/cards/collectibles/sole-vault-order.gts +++ b/cards/collectibles/sole-vault-order.gts @@ -28,12 +28,7 @@ import LockIcon from '@cardstack/boxel-icons/lock'; import AlertTriangleIcon from '@cardstack/boxel-icons/alert-triangle'; import { tracked } from '@glimmer/tracking'; import { on } from '@ember/modifier'; -import { - Accordion, - Button, - FieldContainer, - LoadingIndicator, -} from '@cardstack/boxel-ui/components'; +import { FieldContainer } from '@cardstack/boxel-ui/components'; import { eq } from '@cardstack/boxel-ui/helpers'; import { identifyCard } from '@cardstack/runtime-common'; // CYCLE-SAFE IMPORTS. Both of these modules import THIS one, so these are @@ -43,6 +38,11 @@ import { identifyCard } from '@cardstack/runtime-common'; import { Shipment } from './sole-vault-shipment'; import { Payment } from './sole-vault-payment'; import { formatMoney } from './money-format'; +import { Accordion } from '@cardstack/pretui/components/accordion'; +import { Button } from '@cardstack/pretui/components/button'; +import { Spinner } from '@cardstack/pretui/components/spinner'; +import { EmptyState } from '@cardstack/pretui/components/empty-state'; +import { COMPACT_EMPTY_STYLE } from '@cardstack/catalog/components/pretui-helpers'; // Order — one escrow-protected purchase of one Listing. // @@ -348,17 +348,13 @@ class OrderEdit extends Component { /* Literal Sole Vault tokens — same names and values as the app shell and this card's isolated/fitted slices, on purpose. */ - --ink-950: var(--primary-foreground, oklch(0.216 0.006 56.04)); + --ink-950: var(--primary-foreground); --background: oklch(0.985 0.001 106.42); --ink-900: var(--background); --card: oklch(1 0 0); --card-foreground: oklch(0.147 0.004 49.25); --ink-800: var(--card); - --ink-700: color-mix( - in oklch, - var(--card, oklch(0.216 0.006 56.04)) 80%, - var(--foreground, white) 20% - ); + --ink-700: color-mix(in oklch, var(--card) 80%, var(--foreground) 20%); --foreground: oklch(0.147 0.004 49.25); --paper: var(--foreground); --muted: oklch(0.97 0.001 106.42); @@ -385,18 +381,12 @@ class OrderEdit extends Component { --gold-bright: var(--accent); --shadow-1: 0 1px 2px oklch(0.05 0 0 / 0.08); - --font-display: var(--font-serif, 'Playfair Display', Georgia, serif); + --font-display: var(--font-serif); --font-mono: ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace; background: var(--ink-900); color: var(--paper); - font-family: var( - --font-sans, - 'Inter', - system-ui, - -apple-system, - sans-serif - ); + font-family: var(--font-sans); height: 100%; overflow-y: auto; padding: 1.5rem; @@ -764,27 +754,30 @@ class OrderIsolated extends Component {
    {{#if this.canProcessPayment}} {{/if}} {{#if this.canDispatch}} {{/if}} {{#if this.canRefund}} @@ -860,7 +853,7 @@ class OrderIsolated extends Component { />Could not load shipments.

    {{else if this.shipmentsLoading}} -

    Looking for shipments…

    +

    Looking for shipments…

    {{else if this.shipments.length}} {{else}} -

    -

    + {{/if}}
    @@ -896,7 +889,7 @@ class OrderIsolated extends Component { not load payments.

    {{else if this.paymentsLoading}} -

    Looking for payments…

    +

    Looking for payments…

    {{else if this.payments.length}} {{else}} -

    -

    + {{/if}} @@ -928,17 +922,13 @@ class OrderIsolated extends Component { /* Literal Sole Vault palette — same names and values as the app shell, so the family reads as one continuous surface. */ - --ink-950: var(--primary-foreground, oklch(0.216 0.006 56.04)); + --ink-950: var(--primary-foreground); --background: oklch(0.985 0.001 106.42); --ink-900: var(--background); --card: oklch(1 0 0); --card-foreground: oklch(0.147 0.004 49.25); --ink-800: var(--card); - --ink-700: color-mix( - in oklch, - var(--card, oklch(0.216 0.006 56.04)) 80%, - var(--foreground, white) 20% - ); + --ink-700: color-mix(in oklch, var(--card) 80%, var(--foreground) 20%); --foreground: oklch(0.147 0.004 49.25); --paper: var(--foreground); --muted: oklch(0.97 0.001 106.42); @@ -967,7 +957,7 @@ class OrderIsolated extends Component { --shadow-2: 0 8px 24px -8px oklch(0.05 0 0 / 0.14); --shadow-3: 0 20px 48px -16px oklch(0.05 0 0 / 0.18); - --font-display: var(--font-serif, 'Playfair Display', Georgia, serif); + --font-display: var(--font-serif); --font-mono: ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace; background: var(--ink-900); @@ -977,13 +967,7 @@ class OrderIsolated extends Component { transparent 60% ); color: var(--paper); - font-family: var( - --font-sans, - 'Inter', - system-ui, - -apple-system, - sans-serif - ); + font-family: var(--font-sans); padding: 1.75rem; /* ONE rhythm mechanism — the parent's gap. No child margin-top, so there is no override to undo it. */ @@ -1194,7 +1178,7 @@ class OrderIsolated extends Component { align-items: center; gap: 0.45em; font-size: 0.8125rem; - color: var(--destructive, oklch(0.577 0.245 27.32)); + color: var(--destructive); } .next { margin: 1rem 0 0; @@ -1282,7 +1266,7 @@ class OrderIsolated extends Component { color: var(--smoke); } .err { - color: var(--destructive, oklch(0.704 0.191 22.216)); + color: var(--destructive); } @media (prefers-reduced-motion: reduce) { @@ -1583,13 +1567,7 @@ export class Order extends CardDef { background: var(--ink-800); color: var(--paper); - font-family: var( - --font-sans, - 'Inter', - system-ui, - -apple-system, - sans-serif - ); + font-family: var(--font-sans); padding: 0.5rem 0.75rem; box-sizing: border-box; /* The family's miniature vault plaque — inset gold edge, not a border, @@ -1734,13 +1712,7 @@ export class Order extends CardDef { display: inline-flex; align-items: baseline; gap: 0.3rem; - font-family: var( - --font-sans, - 'Inter', - system-ui, - -apple-system, - sans-serif - ); + font-family: var(--font-sans); } /* An order reference is quoted into support threads — mono, never cut. */ .o-ref { @@ -1751,7 +1723,7 @@ export class Order extends CardDef { } .o-total { font-variant-numeric: tabular-nums; - color: var(--muted-foreground, oklch(0.709 0.01 56.26)); + color: var(--muted-foreground); } @@ -1788,22 +1760,16 @@ export class Order extends CardDef { gap: 0.75rem; align-items: center; padding: 0.5rem 0.75rem; - background: var(--card, oklch(0.216 0.006 56.04)); - color: var(--foreground, oklch(0.985 0.001 106.42)); - font-family: var( - --font-sans, - 'Inter', - system-ui, - -apple-system, - sans-serif - ); + background: var(--card); + color: var(--foreground); + font-family: var(--font-sans); } .o-ref { font-family: ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace; font-size: 0.75rem; font-weight: 600; - color: var(--muted-foreground, oklch(0.709 0.01 56.26)); + color: var(--muted-foreground); white-space: nowrap; } .o-title { @@ -1817,7 +1783,7 @@ export class Order extends CardDef { font-family: 'Playfair Display', Georgia, serif; font-weight: 700; font-variant-numeric: tabular-nums; - color: var(--accent, oklch(0.828 0.189 84.43)); + color: var(--accent); white-space: nowrap; } @container (width < 500px) { diff --git a/cards/collectibles/sole-vault-payment.gts b/cards/collectibles/sole-vault-payment.gts index 90d785f6..1bf3fb42 100644 --- a/cards/collectibles/sole-vault-payment.gts +++ b/cards/collectibles/sole-vault-payment.gts @@ -18,13 +18,12 @@ import CreditCardIcon from '@cardstack/boxel-icons/credit-card'; import ReceiptIcon from '@cardstack/boxel-icons/receipt'; import ClockIcon from '@cardstack/boxel-icons/clock'; import { tracked } from '@glimmer/tracking'; -import { - Accordion, - FieldContainer, - FittedCard, -} from '@cardstack/boxel-ui/components'; +import { FieldContainer, FittedCard } from '@cardstack/boxel-ui/components'; import { eq } from '@cardstack/boxel-ui/helpers'; import { formatMoney } from './money-format'; +import { Accordion } from '@cardstack/pretui/components/accordion'; +import { EmptyState } from '@cardstack/pretui/components/empty-state'; +import { COMPACT_EMPTY_STYLE } from '@cardstack/catalog/components/pretui-helpers'; // Payment — the RECORD of one money movement against one Order. // @@ -238,11 +237,7 @@ class PaymentEdit extends Component { --accent: oklch(0.769 0.188 70.08); --accent-foreground: oklch(0.216 0.006 56.04); --ink-800: var(--card); - --ink-700: color-mix( - in oklch, - var(--card, oklch(0.216 0.006 56.04)) 80%, - var(--foreground, white) 20% - ); + --ink-700: color-mix(in oklch, var(--card) 80%, var(--foreground) 20%); --foreground: oklch(0.147 0.004 49.25); --paper: var(--foreground); --muted: oklch(0.97 0.001 106.42); @@ -268,13 +263,7 @@ class PaymentEdit extends Component { background: var(--ink-900); color: var(--paper); - font-family: var( - --font-sans, - 'Inter', - system-ui, - -apple-system, - sans-serif - ); + font-family: var(--font-sans); height: 100%; overflow-y: auto; padding: var(--boxel-sp); @@ -535,10 +524,11 @@ export class Payment extends CardDef { <@fields.order @format='embedded' /> {{else}} -

    -

    + {{/if}} @@ -554,7 +544,7 @@ export class Payment extends CardDef { height: 100%; overflow-y: auto; - --ink-950: var(--primary-foreground, oklch(0.216 0.006 56.04)); + --ink-950: var(--primary-foreground); --background: oklch(0.985 0.001 106.42); --ink-900: var(--background); --card: oklch(1 0 0); @@ -562,8 +552,8 @@ export class Payment extends CardDef { --ink-800: var(--card); --ink-700: color-mix( in oklch, - var(--card, oklch(0.216 0.006 56.04)) 80%, - var(--foreground, white) 20% + var(--card) 80%, + var(--foreground) 20% ); --foreground: oklch(0.147 0.004 49.25); --paper: var(--foreground); @@ -589,12 +579,12 @@ export class Payment extends CardDef { --accent: oklch(0.769 0.188 70.08); --accent-foreground: oklch(0.216 0.006 56.04); --gold-bright: var(--accent); - --rose: var(--destructive, oklch(0.7 0.16 24)); + --rose: var(--destructive); --rose-bright: oklch(0.76 0.17 27); --shadow-2: 0 8px 24px -8px oklch(0.05 0 0 / 0.14); --shadow-3: 0 20px 48px -16px oklch(0.05 0 0 / 0.18); - --font-display: var(--font-serif, 'Playfair Display', Georgia, serif); + --font-display: var(--font-serif); --font-mono: ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace; @@ -605,13 +595,7 @@ export class Payment extends CardDef { transparent 60% ); color: var(--paper); - font-family: var( - --font-sans, - 'Inter', - system-ui, - -apple-system, - sans-serif - ); + font-family: var(--font-sans); padding: var(--boxel-sp-lg); display: flex; flex-direction: column; @@ -896,16 +880,6 @@ export class Payment extends CardDef { box-shadow: var(--shadow-3); } - .empty { - margin: 0; - display: flex; - align-items: center; - gap: 0.5em; - font-size: 0.8125rem; - line-height: 1.45; - color: var(--smoke); - } - @media (prefers-reduced-motion: reduce) { .stub-body { transition: none; @@ -1012,8 +986,8 @@ export class Payment extends CardDef { --ink-800: var(--card); --ink-700: color-mix( in oklch, - var(--card, oklch(0.216 0.006 56.04)) 80%, - var(--foreground, white) 20% + var(--card) 80%, + var(--foreground) 20% ); --foreground: oklch(0.147 0.004 49.25); --paper: var(--foreground); @@ -1042,13 +1016,7 @@ export class Payment extends CardDef { background: var(--ink-800); color: var(--paper); - font-family: var( - --font-sans, - 'Inter', - system-ui, - -apple-system, - sans-serif - ); + font-family: var(--font-sans); /* Family plaque — inset gold edge, never a border. */ box-shadow: inset 2px 0 0 0 var(--gold); @@ -1150,13 +1118,7 @@ export class Payment extends CardDef { display: inline-flex; align-items: center; gap: var(--boxel-sp-xxs); - font-family: var( - --font-sans, - 'Inter', - system-ui, - -apple-system, - sans-serif - ); + font-family: var(--font-sans); } .pay-amt { font-family: @@ -1164,12 +1126,12 @@ export class Payment extends CardDef { font-weight: 700; font-variant-numeric: tabular-nums; white-space: nowrap; - color: var(--primary, oklch(0.769 0.188 70.08)); + color: var(--primary); } /* A refund reads as an outflow — a distinct rose hue from the family's gold inflow tone. */ .pay-amt--out { - color: var(--destructive, oklch(0.7 0.16 24)); + color: var(--destructive); } @@ -1210,15 +1172,9 @@ export class Payment extends CardDef { gap: var(--boxel-sp-sm); align-items: center; padding: var(--boxel-sp-xs) var(--boxel-sp-sm); - background: var(--card, oklch(0.216 0.006 56.04)); - color: var(--foreground, oklch(0.985 0.001 106.42)); - font-family: var( - --font-sans, - 'Inter', - system-ui, - -apple-system, - sans-serif - ); + background: var(--card); + color: var(--foreground); + font-family: var(--font-sans); font-size: var(--boxel-font-size-sm); } /* A processor reference is reconciled against a dashboard by hand. */ @@ -1232,7 +1188,7 @@ export class Payment extends CardDef { font-weight: 600; } .pay-reason { - color: var(--muted-foreground, oklch(0.709 0.01 56.26)); + color: var(--muted-foreground); } .pay-amt { font-family: @@ -1240,10 +1196,10 @@ export class Payment extends CardDef { font-weight: 700; font-variant-numeric: tabular-nums; white-space: nowrap; - color: var(--accent, oklch(0.828 0.189 84.43)); + color: var(--accent); } .pay-amt--out { - color: var(--destructive, oklch(0.7 0.16 24)); + color: var(--destructive); } @container (width < 520px) { .pay-row { diff --git a/cards/collectibles/sole-vault-person.gts b/cards/collectibles/sole-vault-person.gts index 0c836d3f..ebaa7d3c 100644 --- a/cards/collectibles/sole-vault-person.gts +++ b/cards/collectibles/sole-vault-person.gts @@ -11,11 +11,7 @@ import MailIcon from '@cardstack/boxel-icons/mail'; import PhoneIcon from '@cardstack/boxel-icons/phone'; import TagIcon from '@cardstack/boxel-icons/tag'; import AlertTriangleIcon from '@cardstack/boxel-icons/alert-triangle'; -import { - FieldContainer, - FittedCard, - LoadingIndicator, -} from '@cardstack/boxel-ui/components'; +import { FieldContainer, FittedCard } from '@cardstack/boxel-ui/components'; import { identifyCard } from '@cardstack/runtime-common'; import { PersonBase } from '@cardstack/catalog/cards/people/person-base'; // NOT a static import of `./listing` on purpose. listing.gts now imports @@ -28,6 +24,9 @@ import { PersonBase } from '@cardstack/catalog/cards/people/person-base'; // the loop loads first. A dynamic import breaks the cycle: it is not part of // the synchronous module graph, so nothing waits on it to finish loading. import type { Listing as ListingType } from './listing'; +import { Spinner } from '@cardstack/pretui/components/spinner'; +import { EmptyState } from '@cardstack/pretui/components/empty-state'; +import { COMPACT_EMPTY_STYLE } from '@cardstack/catalog/components/pretui-helpers'; // The isolated landing page, exported so SellerProfile can extend it. export class SoleVaultPersonIsolated< @@ -135,7 +134,7 @@ export class SoleVaultPersonIsolated< not load listings.

    {{else if this.listingsLoading}} -

    Looking for listings…

    +

    Looking for listings…

    {{else if this.listings.length}} {{else}} -

    -

    + {{/if}} @@ -168,17 +168,13 @@ export class SoleVaultPersonIsolated< height: 100%; overflow-y: auto; - --ink-950: var(--primary-foreground, oklch(0.216 0.006 56.04)); + --ink-950: var(--primary-foreground); --background: oklch(0.985 0.001 106.42); --ink-900: var(--background); --card: oklch(1 0 0); --card-foreground: oklch(0.147 0.004 49.25); --ink-800: var(--card); - --ink-700: color-mix( - in oklch, - var(--card, oklch(0.216 0.006 56.04)) 80%, - var(--foreground, white) 20% - ); + --ink-700: color-mix(in oklch, var(--card) 80%, var(--foreground) 20%); --foreground: oklch(0.147 0.004 49.25); --paper: var(--foreground); --muted: oklch(0.97 0.001 106.42); @@ -207,17 +203,11 @@ export class SoleVaultPersonIsolated< --shadow-2: 0 8px 24px -8px oklch(0.05 0 0 / 0.14); --shadow-3: 0 20px 48px -16px oklch(0.05 0 0 / 0.18); - --font-display: var(--font-serif, 'Playfair Display', Georgia, serif); + --font-display: var(--font-serif); background: var(--ink-900); color: var(--paper); - font-family: var( - --font-sans, - 'Inter', - system-ui, - -apple-system, - sans-serif - ); + font-family: var(--font-sans); padding: 2rem; display: flex; flex-direction: column; @@ -487,7 +477,7 @@ export class SoleVaultPerson extends PersonBase { container-type: inline-size; container-name: p-edit; - --ink-950: var(--primary-foreground, oklch(0.216 0.006 56.04)); + --ink-950: var(--primary-foreground); --background: oklch(0.985 0.001 106.42); --ink-900: var(--background); --card: oklch(1 0 0); @@ -497,8 +487,8 @@ export class SoleVaultPerson extends PersonBase { --ink-800: var(--card); --ink-700: color-mix( in oklch, - var(--card, oklch(0.216 0.006 56.04)) 80%, - var(--foreground, white) 20% + var(--card) 80%, + var(--foreground) 20% ); --foreground: oklch(0.147 0.004 49.25); --paper: var(--foreground); @@ -524,13 +514,7 @@ export class SoleVaultPerson extends PersonBase { background: var(--ink-900); color: var(--paper); - font-family: var( - --font-sans, - 'Inter', - system-ui, - -apple-system, - sans-serif - ); + font-family: var(--font-sans); height: 100%; overflow-y: auto; padding: 1.5rem; @@ -654,8 +638,8 @@ export class SoleVaultPerson extends PersonBase { --ink-800: var(--card); --ink-700: color-mix( in oklch, - var(--card, oklch(0.216 0.006 56.04)) 80%, - var(--foreground, white) 20% + var(--card) 80%, + var(--foreground) 20% ); --foreground: oklch(0.147 0.004 49.25); --paper: var(--foreground); @@ -682,13 +666,7 @@ export class SoleVaultPerson extends PersonBase { background: var(--ink-800); color: var(--paper); - font-family: var( - --font-sans, - 'Inter', - system-ui, - -apple-system, - sans-serif - ); + font-family: var(--font-sans); /* The miniature vault plaque — inset shadow, not a border. */ box-shadow: inset 2px 0 0 0 var(--gold); @@ -800,15 +778,9 @@ export class SoleVaultPerson extends PersonBase { align-items: center; gap: 0.6rem; padding: 0.5rem 0.7rem; - background: var(--card, oklch(0.216 0.006 56.04)); - color: var(--foreground, oklch(0.985 0.001 106.42)); - font-family: var( - --font-sans, - 'Inter', - system-ui, - -apple-system, - sans-serif - ); + background: var(--card); + color: var(--foreground); + font-family: var(--font-sans); } .person-avatar { width: 2.375rem; @@ -825,11 +797,11 @@ export class SoleVaultPerson extends PersonBase { font-weight: 700; font-size: 0.875rem; line-height: 1; - color: var(--background, oklch(0.1 0.004 49.25)); + color: var(--background); background: linear-gradient( 155deg, - var(--primary, oklch(0.769 0.188 70.08)) 0%, - var(--accent, oklch(0.828 0.189 84.43)) 100% + var(--primary) 0%, + var(--accent) 100% ); } .person-main { @@ -848,7 +820,7 @@ export class SoleVaultPerson extends PersonBase { } .person-sub { font-size: 0.75rem; - color: var(--muted-foreground, oklch(0.709 0.01 56.26)); + color: var(--muted-foreground); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; diff --git a/cards/collectibles/sole-vault-shipment.gts b/cards/collectibles/sole-vault-shipment.gts index b019c72b..469f7bf9 100644 --- a/cards/collectibles/sole-vault-shipment.gts +++ b/cards/collectibles/sole-vault-shipment.gts @@ -21,12 +21,11 @@ import ReceiptIcon from '@cardstack/boxel-icons/receipt'; import AlertTriangleIcon from '@cardstack/boxel-icons/alert-triangle'; import { tracked } from '@glimmer/tracking'; import { on } from '@ember/modifier'; -import { - Accordion, - Button, - FieldContainer, - FittedCard, -} from '@cardstack/boxel-ui/components'; +import { FieldContainer, FittedCard } from '@cardstack/boxel-ui/components'; +import { Accordion } from '@cardstack/pretui/components/accordion'; +import { Button } from '@cardstack/pretui/components/button'; +import { EmptyState } from '@cardstack/pretui/components/empty-state'; +import { COMPACT_EMPTY_STYLE } from '@cardstack/catalog/components/pretui-helpers'; // Shipment — one physical movement of one item, with its tracking. // @@ -241,9 +240,10 @@ class ShipmentIsolated extends Component { {{#if this.showMarkDelivered}}
    @@ -320,10 +320,11 @@ class ShipmentIsolated extends Component { {{#if @model.order}}
    <@fields.order @format='embedded' />
    {{else}} -

    -

    + {{/if}} @@ -338,17 +339,13 @@ class ShipmentIsolated extends Component { overflow-y: auto; box-sizing: border-box; - --ink-950: var(--primary-foreground, oklch(0.216 0.006 56.04)); + --ink-950: var(--primary-foreground); --background: oklch(0.985 0.001 106.42); --ink-900: var(--background); --card: oklch(1 0 0); --card-foreground: oklch(0.147 0.004 49.25); --ink-800: var(--card); - --ink-700: color-mix( - in oklch, - var(--card, oklch(0.216 0.006 56.04)) 80%, - var(--foreground, white) 20% - ); + --ink-700: color-mix(in oklch, var(--card) 80%, var(--foreground) 20%); --foreground: oklch(0.147 0.004 49.25); --paper: var(--foreground); --muted: oklch(0.97 0.001 106.42); @@ -377,7 +374,7 @@ class ShipmentIsolated extends Component { --shadow-2: 0 8px 24px -8px oklch(0.05 0 0 / 0.14); --shadow-3: 0 20px 48px -16px oklch(0.05 0 0 / 0.18); - --font-display: var(--font-serif, 'Playfair Display', Georgia, serif); + --font-display: var(--font-serif); --font-mono: ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace; background: var(--ink-900); @@ -387,13 +384,7 @@ class ShipmentIsolated extends Component { transparent 60% ); color: var(--paper); - font-family: var( - --font-sans, - 'Inter', - system-ui, - -apple-system, - sans-serif - ); + font-family: var(--font-sans); padding: 2rem; display: flex; flex-direction: column; @@ -529,7 +520,7 @@ class ShipmentIsolated extends Component { align-items: center; gap: 0.45em; font-size: 0.8125rem; - color: var(--destructive, oklch(0.577 0.245 27.32)); + color: var(--destructive); } .track-btn { justify-self: start; @@ -664,15 +655,6 @@ class ShipmentIsolated extends Component { overflow: hidden; box-shadow: var(--shadow-1); } - .empty { - margin: 0; - display: flex; - align-items: center; - gap: 0.5em; - font-size: 0.8125rem; - line-height: 1.45; - color: var(--smoke); - } @container card (width < 640px) { .manifest-grid { @@ -832,11 +814,7 @@ class ShipmentEdit extends Component { --accent: oklch(0.769 0.188 70.08); --accent-foreground: oklch(0.216 0.006 56.04); --ink-800: var(--card); - --ink-700: color-mix( - in oklch, - var(--card, oklch(0.216 0.006 56.04)) 80%, - var(--foreground, white) 20% - ); + --ink-700: color-mix(in oklch, var(--card) 80%, var(--foreground) 20%); --foreground: oklch(0.147 0.004 49.25); --paper: var(--foreground); --muted: oklch(0.97 0.001 106.42); @@ -858,18 +836,12 @@ class ShipmentEdit extends Component { /* Text-grade gold for sub-18px strings: bare --gold on white sits under AA. oklab, not oklch (hue-rotates against achromatic endpoints). */ --gold-ink: color-mix(in oklab, var(--gold) 72%, var(--foreground)); - --font-display: var(--font-serif, 'Playfair Display', Georgia, serif); + --font-display: var(--font-serif); --font-mono: ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace; background: var(--ink-900); color: var(--paper); - font-family: var( - --font-sans, - 'Inter', - system-ui, - -apple-system, - sans-serif - ); + font-family: var(--font-sans); height: 100%; overflow-y: auto; padding: 1.5rem; @@ -1113,8 +1085,8 @@ export class Shipment extends CardDef { --ink-800: var(--card); --ink-700: color-mix( in oklch, - var(--card, oklch(0.216 0.006 56.04)) 80%, - var(--foreground, white) 20% + var(--card) 80%, + var(--foreground) 20% ); --foreground: oklch(0.147 0.004 49.25); --paper: var(--foreground); @@ -1138,13 +1110,7 @@ export class Shipment extends CardDef { background: var(--ink-800); color: var(--paper); - font-family: var( - --font-sans, - 'Inter', - system-ui, - -apple-system, - sans-serif - ); + font-family: var(--font-sans); box-shadow: inset 3px 0 0 0 var(--gold); --fc-image-width: 34cqh; @@ -1253,11 +1219,7 @@ export class Shipment extends CardDef { } .sh-none { font-size: 0.8125em; - color: color-mix( - in oklch, - var(--muted-foreground, oklch(0.709 0.01 56.26)) 85%, - transparent - ); + color: color-mix(in oklch, var(--muted-foreground) 85%, transparent); } @@ -1292,18 +1254,12 @@ export class Shipment extends CardDef { gap: 0.75rem; align-items: center; padding: 0.6rem 0.9rem; - background: var(--card, oklch(0.216 0.006 56.04)); + background: var(--card); border: 1px solid oklch(0.32 0.012 55 / 0.55); border-radius: 8px; - box-shadow: inset 2px 0 0 0 var(--primary, oklch(0.769 0.188 70.08)); - color: var(--foreground, oklch(0.985 0.001 106.42)); - font-family: var( - --font-sans, - 'Inter', - system-ui, - -apple-system, - sans-serif - ); + box-shadow: inset 2px 0 0 0 var(--primary); + color: var(--foreground); + font-family: var(--font-sans); font-size: 0.875rem; } .sh-main { @@ -1317,7 +1273,7 @@ export class Shipment extends CardDef { white-space: nowrap; } .sh-carrier { - color: var(--muted-foreground, oklch(0.709 0.01 56.26)); + color: var(--muted-foreground); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -1329,7 +1285,7 @@ export class Shipment extends CardDef { ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace; font-size: 0.75rem; font-variant-numeric: tabular-nums; - color: var(--muted-foreground, oklch(0.709 0.01 56.26)); + color: var(--muted-foreground); white-space: nowrap; } @container (width < 480px) { diff --git a/cards/commerce/price.gts b/cards/commerce/price.gts index 5b4c49b8..544a71be 100644 --- a/cards/commerce/price.gts +++ b/cards/commerce/price.gts @@ -57,12 +57,12 @@ export class Price extends CardDef { gap: 0.375rem; font-size: 0.8125rem; font-weight: 500; - color: var(--foreground, #111111); + color: var(--foreground); } .pa-icon { width: 14px; height: 14px; - color: var(--muted-foreground, #6b7280); + color: var(--muted-foreground); flex-shrink: 0; } .pa-name { @@ -100,7 +100,7 @@ export class Price extends CardDef { .icon { width: 20px; height: 20px; - color: var(--muted-foreground, #6b7280); + color: var(--muted-foreground); flex-shrink: 0; } .figure { @@ -114,8 +114,8 @@ export class Price extends CardDef { letter-spacing: 0.04em; padding: 0.125rem 0.5rem; border-radius: 999px; - background: var(--muted, #f3f4f6); - color: var(--muted-foreground, #6b7280); + background: var(--muted); + color: var(--muted-foreground); } @@ -148,7 +148,7 @@ export class Price extends CardDef { .fitted { width: 100%; height: 100%; - color: var(--foreground, #111111); + color: var(--foreground); } .fmt { display: none; @@ -162,7 +162,7 @@ export class Price extends CardDef { .doc-icon { width: 18px; height: 18px; - color: var(--muted-foreground, #6b7280); + color: var(--muted-foreground); flex-shrink: 0; } .figure { @@ -177,8 +177,8 @@ export class Price extends CardDef { text-transform: uppercase; padding: 0.125rem 0.4375rem; border-radius: 999px; - background: var(--muted, #f3f4f6); - color: var(--muted-foreground, #6b7280); + background: var(--muted); + color: var(--muted-foreground); white-space: nowrap; } @container fitted-card (max-width: 150px) and (max-height: 169px) { @@ -238,7 +238,7 @@ export class Price extends CardDef { display: flex; flex-direction: column; gap: 0.5rem; - border-bottom: 2px solid var(--foreground, #111111); + border-bottom: 2px solid var(--foreground); padding-bottom: 1.25rem; } .doc-kind { @@ -247,7 +247,7 @@ export class Price extends CardDef { font-weight: 700; text-transform: uppercase; letter-spacing: 0.14em; - color: var(--muted-foreground, #6b7280); + color: var(--muted-foreground); } h1 { margin: 0; @@ -262,11 +262,11 @@ export class Price extends CardDef { text-transform: uppercase; padding: 0.1875rem 0.625rem; border-radius: 999px; - background: var(--muted, #f3f4f6); - color: var(--muted-foreground, #6b7280); + background: var(--muted); + color: var(--muted-foreground); } .subject { - border: 1px solid var(--border, #e5e7eb); + border: 1px solid var(--border); border-radius: 0.5rem; } diff --git a/cards/commerce/product-review.gts b/cards/commerce/product-review.gts index c20aeae8..89e854fd 100644 --- a/cards/commerce/product-review.gts +++ b/cards/commerce/product-review.gts @@ -48,12 +48,12 @@ export class ProductReview extends CardDef { gap: 0.375rem; font-size: 0.8125rem; font-weight: 500; - color: var(--foreground, #111111); + color: var(--foreground); } .ra-icon { width: 14px; height: 14px; - color: #f59e0b; + color: var(--warning); flex-shrink: 0; } @@ -91,14 +91,14 @@ export class ProductReview extends CardDef { .star { width: 14px; height: 14px; - color: var(--border, #e5e7eb); + color: var(--border); } .star-on { - color: #f59e0b; + color: var(--warning); } .body { margin: 0; - color: var(--foreground, #111111); + color: var(--foreground); line-height: 1.5; } @@ -124,7 +124,7 @@ export class ProductReview extends CardDef { .fitted { width: 100%; height: 100%; - color: var(--foreground, #111111); + color: var(--foreground); } .fmt { display: none; @@ -138,7 +138,7 @@ export class ProductReview extends CardDef { .doc-icon { width: 18px; height: 18px; - color: #f59e0b; + color: var(--warning); flex-shrink: 0; } .figure { @@ -148,7 +148,7 @@ export class ProductReview extends CardDef { } .meta { font-size: 0.75rem; - color: var(--muted-foreground, #6b7280); + color: var(--muted-foreground); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -202,10 +202,10 @@ export class ProductReview extends CardDef { .star { width: 22px; height: 22px; - color: var(--border, #e5e7eb); + color: var(--border); } .star-on { - color: #f59e0b; + color: var(--warning); } .body { font-size: 1rem; diff --git a/cards/fulfilment/pick-list.gts b/cards/fulfilment/pick-list.gts index 7218b5dc..64e6f3e0 100644 --- a/cards/fulfilment/pick-list.gts +++ b/cards/fulfilment/pick-list.gts @@ -17,10 +17,11 @@ import { action } from '@ember/object'; import { on } from '@ember/modifier'; import { tracked } from '@glimmer/tracking'; import { htmlSafe } from '@ember/template'; -import { Button, BoxelInput } from '@cardstack/boxel-ui/components'; import RouteIcon from '@cardstack/boxel-icons/route'; import { Warehouse } from '@cardstack/catalog/cards/fulfilment/warehouse'; import { FulfilmentOrder } from '@cardstack/catalog/cards/fulfilment/fulfilment-order'; +import { Button } from '@cardstack/pretui/components/button'; +import { Input } from '@cardstack/pretui/components/input'; export const PICK_LIST_STATUSES = [ { value: 'pending', label: 'Not started' }, @@ -90,11 +91,11 @@ export class PickItemField extends FieldDef { opacity: 0.55; } .pi-bin { - font-family: var(--font-mono, ui-monospace, monospace); + font-family: var(--font-mono); font-size: 0.78rem; font-weight: 700; letter-spacing: 0.06em; - color: var(--foreground, var(--boxel-dark)); + color: var(--foreground); } .pi-id { display: flex; @@ -102,21 +103,21 @@ export class PickItemField extends FieldDef { min-width: 0; } .pi-sku { - font-family: var(--font-mono, ui-monospace, monospace); + font-family: var(--font-mono); font-size: 0.75rem; font-weight: 700; - color: var(--foreground, var(--boxel-dark)); + color: var(--foreground); } .pi-name { font-size: 0.75rem; - color: var(--muted-foreground, var(--boxel-500)); + color: var(--muted-foreground); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .pi-qty { text-align: right; - font-family: var(--font-mono, ui-monospace, monospace); + font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-weight: 700; } @@ -127,8 +128,8 @@ export class PickItemField extends FieldDef { letter-spacing: 0.08em; color: color-mix( in oklch, - var(--destructive, var(--boxel-danger)) 60%, - var(--foreground, var(--boxel-dark)) + var(--destructive) 60%, + var(--foreground) ); } @@ -140,7 +141,7 @@ export class PickItemField extends FieldDef { {{@model.binLocation}} · {{@model.sku}} @@ -340,7 +341,7 @@ class PickListIsolated extends Component { {{#if this.canAct}}
    - { {{/if}}
    @@ -434,7 +437,7 @@ class PickListIsolated extends Component { exposed it. */ --panel-bg: color-mix(in oklch, var(--foreground) 3%, transparent); --panel-pad: var(--boxel-sp) var(--boxel-sp-lg) var(--boxel-sp-lg); - --panel-radius: var(--radius, 8px); + --panel-radius: var(--radius); /* The ONE vertical rhythm. It used to be `margin-top` on `.sec` plus a `.cols .sec { margin-top: 0 }` override for the side-by-side case — two mechanisms for one relationship, and `.cols` itself had neither, @@ -451,7 +454,7 @@ class PickListIsolated extends Component { padding: var(--boxel-sp-lg); background: var(--ful-bg, var(--boxel-light)); color: var(--ful-fg, var(--boxel-dark)); - font-family: var(--font-sans, inherit); + font-family: var(--font-sans); } .hd { display: flex; @@ -469,7 +472,7 @@ class PickListIsolated extends Component { } .num { margin: 2px 0 0; - font-family: var(--font-mono, ui-monospace, monospace); + font-family: var(--font-mono); font-size: var(--t-lg); line-height: 1; } @@ -482,7 +485,7 @@ class PickListIsolated extends Component { display: flex; align-items: baseline; gap: 6px; - font-family: var(--font-mono, ui-monospace, monospace); + font-family: var(--font-mono); font-variant-numeric: tabular-nums; } .count-num { @@ -517,8 +520,8 @@ class PickListIsolated extends Component { padding: var(--panel-pad); border: 2px solid var(--ful-perf); border-radius: var(--panel-radius); - background: var(--card, var(--boxel-light)); - color: var(--card-foreground, var(--boxel-dark)); + background: var(--card); + color: var(--card-foreground); } .cap { font-size: var(--t-micro); @@ -529,7 +532,7 @@ class PickListIsolated extends Component { } .bin { margin: 6px 0 0; - font-family: var(--font-mono, ui-monospace, monospace); + font-family: var(--font-mono); font-size: clamp(2rem, 7vw, 3.2rem); font-weight: 800; line-height: 1; @@ -542,7 +545,7 @@ class PickListIsolated extends Component { } .item-sku { margin: 2px 0 0; - font-family: var(--font-mono, ui-monospace, monospace); + font-family: var(--font-mono); font-size: var(--t-sm); letter-spacing: 0.08em; color: var(--ful-muted-fg, var(--boxel-500)); @@ -552,7 +555,7 @@ class PickListIsolated extends Component { font-size: var(--t-body); } .take strong { - font-family: var(--font-mono, ui-monospace, monospace); + font-family: var(--font-mono); font-size: var(--t-lg); } .for { @@ -561,7 +564,7 @@ class PickListIsolated extends Component { color: var(--ful-muted-fg, var(--boxel-500)); } .ord { - font-family: var(--font-mono, ui-monospace, monospace); + font-family: var(--font-mono); margin-left: 6px; } .scan { @@ -573,8 +576,8 @@ class PickListIsolated extends Component { font-weight: 600; color: color-mix( in oklch, - var(--destructive, var(--boxel-danger)) 58%, - var(--foreground, var(--boxel-dark)) + var(--destructive) 58%, + var(--foreground) ); } .acts { @@ -639,7 +642,7 @@ class PickListIsolated extends Component { } .r-bin, .r-sku { - font-family: var(--font-mono, ui-monospace, monospace); + font-family: var(--font-mono); font-weight: 700; } .r-bin { @@ -662,7 +665,7 @@ class PickListIsolated extends Component { } .r-qty { text-align: right; - font-family: var(--font-mono, ui-monospace, monospace); + font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-weight: 700; } @@ -673,8 +676,8 @@ class PickListIsolated extends Component { letter-spacing: 0.08em; color: color-mix( in oklch, - var(--destructive, var(--boxel-danger)) 60%, - var(--foreground, var(--boxel-dark)) + var(--destructive) 60%, + var(--foreground) ); } @@ -793,21 +796,21 @@ export class PickList extends CardDef { font-size: 0.88rem; } .p-num { - font-family: var(--font-mono, ui-monospace, monospace); + font-family: var(--font-mono); font-weight: 700; - color: var(--foreground, var(--boxel-dark)); + color: var(--foreground); } .p-wh { - font-family: var(--font-mono, ui-monospace, monospace); + font-family: var(--font-mono); font-size: 0.78rem; - color: var(--muted-foreground, var(--boxel-500)); + color: var(--muted-foreground); } .p-slot { text-align: right; - font-family: var(--font-mono, ui-monospace, monospace); + font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: 0.78rem; - color: var(--muted-foreground, var(--boxel-500)); + color: var(--muted-foreground); } @@ -818,7 +821,7 @@ export class PickList extends CardDef { {{@model.pickListNumber}} @@ -489,7 +491,11 @@ export class ProductReturn extends CardDef {
    <@fields.lineItems @format='embedded' /> {{else}} -

    No items on this RMA yet.

    + {{/if}} @@ -592,7 +598,7 @@ export class ProductReturn extends CardDef { exposed it. */ --panel-bg: color-mix(in oklch, var(--foreground) 3%, transparent); --panel-pad: var(--boxel-sp) var(--boxel-sp-lg) var(--boxel-sp-lg); - --panel-radius: var(--radius, 8px); + --panel-radius: var(--radius); /* The ONE vertical rhythm. It used to be `margin-top` on `.sec` plus a `.cols .sec { margin-top: 0 }` override for the side-by-side case — two mechanisms for one relationship, and `.cols` itself had neither, @@ -609,7 +615,7 @@ export class ProductReturn extends CardDef { padding: var(--boxel-sp-lg); background: var(--ful-bg, var(--boxel-light)); color: var(--ful-fg, var(--boxel-dark)); - font-family: var(--font-sans, inherit); + font-family: var(--font-sans); } .hd { display: flex; @@ -629,7 +635,7 @@ export class ProductReturn extends CardDef { } .num { margin: 2px 0 0; - font-family: var(--font-mono, ui-monospace, monospace); + font-family: var(--font-mono); font-size: var(--t-xl); line-height: 1; } @@ -660,7 +666,7 @@ export class ProductReturn extends CardDef { font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; - color: var(--ful-fg, var(--foreground, var(--boxel-dark))); + color: var(--ful-fg, var(--foreground)); } .reason { margin: 0; @@ -705,7 +711,7 @@ export class ProductReturn extends CardDef { .calc dd { margin: 0; text-align: right; - font-family: var(--font-mono, ui-monospace, monospace); + font-family: var(--font-mono); font-variant-numeric: tabular-nums; } .waived { @@ -755,10 +761,6 @@ export class ProductReturn extends CardDef { margin: 0; font-size: var(--t-sm); } - .empty { - font-size: var(--t-sm); - color: var(--ful-muted-fg, var(--boxel-500)); - } /* Section icons: one size, one muted colour, everywhere. They make the card scannable by shape; they must never compete with the heading. */ @@ -806,22 +808,22 @@ export class ProductReturn extends CardDef { font-size: 0.88rem; } .r-num { - font-family: var(--font-mono, ui-monospace, monospace); + font-family: var(--font-mono); font-weight: 700; - color: var(--foreground, var(--boxel-dark)); + color: var(--foreground); } .r-reason { - color: var(--muted-foreground, var(--boxel-500)); + color: var(--muted-foreground); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .r-slot { text-align: right; - font-family: var(--font-mono, ui-monospace, monospace); + font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-weight: 700; - color: var(--foreground, var(--boxel-dark)); + color: var(--foreground); } @container (width < 420px) { .r-emb { @@ -840,7 +842,7 @@ export class ProductReturn extends CardDef { {{@model.rmaNumber}} @@ -211,24 +211,24 @@ export class PublishingMetadataField extends FieldDef { display: flex; flex-direction: column; gap: 1px; - color: var(--foreground, var(--boxel-dark)); + color: var(--foreground); } .lic { font: 600 var(--boxel-font-sm); } .meta { font: var(--boxel-font-xs); - color: var(--muted-foreground, var(--boxel-450)); + color: var(--muted-foreground); } .ok, .no { font: var(--boxel-font-xs); } .ok { - color: var(--success, #2f855a); + color: var(--success); } .no { - color: var(--muted-foreground, var(--boxel-450)); + color: var(--muted-foreground); } From 6c5a020bd6bb24904a9b807ec6ed3efb37598e25 Mon Sep 17 00:00:00 2001 From: Richard Tan Date: Sat, 10 Oct 2026 00:49:35 +0800 Subject: [PATCH 3/8] Move only the reused Sole Vault cards, without the app prefix Order, Payment, Shipment and the five commands that move them are used only by the Sole Vault app, so they stay in the matrix realm. Sole Vault Person and Sole Vault Offer become Marketplace Member and Marketplace Offer: Person and Offer are already catalog card names. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../vaultkeeper.json | 4 +- .../yeezy-350-zebra-185.json | 8 +- cards/collectibles/Order/sv-2026-1041.json | 70 - cards/collectibles/Payment/sim-pi-3xkq2r.json | 39 - .../Shipment/sv-2026-1041-inbound.json | 37 - .../Spec/authenticate-item-command.json | 2 +- .../Spec/mark-delivered-command.json | 35 - ...lt-person.json => marketplace-member.json} | 12 +- ...ault-offer.json => marketplace-offer.json} | 10 +- .../Spec/place-order-command.json | 35 - .../Spec/process-payment-command.json | 35 - .../Spec/refund-order-command.json | 35 - cards/collectibles/Spec/seller-profile.json | 4 +- .../sole-vault-dispatch-shipment-command.json | 35 - cards/collectibles/Spec/sole-vault-order.json | 32 - .../collectibles/Spec/sole-vault-payment.json | 32 - .../Spec/sole-vault-shipment.json | 32 - cards/collectibles/authentication-record.gts | 12 +- cards/collectibles/collection-item.gts | 17 +- .../commands/add-to-collection-command.gts | 3 +- .../commands/authenticate-item-command.gts | 5 +- .../commands/create-listing-command.gts | 3 +- .../commands/create-set-command.gts | 3 +- .../commands/mark-delivered-command.gts | 126 -- .../commands/place-order-command.gts | 149 -- .../commands/process-payment-command.gts | 108 - .../commands/refund-order-command.gts | 102 - .../sole-vault-dispatch-shipment-command.gts | 114 -- cards/collectibles/listing.gts | 10 +- ...ault-person.gts => marketplace-member.gts} | 26 +- ...-vault-offer.gts => marketplace-offer.gts} | 36 +- cards/collectibles/seller-profile.gts | 31 +- cards/collectibles/sole-vault-order.gts | 1803 ----------------- cards/collectibles/sole-vault-payment.gts | 1217 ----------- cards/collectibles/sole-vault-shipment.gts | 1301 ------------ 35 files changed, 88 insertions(+), 5435 deletions(-) rename cards/collectibles/{SoleVaultPerson => MarketplaceMember}/vaultkeeper.json (87%) rename cards/collectibles/{Offer => MarketplaceOffer}/yeezy-350-zebra-185.json (82%) delete mode 100644 cards/collectibles/Order/sv-2026-1041.json delete mode 100644 cards/collectibles/Payment/sim-pi-3xkq2r.json delete mode 100644 cards/collectibles/Shipment/sv-2026-1041-inbound.json delete mode 100644 cards/collectibles/Spec/mark-delivered-command.json rename cards/collectibles/Spec/{sole-vault-person.json => marketplace-member.json} (64%) rename cards/collectibles/Spec/{sole-vault-offer.json => marketplace-offer.json} (92%) delete mode 100644 cards/collectibles/Spec/place-order-command.json delete mode 100644 cards/collectibles/Spec/process-payment-command.json delete mode 100644 cards/collectibles/Spec/refund-order-command.json delete mode 100644 cards/collectibles/Spec/sole-vault-dispatch-shipment-command.json delete mode 100644 cards/collectibles/Spec/sole-vault-order.json delete mode 100644 cards/collectibles/Spec/sole-vault-payment.json delete mode 100644 cards/collectibles/Spec/sole-vault-shipment.json delete mode 100644 cards/collectibles/commands/mark-delivered-command.gts delete mode 100644 cards/collectibles/commands/place-order-command.gts delete mode 100644 cards/collectibles/commands/process-payment-command.gts delete mode 100644 cards/collectibles/commands/refund-order-command.gts delete mode 100644 cards/collectibles/commands/sole-vault-dispatch-shipment-command.gts rename cards/collectibles/{sole-vault-person.gts => marketplace-member.gts} (97%) rename cards/collectibles/{sole-vault-offer.gts => marketplace-offer.gts} (97%) delete mode 100644 cards/collectibles/sole-vault-order.gts delete mode 100644 cards/collectibles/sole-vault-payment.gts delete mode 100644 cards/collectibles/sole-vault-shipment.gts diff --git a/cards/collectibles/SoleVaultPerson/vaultkeeper.json b/cards/collectibles/MarketplaceMember/vaultkeeper.json similarity index 87% rename from cards/collectibles/SoleVaultPerson/vaultkeeper.json rename to cards/collectibles/MarketplaceMember/vaultkeeper.json index 22433d7e..2a5294c7 100644 --- a/cards/collectibles/SoleVaultPerson/vaultkeeper.json +++ b/cards/collectibles/MarketplaceMember/vaultkeeper.json @@ -19,8 +19,8 @@ }, "meta": { "adoptsFrom": { - "module": "../sole-vault-person", - "name": "SoleVaultPerson" + "module": "../marketplace-member", + "name": "MarketplaceMember" } } } diff --git a/cards/collectibles/Offer/yeezy-350-zebra-185.json b/cards/collectibles/MarketplaceOffer/yeezy-350-zebra-185.json similarity index 82% rename from cards/collectibles/Offer/yeezy-350-zebra-185.json rename to cards/collectibles/MarketplaceOffer/yeezy-350-zebra-185.json index c03aa06c..92875040 100644 --- a/cards/collectibles/Offer/yeezy-350-zebra-185.json +++ b/cards/collectibles/MarketplaceOffer/yeezy-350-zebra-185.json @@ -14,7 +14,7 @@ "respondedAt": null, "expiresAt": "2026-08-31", "cardInfo": { - "name": "Offer $185 \u2014 Yeezy 350 V2 Zebra", + "name": "Offer $185 — Yeezy 350 V2 Zebra", "summary": "An open opening offer, 16% below the $220 asking price. `counterTo` is empty, which is what marks it as the head of a negotiation rather than a reply.", "notes": null, "cardThumbnailURL": null @@ -28,14 +28,14 @@ }, "offeredBy": { "links": { - "self": "../SoleVaultPerson/vaultkeeper" + "self": "../MarketplaceMember/vaultkeeper" } } }, "meta": { "adoptsFrom": { - "module": "../sole-vault-offer", - "name": "Offer" + "module": "../marketplace-offer", + "name": "MarketplaceOffer" } } } diff --git a/cards/collectibles/Order/sv-2026-1041.json b/cards/collectibles/Order/sv-2026-1041.json deleted file mode 100644 index 017f8568..00000000 --- a/cards/collectibles/Order/sv-2026-1041.json +++ /dev/null @@ -1,70 +0,0 @@ -{ - "data": { - "type": "card", - "attributes": { - "reference": "SV-2026-1041", - "price": { - "amount": 220, - "currency": { - "code": "USD" - } - }, - "shippingPrice": { - "amount": 15, - "currency": { - "code": "USD" - } - }, - "authFee": { - "amount": 10, - "currency": { - "code": "USD" - } - }, - "platformFee": { - "amount": 6.6, - "currency": { - "code": "USD" - } - }, - "orderStatus": "authenticating", - "placedAt": "2026-08-18", - "paidAt": "2026-08-18", - "shippedAt": "2026-08-19", - "authenticatedAt": null, - "deliveredAt": null, - "completedAt": null, - "trackingNumber": "1Z999AA10123456784", - "paymentReference": "sim_pi_3XkQ2r", - "cardInfo": { - "name": "SV-2026-1041 \u2014 Yeezy 350 V2 Zebra", - "summary": "An escrow order mid-flight: paid, shipped, now under authentication. Funds are held and the verdict decides whether it completes or refunds.", - "notes": null, - "cardThumbnailURL": null - } - }, - "relationships": { - "listing": { - "links": { - "self": "../Listing/yeezy-350-zebra-11" - } - }, - "seller": { - "links": { - "self": "../SellerProfile/sneakerking" - } - }, - "buyer": { - "links": { - "self": "../SoleVaultPerson/vaultkeeper" - } - } - }, - "meta": { - "adoptsFrom": { - "module": "../sole-vault-order", - "name": "Order" - } - } - } -} diff --git a/cards/collectibles/Payment/sim-pi-3xkq2r.json b/cards/collectibles/Payment/sim-pi-3xkq2r.json deleted file mode 100644 index 12777c21..00000000 --- a/cards/collectibles/Payment/sim-pi-3xkq2r.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "data": { - "type": "card", - "attributes": { - "amount": { - "amount": 251.6, - "currency": { - "code": "USD" - } - }, - "paymentState": "authorized", - "direction": "charge", - "processorReference": "sim_pi_3XkQ2r", - "processor": "simulated", - "initiatedAt": "2026-08-18", - "settledAt": null, - "reason": null, - "cardInfo": { - "name": "Charge $251.60 \u2014 SV-2026-1041", - "summary": "The escrow hold: authorized, not captured. Funds are reserved with the processor and only settle once the buyer confirms delivery. Processor is 'simulated' \u2014 this realm records payments, it never moves money.", - "notes": null, - "cardThumbnailURL": null - } - }, - "relationships": { - "order": { - "links": { - "self": "../Order/sv-2026-1041" - } - } - }, - "meta": { - "adoptsFrom": { - "module": "../sole-vault-payment", - "name": "Payment" - } - } - } -} diff --git a/cards/collectibles/Shipment/sv-2026-1041-inbound.json b/cards/collectibles/Shipment/sv-2026-1041-inbound.json deleted file mode 100644 index 2a516f4a..00000000 --- a/cards/collectibles/Shipment/sv-2026-1041-inbound.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "data": { - "type": "card", - "attributes": { - "leg": "to-authenticator", - "shipmentStatus": "delivered", - "carrier": "UPS", - "trackingNumber": "1Z999AA10123456784", - "trackingUrl": "https://www.ups.com/track?tracknum=1Z999AA10123456784", - "shippedFrom": "Los Angeles, CA", - "shippedTo": "Sole Vault Authentication \u2014 Carlstadt, NJ", - "labelCreatedAt": "2026-08-18", - "shippedAt": "2026-08-19", - "deliveredAt": "2026-08-21", - "estimatedDelivery": "2026-08-21", - "cardInfo": { - "name": "To authenticator \u2014 SV-2026-1041", - "summary": "The inbound leg: seller to authentication centre, delivered. The outbound leg to the buyer does not exist yet, which is exactly why legs are separate cards rather than two tracking fields on one.", - "notes": null, - "cardThumbnailURL": null - } - }, - "relationships": { - "order": { - "links": { - "self": "../Order/sv-2026-1041" - } - } - }, - "meta": { - "adoptsFrom": { - "module": "../sole-vault-shipment", - "name": "Shipment" - } - } - } -} diff --git a/cards/collectibles/Spec/authenticate-item-command.json b/cards/collectibles/Spec/authenticate-item-command.json index 8d7e1be4..0633d0a0 100644 --- a/cards/collectibles/Spec/authenticate-item-command.json +++ b/cards/collectibles/Spec/authenticate-item-command.json @@ -9,7 +9,7 @@ "module": "../commands/authenticate-item-command", "name": "default" }, - "readMe": "## Authenticate Item\n\nCreates a full `AuthenticationRecord` (service, authenticator, certificate, notes) and — when the verdict is `passed` — syncs `verifiedOn`/`verifiedBy`/`verificationReference` onto the linked `CollectionItem`, so a prerendered fitted tile can show a verified badge without resolving a link.\n\n### Example\n\n`linkedExamples` points at the command's natural target: `CollectionItem/aj1-lost-and-found-105`.\n\n### A failed verdict does not auto-refund\n\n`authentication-record.gts`'s own header says failure \"in an escrow flow this refunds the buyer\" — that is a fact about what should happen next, not something this command does unasked. Refund is money moving, and `RefundOrderCommand` is its own explicit call.", + "readMe": "## Authenticate Item\n\nCreates a full `AuthenticationRecord` (service, authenticator, certificate, notes) and — when the verdict is `passed` — syncs `verifiedOn`/`verifiedBy`/`verificationReference` onto the linked `CollectionItem`, so a prerendered fitted tile can show a verified badge without resolving a link.\n\n### Example\n\n`linkedExamples` points at the command's natural target: `CollectionItem/aj1-lost-and-found-105`.\n\n### A failed verdict does not auto-refund\n\n`authentication-record.gts`'s own header says failure \"in an escrow flow this refunds the buyer\" — that is a fact about what should happen next, not something this command does unasked. Refund is money moving, and a separate, explicit call.", "containedExamples": [], "cardInfo": { "name": "Authenticate Item", diff --git a/cards/collectibles/Spec/mark-delivered-command.json b/cards/collectibles/Spec/mark-delivered-command.json deleted file mode 100644 index e2c9bf7e..00000000 --- a/cards/collectibles/Spec/mark-delivered-command.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "data": { - "type": "card", - "attributes": { - "cardTitle": "Mark Delivered", - "cardDescription": "Record a shipment leg as delivered and advance the order status appropriately for that leg.", - "specType": "command", - "ref": { - "module": "../commands/mark-delivered-command", - "name": "default" - }, - "readMe": "## Mark Delivered\n\nLeg-aware: a `seller-to-auth` delivery advances `Order.orderStatus` to `authenticating` (item arrived at the auth centre, not yet with the buyer); an `auth-to-buyer` delivery advances it to `delivered`.\n\n### Example\n\n`linkedExamples` points at the command's natural target: `Shipment/sv-2026-1041-inbound`.\n\n### Honest gap, stated in the command's own header comment\n\n`delivered → completed` (the buyer's own confirmation that releases funds to the seller) has no command — no command marks it. Left as an explicit, undone edge rather than silently folded into a carrier-scan event.", - "containedExamples": [], - "cardInfo": { - "name": "Mark Delivered", - "summary": null, - "notes": null, - "cardThumbnailURL": null - } - }, - "relationships": { - "linkedExamples.0": { - "links": { - "self": "../Shipment/sv-2026-1041-inbound" - } - } - }, - "meta": { - "adoptsFrom": { - "module": "https://cardstack.com/base/spec", - "name": "Spec" - } - } - } -} diff --git a/cards/collectibles/Spec/sole-vault-person.json b/cards/collectibles/Spec/marketplace-member.json similarity index 64% rename from cards/collectibles/Spec/sole-vault-person.json rename to cards/collectibles/Spec/marketplace-member.json index ec52faac..cd160a24 100644 --- a/cards/collectibles/Spec/sole-vault-person.json +++ b/cards/collectibles/Spec/marketplace-member.json @@ -2,17 +2,17 @@ "data": { "type": "card", "attributes": { - "cardTitle": "Sole Vault Person", + "cardTitle": "Marketplace Member", "cardDescription": "The Sole Vault app's own generic person profile — a subclass of the pulled matrix Person block (PersonBase), extended with the app's isolated/edit/fitted views. Used for a buyer, or anyone not specifically a seller.", "specType": "card", "ref": { - "module": "../sole-vault-person", - "name": "SoleVaultPerson" + "module": "../marketplace-member", + "name": "MarketplaceMember" }, - "readMe": "### Why a subclass, not an edit\n\nA pulled building block is meant to stay swappable and re-pullable: if `person-base.gts` gets a fix or a new field upstream, re-pulling it should be a clean file replace, not a merge against app-specific additions. Every format override (`isolated`, `edit`, `fitted`, a recoloured `embedded`) is declared here. `person-base.gts` itself carries zero Sole-Vault-specific code.\n\n### Not the seller profile\n\nThis card has no `rating` field — that lives on the sibling `SellerProfile` (`seller-profile.gts`), a separate, genuinely reusable marketplace building block, not a subclass of this file. `Order.buyer`, `Offer.offeredBy` and `CollectionItem.owner` narrow to plain `SoleVaultPerson`; only `Order.seller` and `Listing.seller` narrow to `SellerProfile`. Splitting them keeps a buyer from carrying a meaningless, always-unset rating field.\n\n### Reused, not rebuilt\n\n`photo` is the catalog's `image-source` field (inherited from `PersonBase`).\n\n### Non-goals\n\nThis card does not carry a seller rating, review count, or any trust signal — see `SellerProfile` for that. It does not distinguish roles by its own type; a buyer is simply whichever `SoleVaultPerson` instance a link field points at.", + "readMe": "### Why a subclass, not an edit\n\nA pulled building block is meant to stay swappable and re-pullable: if `person-base.gts` gets a fix or a new field upstream, re-pulling it should be a clean file replace, not a merge against app-specific additions. Every format override (`isolated`, `edit`, `fitted`, a recoloured `embedded`) is declared here. `person-base.gts` itself carries zero Sole-Vault-specific code.\n\n### Not the seller profile\n\nThis card has no `rating` field — that lives on the sibling `SellerProfile` (`seller-profile.gts`), a separate, genuinely reusable marketplace building block, not a subclass of this file. `Order.buyer`, `Offer.offeredBy` and `CollectionItem.owner` narrow to plain `MarketplaceMember`; only `Order.seller` and `Listing.seller` narrow to `SellerProfile`. Splitting them keeps a buyer from carrying a meaningless, always-unset rating field.\n\n### Reused, not rebuilt\n\n`photo` is the catalog's `image-source` field (inherited from `PersonBase`).\n\n### Non-goals\n\nThis card does not carry a seller rating, review count, or any trust signal — see `SellerProfile` for that. It does not distinguish roles by its own type; a buyer is simply whichever `MarketplaceMember` instance a link field points at.", "containedExamples": [], "cardInfo": { - "name": "Sole Vault Person", + "name": "Marketplace Member", "summary": null, "notes": null, "cardThumbnailURL": null @@ -21,7 +21,7 @@ "relationships": { "linkedExamples.0": { "links": { - "self": "../SoleVaultPerson/vaultkeeper" + "self": "../MarketplaceMember/vaultkeeper" } } }, diff --git a/cards/collectibles/Spec/sole-vault-offer.json b/cards/collectibles/Spec/marketplace-offer.json similarity index 92% rename from cards/collectibles/Spec/sole-vault-offer.json rename to cards/collectibles/Spec/marketplace-offer.json index 57c5a0fe..03cc98ac 100644 --- a/cards/collectibles/Spec/sole-vault-offer.json +++ b/cards/collectibles/Spec/marketplace-offer.json @@ -2,23 +2,23 @@ "data": { "type": "card", "attributes": { - "cardTitle": "Sole Vault Offer", + "cardTitle": "Marketplace Offer", "cardDescription": "A price proposed below asking, and the negotiation chain that follows.", "specType": "card", "ref": { - "module": "../sole-vault-offer", - "name": "Offer" + "module": "../marketplace-offer", + "name": "MarketplaceOffer" }, "readMe": "## Offer1 Offer System — offer, counter, negotiate.\n\n### One card per offer, not one per negotiation\n\nA counter-offer is a **new** Offer linked back through `counterTo`, never an edit to the original. Two reasons, and the first is the one that matters:\n\n1. An offer is a commitment someone made at a moment. Overwriting `amount` when the seller counters destroys the record of what the buyer actually offered — which is precisely what both parties argue about later.\n2. It makes the thread queryable. \"Every offer on this listing\" and \"the chain that produced this deal\" are both plain queries over links, with no array of embedded revisions to walk.\n\n`counterTo` is the negotiation's spine: null means an opening offer, and following the chain backwards reconstructs the whole exchange in order.\n\n### `open` is the only non-terminal state\n\nThat is the whole design. Every other value closes the card permanently:\n\n- A counter does not reopen this offer — it terminates it and starts a new one, so `countered` leads nowhere.\n- `accepted` leads nowhere because the next step is an **Order**, a different card with its own graph. Letting an accepted offer be un-accepted would orphan that order.\n- `declined` / `withdrawn` / `expired` are terminal for the same reason: re-offering is a new offer, and the history of the refused one is evidence.\n\n### The discount is computed at render time, never stored\n\n`listingPrice` is denormalized onto the card so a prerendered tile can show offer-vs-asking without resolving the listing — but the **difference** is a render-time getter. A stored discount goes stale the moment the seller re-prices, and a confident \"16% below asking\" that is no longer true is worse than no badge at all. The embedded row also drops that ratio a step in size and weight, so the money figure stays the thing read first.\n\n### Roles swap down the chain\n\nThe field is `offeredBy`, not `buyer`: on a counter it is the seller who is offering.", "containedExamples": [], "cardInfo": { - "name": "Sole Vault Offer" + "name": "Marketplace Offer" } }, "relationships": { "linkedExamples.0": { "links": { - "self": "../Offer/yeezy-350-zebra-185" + "self": "../MarketplaceOffer/yeezy-350-zebra-185" } } }, diff --git a/cards/collectibles/Spec/place-order-command.json b/cards/collectibles/Spec/place-order-command.json deleted file mode 100644 index 8fb050f4..00000000 --- a/cards/collectibles/Spec/place-order-command.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "data": { - "type": "card", - "attributes": { - "cardTitle": "Place Order", - "cardDescription": "Create an order from an active listing, snapshotting price and fees, and mark the listing sold.", - "specType": "command", - "ref": { - "module": "../commands/place-order-command", - "name": "default" - }, - "readMe": "## Place Order\n\na buyer commits to a listing. Snapshots price, shipping price, a flat authentication fee, and a percentage platform fee onto a new `Order`, then flips the source `Listing`'s status to `sold`.\n\n### Example, per block-factory's command-example rule\n\nA command has no `containedExamples` slot of its own — `linkedExamples` points at the command's natural TARGET instance instead: the active `Listing` this command would act on (`Listing/yeezy-350-zebra-11`).\n\n### Fee snapshot, not a re-derived rate\n\n`authFee` ($10 flat) and `platformFee` (3% of price) are computed once at order-creation time and stored — never re-derived from a rate later, so a fee-schedule change never rewrites the receipt of a past order.\n\n### Non-goals\n\nDoes not charge anything (that is `ProcessPayment`'s job) and does not create a `Shipment` (that is `DispatchShipment`'s job).", - "containedExamples": [], - "cardInfo": { - "name": "Place Order", - "summary": null, - "notes": null, - "cardThumbnailURL": null - } - }, - "relationships": { - "linkedExamples.0": { - "links": { - "self": "../Listing/yeezy-350-zebra-11" - } - } - }, - "meta": { - "adoptsFrom": { - "module": "https://cardstack.com/base/spec", - "name": "Spec" - } - } - } -} diff --git a/cards/collectibles/Spec/process-payment-command.json b/cards/collectibles/Spec/process-payment-command.json deleted file mode 100644 index 45d38d73..00000000 --- a/cards/collectibles/Spec/process-payment-command.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "data": { - "type": "card", - "attributes": { - "cardTitle": "Process Payment", - "cardDescription": "Charge the order total and move the order from pending-payment to paid, recording a Payment.", - "specType": "command", - "ref": { - "module": "../commands/process-payment-command", - "name": "default" - }, - "readMe": "## Process Payment\n\nCharges `Order.total` (an escrow simulation — see `order.gts`'s own header note that a realm cannot hold real funds), records a `captured` `Payment`, and advances `Order.orderStatus` to `paid`.\n\n### Example\n\n`linkedExamples` points at the command's natural target: `Order/sv-2026-1041`.\n\n### State-machine guarded\n\nChecks `canOrderTransition(order.orderStatus, 'paid')` before writing anything — an order not in `pending-payment` is refused, not silently re-charged.\n\n### Non-goals\n\nDoes not model a real processor's `pending → authorized → captured` round trip; the effect (order moves to `paid`, funds now held) is what matters here, not a faithful gateway simulation.", - "containedExamples": [], - "cardInfo": { - "name": "Process Payment", - "summary": null, - "notes": null, - "cardThumbnailURL": null - } - }, - "relationships": { - "linkedExamples.0": { - "links": { - "self": "../Order/sv-2026-1041" - } - } - }, - "meta": { - "adoptsFrom": { - "module": "https://cardstack.com/base/spec", - "name": "Spec" - } - } - } -} diff --git a/cards/collectibles/Spec/refund-order-command.json b/cards/collectibles/Spec/refund-order-command.json deleted file mode 100644 index f9b35fa7..00000000 --- a/cards/collectibles/Spec/refund-order-command.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "data": { - "type": "card", - "attributes": { - "cardTitle": "Refund Order", - "cardDescription": "Record a refund payment for the order total and move the order to refunded.", - "specType": "command", - "ref": { - "module": "../commands/refund-order-command", - "name": "default" - }, - "readMe": "## Refund Order\n\nRecords a new `Payment` with `direction: 'refund'` for the order's total, and moves `Order.orderStatus` to `refunded`.\n\n### Example\n\n`linkedExamples` points at the command's natural target: `Order/sv-2026-1041`.\n\n### A refund is a new ledger row, never an edit\n\nMatches `PaymentStateField`'s own transition graph (`captured` only ever leads to `refunded`, one-way) and `payment.gts`'s `signedAmount`, which renders a refund with a minus sign rather than mutating the original charge's amount. Two rows, not one row that changed its mind.\n\n### Non-goals\n\nDoes not decide WHEN a refund is warranted (a failed authentication, a dispute resolution) — that judgment belongs to the caller (e.g. `AuthenticateItem`'s own header note says a failed verdict does not auto-refund).", - "containedExamples": [], - "cardInfo": { - "name": "Refund Order", - "summary": null, - "notes": null, - "cardThumbnailURL": null - } - }, - "relationships": { - "linkedExamples.0": { - "links": { - "self": "../Order/sv-2026-1041" - } - } - }, - "meta": { - "adoptsFrom": { - "module": "https://cardstack.com/base/spec", - "name": "Spec" - } - } - } -} diff --git a/cards/collectibles/Spec/seller-profile.json b/cards/collectibles/Spec/seller-profile.json index fa80c19a..91ef7d66 100644 --- a/cards/collectibles/Spec/seller-profile.json +++ b/cards/collectibles/Spec/seller-profile.json @@ -3,13 +3,13 @@ "type": "card", "attributes": { "cardTitle": "Seller Profile", - "cardDescription": "A genuinely reusable marketplace-seller building block — identity plus a numeric rating — not a Sole Vault–specific concept. Extends the app's own SoleVaultPerson (itself a subclass of the pulled matrix Person block) so it inherits the dark-luxury views for free.", + "cardDescription": "A genuinely reusable marketplace-seller building block — identity plus a numeric rating — not a Sole Vault–specific concept. Extends the app's own MarketplaceMember (itself a subclass of the pulled matrix Person block) so it inherits the dark-luxury views for free.", "specType": "card", "ref": { "module": "../seller-profile", "name": "SellerProfile" }, - "readMe": "### Why a subclass of SoleVaultPerson, not of PersonBase directly\n\nThe reusable *essence* of this block is the schema (person identity + rating), not these specific views. Extending `SoleVaultPerson` inherits the dark-luxury isolated/edit/fitted templates for free rather than duplicating ~400 lines of near-identical markup; a consuming app in a different visual world would override the formats the same way any card override works — same as `SoleVaultPerson` itself overrides `PersonBase`'s plain `embedded`.\n\n### Split from SoleVaultPerson on purpose\n\n`rating` has no meaning on a buyer. `Order.buyer`, `Offer.offeredBy` and `CollectionItem.owner` narrow to plain `SoleVaultPerson`; only `Order.seller` and `Listing.seller` narrow to `SellerProfile`. One real-world person can still be linked from both kinds of field on different cards — a `SellerProfile` instance satisfies a `SoleVaultPerson`-typed link too, since it is one via inheritance.\n\n### Non-goals\n\nThis card does not carry review text, only the aggregate numeric rating (written reviews are a separate concept). It does not distinguish a permanent seller identity from a one-off listing — a person becomes \"a seller\" simply by being the target of a `seller` link, not by any flag on this card itself.", + "readMe": "### Why a subclass of MarketplaceMember, not of PersonBase directly\n\nThe reusable *essence* of this block is the schema (person identity + rating), not these specific views. Extending `MarketplaceMember` inherits the dark-luxury isolated/edit/fitted templates for free rather than duplicating ~400 lines of near-identical markup; a consuming app in a different visual world would override the formats the same way any card override works — same as `MarketplaceMember` itself overrides `PersonBase`'s plain `embedded`.\n\n### Split from MarketplaceMember on purpose\n\n`rating` has no meaning on a buyer. `Order.buyer`, `Offer.offeredBy` and `CollectionItem.owner` narrow to plain `MarketplaceMember`; only `Order.seller` and `Listing.seller` narrow to `SellerProfile`. One real-world person can still be linked from both kinds of field on different cards — a `SellerProfile` instance satisfies a `MarketplaceMember`-typed link too, since it is one via inheritance.\n\n### Non-goals\n\nThis card does not carry review text, only the aggregate numeric rating (written reviews are a separate concept). It does not distinguish a permanent seller identity from a one-off listing — a person becomes \"a seller\" simply by being the target of a `seller` link, not by any flag on this card itself.", "containedExamples": [], "cardInfo": { "name": "Seller Profile", diff --git a/cards/collectibles/Spec/sole-vault-dispatch-shipment-command.json b/cards/collectibles/Spec/sole-vault-dispatch-shipment-command.json deleted file mode 100644 index a0b2f46d..00000000 --- a/cards/collectibles/Spec/sole-vault-dispatch-shipment-command.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "data": { - "type": "card", - "attributes": { - "cardTitle": "Sole Vault Dispatch Shipment", - "cardDescription": "Create a shipment leg for a paid order, and move the order from paid to shipped.", - "specType": "command", - "ref": { - "module": "../commands/sole-vault-dispatch-shipment-command", - "name": "default" - }, - "readMe": "## Dispatch Shipment\n\nCreates a `Shipment` (`label-created`) linked to the order and moves `Order.orderStatus` to `shipped`.\n\n### Example\n\n`linkedExamples` points at the command's natural target: `Order/sv-2026-1041`.\n\n### Leg-aware\n\n`leg` defaults to `seller-to-auth` — the first of two shipment legs (seller → authentication centre → buyer). See Mark Delivered for how the second leg (`auth-to-buyer`) is distinguished when it lands.", - "containedExamples": [], - "cardInfo": { - "name": "Sole Vault Dispatch Shipment", - "summary": null, - "notes": null, - "cardThumbnailURL": null - } - }, - "relationships": { - "linkedExamples.0": { - "links": { - "self": "../Order/sv-2026-1041" - } - } - }, - "meta": { - "adoptsFrom": { - "module": "https://cardstack.com/base/spec", - "name": "Spec" - } - } - } -} diff --git a/cards/collectibles/Spec/sole-vault-order.json b/cards/collectibles/Spec/sole-vault-order.json deleted file mode 100644 index dd519cd3..00000000 --- a/cards/collectibles/Spec/sole-vault-order.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "data": { - "type": "card", - "attributes": { - "cardTitle": "Sole Vault Order", - "cardDescription": "One escrow-protected purchase of one Listing, as a state machine holding money.", - "specType": "card", - "ref": { - "module": "../sole-vault-order", - "name": "Order" - }, - "readMe": "## Order\n\nWithout an order the marketplace can only be browsed, never transacted.\n\n### The escrow graph is the point\n\nAn order is not a record with a status string on it — it is a state machine holding someone's money. `OrderStatusField` is built with `statusField`, so it carries the transition graph and can answer *may I?* (`canOrderTransition`) rather than only *what are the choices?* (`nextOrderStatuses`). A command that writes a status directly defeats the entire block.\n\n**The absent edges carry the meaning:**\n\n- Nothing returns to `pending-payment` — un-charging a card is a refund, its own terminal state with its own money movement.\n- `completed` and `refunded` both lead nowhere. Once funds have moved, this graph holds nothing; a later problem is a new dispute record, not an edit to history.\n- Authentication failure is `authenticating → refunded`, and there is deliberately **no** `authenticating → delivered` shortcut. A failed legit check refunds the buyer, so no path may ship a fake onward.\n- Every money-holding state can reach `disputed`, because a problem can surface at any point while funds are held.\n\n### Money: four snapshots, one computed total\n\nItem price, shipping, auth fee and platform fee are each their own `AmountWithCurrency`. `total` is **computed** from them, never stored — a stored total survives a change to any component and then disagrees with the sum of its own parts, with nothing in the UI revealing which is right.\n\nFees are **snapshots, not rates**: `platformFee` holds the money actually charged, not \"3%\". A rate re-derived at read time silently rewrites every past order the day the rate changes, and a buyer's receipt must not move.\n\n### Derived, never stored\n\n`fundsHeld` and `isPaid` are computed from the status and the event dates, matching the rule the rest of this family uses (`verified` from `verifiedOn`, `forSale` from `listedAt`). A stored boolean drifts from the thing that caused it.\n\n### Denormalized for prerendered fitted\n\n`productTitle`, `sellerName`, `buyerName` and `conditionCode` are computed fields, because an Orders tab is a grid, a grid is prerendered fitted, and prerendered fitted **cannot resolve `linksTo`**. A tile reading `listing.productTitle` directly renders blank — which would be every tile in the tab, since the product name is the headline. These resolve at index time into the search document.\n\n### What this block does not do\n\nCharge anything. A realm has no processor and no balance, so `Payment` records what an external processor reported and the commands that move this graph are simulations. Stated plainly rather than implied: a card that looks like it takes money and does not is worse than one that never pretended.", - "containedExamples": [], - "cardInfo": { - "name": "Sole Vault Order" - } - }, - "relationships": { - "linkedExamples.0": { - "links": { - "self": "../Order/sv-2026-1041" - } - } - }, - "meta": { - "adoptsFrom": { - "module": "https://cardstack.com/base/spec", - "name": "Spec" - } - } - } -} diff --git a/cards/collectibles/Spec/sole-vault-payment.json b/cards/collectibles/Spec/sole-vault-payment.json deleted file mode 100644 index 9dc92322..00000000 --- a/cards/collectibles/Spec/sole-vault-payment.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "data": { - "type": "card", - "attributes": { - "cardTitle": "Sole Vault Payment", - "cardDescription": "The record of one money movement against one Order. Records, never moves, money.", - "specType": "card", - "ref": { - "module": "../sole-vault-payment", - "name": "Payment" - }, - "readMe": "## Payment\n\n**Read this before extending it: this card does not move money and cannot.** A realm has no processor, no card vault and no balance. Everything here records what an external processor reported, and the commands that write it are simulations. That is a deliberate boundary, not an unfinished feature — escrow belongs to a payment processor such as Stripe Connect, which a card does not impersonate. The sample instance sets `processor: \"simulated\"` for exactly that reason.\n\n### Why it is separate from Order\n\nAn order has one lifecycle; its money can have several movements — an authorization, a capture, a partial refund, a chargeback reversal. Folding them into `Order` forces one status field to describe two different things, and the moment a refund is partial the model breaks. One Order links to many Payment records; each is immutable history.\n\n### The state graph mirrors a processor, it does not invent one\n\n- `authorized` **is** the escrow state: funds reserved with the processor, not yet taken. This is what \"held in escrow\" actually means technically.\n- `captured → refunded` is the only edge out of a settled payment, and it is one-way. A refund is never un-refunded; that would be a new charge.\n- `failed` is **terminal**, because a retry is a NEW payment record. Reusing this one would erase the evidence that the first attempt was declined.\n\n### Direction, not a negative number\n\n`direction` says `charge` or `refund` and `amount` is always positive. A negative money amount renders as `-$175.00` in some formatters and `($175.00)` in others, and then every aggregate has to remember which convention it is summing. `signedAmount` is the one computed display string, so the atom, the row and any future ledger view cannot disagree about how a refund is written.\n\n### Denormalized for prerendered fitted\n\n`orderReference` and `productTitle` are computed, because prerendered fitted cannot resolve `linksTo`.", - "containedExamples": [], - "cardInfo": { - "name": "Sole Vault Payment" - } - }, - "relationships": { - "linkedExamples.0": { - "links": { - "self": "../Payment/sim-pi-3xkq2r" - } - } - }, - "meta": { - "adoptsFrom": { - "module": "https://cardstack.com/base/spec", - "name": "Spec" - } - } - } -} diff --git a/cards/collectibles/Spec/sole-vault-shipment.json b/cards/collectibles/Spec/sole-vault-shipment.json deleted file mode 100644 index 4838b1c1..00000000 --- a/cards/collectibles/Spec/sole-vault-shipment.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "data": { - "type": "card", - "attributes": { - "cardTitle": "Sole Vault Shipment", - "cardDescription": "One leg of an item's physical journey, with its own carrier and tracking.", - "specType": "card", - "ref": { - "module": "../sole-vault-shipment", - "name": "Shipment" - }, - "readMe": "### The two-leg shape is the domain, not an implementation detail\n\nIn an authenticated marketplace the item does **not** go seller → buyer. It goes seller → authentication centre → buyer, and those are two different shipments with two tracking numbers, two carriers and two failure modes. An item ships to the authentication centre and then to the buyer, so `leg` distinguishes them and an Order links to several.\n\nCollapsing them into one card with two tracking-number fields is the tempting shortcut, and it breaks immediately: the inbound leg can be delivered while the outbound leg does not exist yet, and one status field cannot describe that. The sample instance is the inbound leg, delivered, with no outbound leg created — which is the state that proves the point.\n\n### `exception` is deliberately not terminal\n\nMost carrier exceptions resolve and the parcel moves again, so `exception → in-transit` exists. Only `delivered` and `lost` end a leg. There is no edge out of `delivered`: a parcel that comes back is the `return-to-seller` leg — a different card — not this one reversing.\n\n`lost` on a held order is a refund trigger, not a status tidy-up.\n\n### Two field decisions worth not undoing\n\n- **`trackingUrl` is a `StringField`, not base `url`.** Carrier tracking URLs carry query strings, and `UrlField` silently drops a URL that has one — the whole field vanishes from the search doc with no error. Verified behaviour in this repo, not a guess.\n- **`trackingNumber` is treated as an identifier** everywhere it renders: monospace, tabular, never ellipsised. It gets read aloud and typed into a carrier's site, so a truncated one is useless.\n\n### No carrier API\n\n`carrier` and `trackingNumber` are recorded facts; `trackingUrl` is a link a human clicks. This realm does not poll UPS. A command can update the status from a webhook payload someone else received; nothing here reaches out.\n\n### Derived\n\n`isDelivered` from `deliveredAt`, `needsAttention` from the `exception` status, and `legLabel` so no consumer hand-maps the three leg values and drifts from the others.", - "containedExamples": [], - "cardInfo": { - "name": "Sole Vault Shipment" - } - }, - "relationships": { - "linkedExamples.0": { - "links": { - "self": "../Shipment/sv-2026-1041-inbound" - } - } - }, - "meta": { - "adoptsFrom": { - "module": "https://cardstack.com/base/spec", - "name": "Spec" - } - } - } -} diff --git a/cards/collectibles/authentication-record.gts b/cards/collectibles/authentication-record.gts index dfa5c840..7164dfaf 100644 --- a/cards/collectibles/authentication-record.gts +++ b/cards/collectibles/authentication-record.gts @@ -14,7 +14,6 @@ import { } from '@cardstack/catalog/fields/status/status'; import MultiImageSourceField from '@cardstack/catalog/fields/multi-image-source/multi-image-source'; import { CollectionItem } from './collection-item'; -import { Order as SoleVaultOrder } from './sole-vault-order'; import ShieldCheckIcon from '@cardstack/boxel-icons/shield-check'; import CalendarIcon from '@cardstack/boxel-icons/calendar'; import CalendarClockIcon from '@cardstack/boxel-icons/calendar-clock'; @@ -320,7 +319,7 @@ export class AuthenticationRecord extends CardDef { @field item = linksTo(() => CollectionItem, { searchable: true }); // The transaction this check belongs to, when it is part of an escrow flow. - @field order = linksTo(() => SoleVaultOrder, { searchable: true }); + @field order = linksTo(CardDef, { searchable: true }); // 'Check Check', 'GOAT', 'In-house'. The consumer supplies enum options if it // wants a select; the block does not name anyone's list of services. @@ -588,10 +587,9 @@ export class AuthenticationRecord extends CardDef { height: 100%; overflow-y: auto; - /* Family palette — literal values, defined locally in every scoped - style block in this file (scoped styles do not share custom - properties across components). Matches sole-vault-app.gts - exactly, so the record reads as one app with its shell. */ + /* The family palette, defined in every scoped style block in this + file (scoped styles do not share custom properties across + components). */ --ink-950: var(--primary-foreground); --background: oklch(0.985 0.001 106.42); --ink-900: var(--background); @@ -629,7 +627,7 @@ export class AuthenticationRecord extends CardDef { --gold-bright: var(--accent); /* Verdict hues — passed/failed get the same bold filled-panel treatment as the rest of the family's plaques, just recoloured; - rose matches Payment's outflow tone so "negative" reads the same + rose is the family's outflow tone, so "negative" reads the same across the app. */ --green: oklch(0.72 0.16 145); --green-bright: oklch(0.78 0.17 150); diff --git a/cards/collectibles/collection-item.gts b/cards/collectibles/collection-item.gts index eb766de9..35106dcb 100644 --- a/cards/collectibles/collection-item.gts +++ b/cards/collectibles/collection-item.gts @@ -18,7 +18,7 @@ import AmountWithCurrency from '@cardstack/base/amount-with-currency'; // prefix is not registered for this realm, the fallback is the absolute realm URL // of the catalog module. import MultiImageSourceField from '@cardstack/catalog/fields/multi-image-source/multi-image-source'; -import { SoleVaultPerson } from './sole-vault-person'; +import { MarketplaceMember } from './marketplace-member'; import { ConditionGrade } from '@cardstack/catalog/fields/condition-grade/condition-grade'; import { Acquisition } from './acquisition'; import ArchiveIcon from '@cardstack/boxel-icons/archive'; @@ -416,7 +416,7 @@ export class CollectionItem extends CardDef { @field valuedOn = contains(DateField); // Whose copy this is. - @field owner = linksTo(() => SoleVaultPerson, { searchable: true }); + @field owner = linksTo(() => MarketplaceMember, { searchable: true }); @field notes = contains(MarkdownField); @@ -932,10 +932,8 @@ export class CollectionItem extends CardDef { height: 100%; overflow-y: auto; - /* Committed vault palette — literal values, no theme-var fallback - chain (this app dropped the swappable-theme pattern). Same names - and values the family shell (sole-vault-app.gts) uses, so the - flagship record card reads as one app with it. */ + /* The family palette, mapped onto theme tokens and shared by every + card in this cluster, so they read as one family. */ --ink-950: var(--primary-foreground); --background: oklch(0.985 0.001 106.42); --ink-900: var(--background); @@ -997,7 +995,7 @@ export class CollectionItem extends CardDef { radius may not — that is what keeps blocks registered with each other. */ --panel-pad: var(--boxel-sp) var(--boxel-sp-lg) var(--boxel-sp-lg); /* 14px — the family's one shared plaque/panel radius (matches - CompletionSet, AuthenticationRecord, Payment, Offer, Listing, + CompletionSet, AuthenticationRecord, MarketplaceOffer, Listing, CollectibleProduct exactly; do not drift this value locally). */ --panel-radius: 14px; @@ -1521,10 +1519,7 @@ export class CollectionItem extends CardDef { `fitted-card` container, and declaring one would capture those queries. Everything below is either a --fc-* knob or a visibility change. */ .ci-fit { - /* Family palette — same literal tokens and values the isolated view - and the app shell (sole-vault-app.gts) use, so the family reads as - one app. No theme-var fallback chain — this app dropped the - swappable-theme pattern. */ + /* The family palette, as in the isolated view. */ --card: oklch(1 0 0); --card-foreground: oklch(0.147 0.004 49.25); --background: oklch(0.985 0.001 106.42); diff --git a/cards/collectibles/commands/add-to-collection-command.gts b/cards/collectibles/commands/add-to-collection-command.gts index cb3c6e5a..020a6301 100644 --- a/cards/collectibles/commands/add-to-collection-command.gts +++ b/cards/collectibles/commands/add-to-collection-command.gts @@ -110,8 +110,7 @@ export default class AddToCollectionCommand extends Command< item.acquisition = acquisition; // Save into the SOURCE card's own realm. Without `realm`, SaveCard - // defaults to the base realm and the write 401s — verified live when a - // ProcessPayment run tried to save its Payment to cardstack.com/base/. + // defaults to the base realm and the write 401s. let realm = (product as any)?.[realmURL]?.href; let saved = (await new SaveCardCommand(this.toolContext).execute({ card: item, diff --git a/cards/collectibles/commands/authenticate-item-command.gts b/cards/collectibles/commands/authenticate-item-command.gts index cc6ed2c9..660c422c 100644 --- a/cards/collectibles/commands/authenticate-item-command.gts +++ b/cards/collectibles/commands/authenticate-item-command.gts @@ -50,7 +50,7 @@ export class AuthenticateItemResult extends CardDef { // A FAILED VERDICT DOES NOT AUTO-REFUND. `authentication-record.gts` says // failure "in an escrow flow this refunds the buyer" — that is a fact about // what SHOULD happen next, not something this command does for you. Refund -// is money moving, and `RefundOrderCommand` is its own explicit call, so a +// is money moving and its own explicit call, so a // failed check is never silently followed by an unrequested charge reversal. export default class AuthenticateItemCommand extends Command< typeof AuthenticateItemInput, @@ -104,8 +104,7 @@ export default class AuthenticateItemCommand extends Command< record.authenticatorNotes = input.notes?.trim() || ''; // Save into the SOURCE card's own realm. Without `realm`, SaveCard - // defaults to the base realm and the write 401s — verified live when a - // ProcessPayment run tried to save its Payment to cardstack.com/base/. + // defaults to the base realm and the write 401s. let realm = (item as any)?.[realmURL]?.href; let saved = (await new SaveCardCommand(this.toolContext).execute({ card: record, diff --git a/cards/collectibles/commands/create-listing-command.gts b/cards/collectibles/commands/create-listing-command.gts index e1b87eff..f5522285 100644 --- a/cards/collectibles/commands/create-listing-command.gts +++ b/cards/collectibles/commands/create-listing-command.gts @@ -99,8 +99,7 @@ export default class CreateListingCommand extends Command< listing.listedAt = now; // Save into the SOURCE card's own realm. Without `realm`, SaveCard - // defaults to the base realm and the write 401s — verified live when a - // ProcessPayment run tried to save its Payment to cardstack.com/base/. + // defaults to the base realm and the write 401s. let realm = (collectionItem as any)?.[realmURL]?.href; let saved = (await new SaveCardCommand(this.toolContext).execute({ card: listing, diff --git a/cards/collectibles/commands/create-set-command.gts b/cards/collectibles/commands/create-set-command.gts index f81b747b..02f2d221 100644 --- a/cards/collectibles/commands/create-set-command.gts +++ b/cards/collectibles/commands/create-set-command.gts @@ -76,8 +76,7 @@ export default class CreateSetCommand extends Command< set.isPublic = Boolean(input.isPublic); // Save into the SOURCE card's own realm. Without `realm`, SaveCard - // defaults to the base realm and the write 401s — verified live when a - // ProcessPayment run tried to save its Payment to cardstack.com/base/. + // defaults to the base realm and the write 401s. let realm = (products[0] as any)?.[realmURL]?.href; let saved = (await new SaveCardCommand(this.toolContext).execute({ card: set, diff --git a/cards/collectibles/commands/mark-delivered-command.gts b/cards/collectibles/commands/mark-delivered-command.gts deleted file mode 100644 index faaf6791..00000000 --- a/cards/collectibles/commands/mark-delivered-command.gts +++ /dev/null @@ -1,126 +0,0 @@ -import { Command } from '@cardstack/runtime-common'; -import { - CardDef, - StringField, - contains, - field, -} from '@cardstack/base/card-api'; -import GetCardCommand from '@cardstack/boxel-host/commands/get-card'; -import PatchCardInstanceCommand from '@cardstack/boxel-host/commands/patch-card-instance'; -import { Order, canOrderTransition } from '../sole-vault-order'; -import { Shipment, canShipmentTransition } from '../sole-vault-shipment'; - -export class MarkDeliveredInput extends CardDef { - @field shipmentId = contains(StringField); -} - -export class MarkDeliveredResult extends CardDef { - @field shipmentId = contains(StringField); - @field orderId = contains(StringField); - @field orderStatus = contains(StringField); -} - -// Mark Delivered (MD) — a carrier scan lands this leg. -// -// LEG-AWARE, BECAUSE THE ORDER GRAPH IS. The two-leg route (seller → -// auth centre → buyer) means "delivered" means two different things to the -// Order depending on WHICH leg just arrived: -// * the seller-to-auth leg landing is "item received at the auth centre" — -// the Order moves `shipped` → `authenticating`, NOT to `delivered`. -// * the auth-to-buyer leg landing is delivery to the actual buyer — the -// Order moves `authenticating` → `delivered`. -// Reading `shipment.leg` rather than always advancing to the "next" status is -// what keeps a first-leg scan from wrongly reporting the whole order as -// delivered to the buyer. -// -// HONEST GAP: `delivered` → `completed` (the buyer's own "I received it and -// it's fine" confirmation that releases funds) has no command. It is left an -// explicit, undone edge rather than silently folded into a -// carrier-scan event, which is not the same fact as a buyer confirming -// receipt. -export default class MarkDeliveredCommand extends Command< - typeof MarkDeliveredInput, - typeof MarkDeliveredResult -> { - static actionVerb = 'Mark delivered'; - description = - 'Record a shipment leg as delivered and advance the order status appropriately for that leg.'; - - async getInputType() { - return MarkDeliveredInput; - } - - protected async run(input: MarkDeliveredInput): Promise { - let shipmentId = input.shipmentId?.trim(); - if (!shipmentId) { - throw new Error('shipmentId is required'); - } - - let shipment = (await new GetCardCommand(this.toolContext).execute({ - cardId: shipmentId, - })) as Shipment; - if (!shipment) { - throw new Error(`Shipment not found: ${shipmentId}`); - } - if (!canShipmentTransition(shipment.shipmentStatus, 'delivered')) { - throw new Error( - `Shipment ${shipmentId} cannot move from ${shipment.shipmentStatus} to delivered`, - ); - } - - let now = new Date(); - await new PatchCardInstanceCommand(this.toolContext, { - cardType: Shipment, - }).execute({ - cardId: shipmentId, - patch: { - attributes: { - shipmentStatus: 'delivered', - deliveredAt: now.toISOString().slice(0, 10), - }, - }, - }); - - let result = new MarkDeliveredResult(); - result.shipmentId = shipmentId; - - let orderId = shipment.order?.id; - if (!orderId) { - // A shipment with no order is a real state — nothing more to advance. - return result; - } - result.orderId = orderId; - - let order = (await new GetCardCommand(this.toolContext).execute({ - cardId: orderId, - })) as Order; - - let nextOrderStatus = - shipment.leg === 'auth-to-buyer' ? 'delivered' : 'authenticating'; - - if (!canOrderTransition(order.orderStatus, nextOrderStatus)) { - // The shipment leg still gets recorded above; the order simply does not - // move (e.g. a second scan on an already-advanced order). - result.orderStatus = order.orderStatus ?? undefined; - return result; - } - - await new PatchCardInstanceCommand(this.toolContext, { - cardType: Order, - }).execute({ - cardId: orderId, - patch: { - attributes: - nextOrderStatus === 'authenticating' - ? { orderStatus: 'authenticating' } - : { - orderStatus: 'delivered', - deliveredAt: now.toISOString().slice(0, 10), - }, - }, - }); - - result.orderStatus = nextOrderStatus; - return result; - } -} diff --git a/cards/collectibles/commands/place-order-command.gts b/cards/collectibles/commands/place-order-command.gts deleted file mode 100644 index fa83143f..00000000 --- a/cards/collectibles/commands/place-order-command.gts +++ /dev/null @@ -1,149 +0,0 @@ -import { Command } from '@cardstack/runtime-common'; -import { - CardDef, - StringField, - contains, - field, - realmURL, -} from '@cardstack/base/card-api'; -import AmountWithCurrency from '@cardstack/base/amount-with-currency'; -import GetCardCommand from '@cardstack/boxel-host/commands/get-card'; -import PatchCardInstanceCommand from '@cardstack/boxel-host/commands/patch-card-instance'; -import SaveCardCommand from '@cardstack/boxel-host/commands/save-card'; -import { Listing } from '../listing'; -import { Order } from '../sole-vault-order'; - -export class PlaceOrderInput extends CardDef { - @field listingId = contains(StringField); - @field buyerId = contains(StringField); -} - -export class PlaceOrderResult extends CardDef { - @field orderId = contains(StringField); - @field reference = contains(StringField); -} - -// Place Order (PO) — a buyer commits to a listing. -// -// FEES ARE SNAPSHOTS, PICKED HERE, NOT A RATE RE-DERIVED LATER. The auth fee -// and platform-fee percentage are the checkout's own numbers, applied once at -// order-creation time (a flat $10 authentication fee and a 3% platform fee) — -// see `order.gts`'s header note on -// why `Order.platformFee` holds the money actually charged, never "3%". -// -// LISTING GOES `sold` HERE, NOT ON PAYMENT. `ListingStatusField`'s own -// `active` meaning is "visible to buyers and accepting offers"; `sold` means -// "a buyer committed — the item is spoken for". That commitment happens the -// moment an order is placed, before money moves, which is why this command — -// not ProcessPayment — is the one that flips it. -// `AmountWithCurrency.amount` is a decimal figure in the currency's own major -// unit (dollars, not cents — see `money-format.ts`'s header note on why this -// cluster never scales to minor units), so $10.00 is the literal number 10. -const AUTH_FEE_AMOUNT = 10; // $10.00, flat -const PLATFORM_FEE_RATE = 0.03; // 3% - -export default class PlaceOrderCommand extends Command< - typeof PlaceOrderInput, - typeof PlaceOrderResult -> { - static actionVerb = 'Place order'; - description = - 'Create an order from an active listing, snapshotting price and fees, and mark the listing sold.'; - - async getInputType() { - return PlaceOrderInput; - } - - protected async run(input: PlaceOrderInput): Promise { - let listingId = input.listingId?.trim(); - let buyerId = input.buyerId?.trim(); - if (!listingId) { - throw new Error('listingId is required'); - } - if (!buyerId) { - throw new Error('buyerId is required'); - } - - let listing = (await new GetCardCommand(this.toolContext).execute({ - cardId: listingId, - })) as Listing; - if (!listing) { - throw new Error(`Listing not found: ${listingId}`); - } - if (listing.listingStatus !== 'active') { - throw new Error( - `Listing ${listingId} is not active (status: ${listing.listingStatus}) — cannot place an order against it`, - ); - } - if (!listing.price?.amount || !listing.price?.currency) { - throw new Error(`Listing ${listingId} has no price set`); - } - let sellerId = listing.seller?.id; - if (!sellerId) { - throw new Error(`Listing ${listingId} has no seller`); - } - - let currency = listing.price.currency; - let priceAmount = listing.price.amount; - let shippingAmount = listing.shippingPrice?.amount ?? 0; - let authFeeAmount = AUTH_FEE_AMOUNT; - // Rounded to the nearest cent, not the nearest dollar — `amount` carries - // two decimal places. - let platformFeeAmount = - Math.round(priceAmount * PLATFORM_FEE_RATE * 100) / 100; - - let now = new Date(); - let reference = `SV-${now.getFullYear()}-${Math.floor( - 1000 + Math.random() * 9000, - )}`; - - let order = new Order(); - order.reference = reference; - order.listing = listing; - order.buyer = (await new GetCardCommand(this.toolContext).execute({ - cardId: buyerId, - })) as any; - order.seller = listing.seller; - // Real AmountWithCurrency instances, not `{ amount, currency }` literals - // — a plain object throws when the searchable walker's peekAtField - // reaches it (see order.gts's `total` computeVia for the same note). - order.price = new AmountWithCurrency({ amount: priceAmount, currency }); - order.shippingPrice = new AmountWithCurrency({ - amount: shippingAmount, - currency, - }); - order.authFee = new AmountWithCurrency({ - amount: authFeeAmount, - currency, - }); - order.platformFee = new AmountWithCurrency({ - amount: platformFeeAmount, - currency, - }); - order.orderStatus = 'pending-payment'; - order.placedAt = now; - - let realm = listing[realmURL]?.href; - let saved = (await new SaveCardCommand(this.toolContext).execute({ - card: order, - realm, - })) as Order; - - await new PatchCardInstanceCommand(this.toolContext, { - cardType: Listing, - }).execute({ - cardId: listingId, - patch: { - attributes: { - listingStatus: 'sold', - soldAt: now.toISOString().slice(0, 10), - }, - }, - }); - - let result = new PlaceOrderResult(); - result.orderId = saved.id; - result.reference = reference; - return result; - } -} diff --git a/cards/collectibles/commands/process-payment-command.gts b/cards/collectibles/commands/process-payment-command.gts deleted file mode 100644 index 84f06e03..00000000 --- a/cards/collectibles/commands/process-payment-command.gts +++ /dev/null @@ -1,108 +0,0 @@ -import { Command } from '@cardstack/runtime-common'; -import { - CardDef, - StringField, - contains, - field, - realmURL, -} from '@cardstack/base/card-api'; -import GetCardCommand from '@cardstack/boxel-host/commands/get-card'; -import PatchCardInstanceCommand from '@cardstack/boxel-host/commands/patch-card-instance'; -import SaveCardCommand from '@cardstack/boxel-host/commands/save-card'; -import { Order, canOrderTransition } from '../sole-vault-order'; -import { Payment } from '../sole-vault-payment'; - -export class ProcessPaymentInput extends CardDef { - @field orderId = contains(StringField); - @field processor = contains(StringField); -} - -export class ProcessPaymentResult extends CardDef { - @field paymentId = contains(StringField); - @field orderStatus = contains(StringField); -} - -// Process Payment (PP) — charges the buyer's escrow-held total. -// -// A SIMULATION, STATED PLAINLY. `order.gts`'s own header note says it: a -// realm cannot hold funds, so this records a `Payment` in the `captured` -// state directly rather than modelling `pending`→`authorized`→`captured` as -// a real processor round-trip. What matters for the escrow graph is the -// EFFECT — the order moves to `paid` and the total is now held — not a -// faithful simulation of a payment gateway's own async state machine. -export default class ProcessPaymentCommand extends Command< - typeof ProcessPaymentInput, - typeof ProcessPaymentResult -> { - static actionVerb = 'Process payment'; - description = - 'Charge the order total and move the order from pending-payment to paid, recording a Payment.'; - - async getInputType() { - return ProcessPaymentInput; - } - - protected async run( - input: ProcessPaymentInput, - ): Promise { - let orderId = input.orderId?.trim(); - if (!orderId) { - throw new Error('orderId is required'); - } - - let order = (await new GetCardCommand(this.toolContext).execute({ - cardId: orderId, - })) as Order; - if (!order) { - throw new Error(`Order not found: ${orderId}`); - } - if (!canOrderTransition(order.orderStatus, 'paid')) { - throw new Error( - `Order ${orderId} cannot move from ${order.orderStatus} to paid`, - ); - } - if (!order.total?.amount) { - throw new Error(`Order ${orderId} has no total to charge`); - } - - let now = new Date(); - let processorReference = `sim-pi-${Math.random().toString(36).slice(2, 10)}`; - - let payment = new Payment(); - payment.order = order; - payment.amount = order.total; - payment.paymentState = 'captured'; - payment.direction = 'charge'; - payment.processor = input.processor?.trim() || 'Simulated Processor'; - payment.processorReference = processorReference; - payment.initiatedAt = now; - payment.settledAt = now; - - // Save into the SOURCE card's own realm. Without `realm`, SaveCard - // defaults to the base realm and the write 401s — verified live when a - // ProcessPayment run tried to save its Payment to cardstack.com/base/. - let realm = (order as any)?.[realmURL]?.href; - let saved = (await new SaveCardCommand(this.toolContext).execute({ - card: payment, - realm, - })) as Payment; - - await new PatchCardInstanceCommand(this.toolContext, { - cardType: Order, - }).execute({ - cardId: orderId, - patch: { - attributes: { - orderStatus: 'paid', - paidAt: now.toISOString().slice(0, 10), - paymentReference: processorReference, - }, - }, - }); - - let result = new ProcessPaymentResult(); - result.paymentId = saved.id; - result.orderStatus = 'paid'; - return result; - } -} diff --git a/cards/collectibles/commands/refund-order-command.gts b/cards/collectibles/commands/refund-order-command.gts deleted file mode 100644 index 0042eda2..00000000 --- a/cards/collectibles/commands/refund-order-command.gts +++ /dev/null @@ -1,102 +0,0 @@ -import { Command } from '@cardstack/runtime-common'; -import { - CardDef, - StringField, - contains, - field, - realmURL, -} from '@cardstack/base/card-api'; -import GetCardCommand from '@cardstack/boxel-host/commands/get-card'; -import PatchCardInstanceCommand from '@cardstack/boxel-host/commands/patch-card-instance'; -import SaveCardCommand from '@cardstack/boxel-host/commands/save-card'; -import { Order, canOrderTransition } from '../sole-vault-order'; -import { Payment } from '../sole-vault-payment'; - -export class RefundOrderInput extends CardDef { - @field orderId = contains(StringField); - @field reason = contains(StringField); -} - -export class RefundOrderResult extends CardDef { - @field paymentId = contains(StringField); - @field orderStatus = contains(StringField); -} - -// Refund Order (RO) — unwinds a held order, most often an authentication -// failure. `AuthenticateItem`'s own comment says a failed verdict "refunds -// the buyer" — this is the command that actually moves the money. -// -// A REFUND IS A NEW PAYMENT RECORD, NEVER AN EDIT TO THE ORIGINAL CHARGE. -// `PaymentStateField`'s own transition graph says the same thing: `captured` -// only ever leads to `refunded`, one-way, and `payment.gts`'s `signedAmount` -// renders a refund's `direction: 'refund'` with a minus sign rather than -// mutating the charge's own amount. Two ledger rows, not one row that -// changed its mind. -export default class RefundOrderCommand extends Command< - typeof RefundOrderInput, - typeof RefundOrderResult -> { - static actionVerb = 'Refund order'; - description = - 'Record a refund payment for the order total and move the order to refunded.'; - - async getInputType() { - return RefundOrderInput; - } - - protected async run(input: RefundOrderInput): Promise { - let orderId = input.orderId?.trim(); - if (!orderId) { - throw new Error('orderId is required'); - } - - let order = (await new GetCardCommand(this.toolContext).execute({ - cardId: orderId, - })) as Order; - if (!order) { - throw new Error(`Order not found: ${orderId}`); - } - if (!canOrderTransition(order.orderStatus, 'refunded')) { - throw new Error( - `Order ${orderId} cannot move from ${order.orderStatus} to refunded`, - ); - } - if (!order.total?.amount) { - throw new Error(`Order ${orderId} has no total to refund`); - } - - let now = new Date(); - - let payment = new Payment(); - payment.order = order; - payment.amount = order.total; - payment.paymentState = 'refunded'; - payment.direction = 'refund'; - payment.processor = 'Simulated Processor'; - payment.processorReference = `sim-re-${Math.random().toString(36).slice(2, 10)}`; - payment.initiatedAt = now; - payment.settledAt = now; - payment.reason = input.reason?.trim() || ''; - - // Save into the SOURCE card's own realm. Without `realm`, SaveCard - // defaults to the base realm and the write 401s — verified live when a - // ProcessPayment run tried to save its Payment to cardstack.com/base/. - let realm = (order as any)?.[realmURL]?.href; - let saved = (await new SaveCardCommand(this.toolContext).execute({ - card: payment, - realm, - })) as Payment; - - await new PatchCardInstanceCommand(this.toolContext, { - cardType: Order, - }).execute({ - cardId: orderId, - patch: { attributes: { orderStatus: 'refunded' } }, - }); - - let result = new RefundOrderResult(); - result.paymentId = saved.id; - result.orderStatus = 'refunded'; - return result; - } -} diff --git a/cards/collectibles/commands/sole-vault-dispatch-shipment-command.gts b/cards/collectibles/commands/sole-vault-dispatch-shipment-command.gts deleted file mode 100644 index 9418d2f1..00000000 --- a/cards/collectibles/commands/sole-vault-dispatch-shipment-command.gts +++ /dev/null @@ -1,114 +0,0 @@ -import { Command } from '@cardstack/runtime-common'; -import { - CardDef, - StringField, - contains, - field, - realmURL, -} from '@cardstack/base/card-api'; -import GetCardCommand from '@cardstack/boxel-host/commands/get-card'; -import PatchCardInstanceCommand from '@cardstack/boxel-host/commands/patch-card-instance'; -import SaveCardCommand from '@cardstack/boxel-host/commands/save-card'; -import { Order, canOrderTransition } from '../sole-vault-order'; -import { Shipment } from '../sole-vault-shipment'; - -export class DispatchShipmentInput extends CardDef { - @field orderId = contains(StringField); - @field carrier = contains(StringField); - @field trackingNumber = contains(StringField); - @field trackingUrl = contains(StringField); - @field shippedFrom = contains(StringField); - @field shippedTo = contains(StringField); - // 'seller-to-auth' | 'auth-to-buyer': the two legs of the authentication- - // centre route. Freeform on purpose, same call as - // `shipment.gts`'s own `leg` field: the block names no fixed vocabulary. - @field leg = contains(StringField); -} - -export class DispatchShipmentResult extends CardDef { - @field shipmentId = contains(StringField); - @field orderStatus = contains(StringField); -} - -// Dispatch Shipment (DS) — the seller hands a parcel to a carrier. -// -// It works on Sole Vault's Order and Shipment. The fulfilment cluster's own -// dispatch command patches a Fulfilment Order and quotes through a Carrier, -// which this marketplace does not have. -export default class DispatchShipmentCommand extends Command< - typeof DispatchShipmentInput, - typeof DispatchShipmentResult -> { - static actionVerb = 'Dispatch shipment'; - description = - 'Create a shipment leg for a paid order, and move the order from paid to shipped.'; - - async getInputType() { - return DispatchShipmentInput; - } - - protected async run( - input: DispatchShipmentInput, - ): Promise { - let orderId = input.orderId?.trim(); - if (!orderId) { - throw new Error('orderId is required'); - } - let trackingNumber = input.trackingNumber?.trim(); - if (!trackingNumber) { - throw new Error('trackingNumber is required'); - } - - let order = (await new GetCardCommand(this.toolContext).execute({ - cardId: orderId, - })) as Order; - if (!order) { - throw new Error(`Order not found: ${orderId}`); - } - if (!canOrderTransition(order.orderStatus, 'shipped')) { - throw new Error( - `Order ${orderId} cannot move from ${order.orderStatus} to shipped`, - ); - } - - let now = new Date(); - - let shipment = new Shipment(); - shipment.order = order; - shipment.leg = input.leg?.trim() || 'seller-to-auth'; - shipment.shipmentStatus = 'label-created'; - shipment.carrier = input.carrier?.trim() || ''; - shipment.trackingNumber = trackingNumber; - shipment.trackingUrl = input.trackingUrl?.trim() || ''; - shipment.shippedFrom = input.shippedFrom?.trim() || ''; - shipment.shippedTo = input.shippedTo?.trim() || ''; - shipment.labelCreatedAt = now; - - // Save into the SOURCE card's own realm. Without `realm`, SaveCard - // defaults to the base realm and the write 401s — verified live when a - // ProcessPayment run tried to save its Payment to cardstack.com/base/. - let realm = (order as any)?.[realmURL]?.href; - let saved = (await new SaveCardCommand(this.toolContext).execute({ - card: shipment, - realm, - })) as Shipment; - - await new PatchCardInstanceCommand(this.toolContext, { - cardType: Order, - }).execute({ - cardId: orderId, - patch: { - attributes: { - orderStatus: 'shipped', - shippedAt: now.toISOString().slice(0, 10), - trackingNumber, - }, - }, - }); - - let result = new DispatchShipmentResult(); - result.shipmentId = saved.id; - result.orderStatus = 'shipped'; - return result; - } -} diff --git a/cards/collectibles/listing.gts b/cards/collectibles/listing.gts index beced6f2..cfe6b91c 100644 --- a/cards/collectibles/listing.gts +++ b/cards/collectibles/listing.gts @@ -21,10 +21,10 @@ import { CollectionItem } from './collection-item'; import { CollectibleProduct } from './collectible-product'; import { SellerProfile } from './seller-profile'; // CYCLE-SAFE IMPORT. offer.gts imports THIS module, so this is circular by -// construction. It is legal only because `identifyCard(Offer)` is read inside +// construction. It is legal only because `identifyCard(MarketplaceOffer)` is read inside // the isolated view's reverse-query getter and never at module-evaluation // time. Do not lift it into a top-level constant. -import { Offer } from './sole-vault-offer'; +import { MarketplaceOffer } from './marketplace-offer'; import TagIcon from '@cardstack/boxel-icons/tag'; import HandshakeIcon from '@cardstack/boxel-icons/handshake'; import ArchiveIcon from '@cardstack/boxel-icons/archive'; @@ -373,7 +373,7 @@ export class Listing extends CardDef { @field photos = contains(MultiImageSourceField); @field listingNotes = contains(MarkdownField); - // SellerProfile (a SoleVaultPerson with a rating) rather than a bare + // SellerProfile (a MarketplaceMember with a rating) rather than a bare // CardDef: it gives a listing row the seller's name, avatar and rating // without this card modelling a person. @field seller = linksTo(() => SellerProfile, { searchable: true }); @@ -436,12 +436,12 @@ export class Listing extends CardDef { return realmUrl ? [realmUrl.href] : []; } - // `identifyCard(Offer)` only ever runs inside this getter — never at + // `identifyCard(MarketplaceOffer)` only ever runs inside this getter — never at // module-evaluation time — which is what makes the circular import legal. private offersQuery = this.args.context?.getCards( this, () => { - let ref = identifyCard(Offer); + let ref = identifyCard(MarketplaceOffer); let id = this.args.model?.id; return ref && id ? { filter: { on: ref, every: [{ eq: { 'listing.id': id } }] } } diff --git a/cards/collectibles/sole-vault-person.gts b/cards/collectibles/marketplace-member.gts similarity index 97% rename from cards/collectibles/sole-vault-person.gts rename to cards/collectibles/marketplace-member.gts index ebaa7d3c..03056fdf 100644 --- a/cards/collectibles/sole-vault-person.gts +++ b/cards/collectibles/marketplace-member.gts @@ -16,10 +16,10 @@ import { identifyCard } from '@cardstack/runtime-common'; import { PersonBase } from '@cardstack/catalog/cards/people/person-base'; // NOT a static import of `./listing` on purpose. listing.gts now imports // SellerProfile (seller-profile.gts), which imports THIS module — a 3-way -// cycle (SoleVaultPerson -> listing -> SellerProfile -> SoleVaultPerson). -// SellerProfile's `extends SoleVaultPerson` runs at module-evaluation time +// cycle (MarketplaceMember -> listing -> SellerProfile -> MarketplaceMember). +// SellerProfile's `extends MarketplaceMember` runs at module-evaluation time // (unlike this file's own deferred `identifyCard` read), so a static import -// of Listing here can hand seller-profile.gts an undefined SoleVaultPerson +// of Listing here can hand seller-profile.gts an undefined MarketplaceMember // mid-cycle ("Class extends value undefined"), depending on which module of // the loop loads first. A dynamic import breaks the cycle: it is not part of // the synchronous module graph, so nothing waits on it to finish loading. @@ -29,8 +29,8 @@ import { EmptyState } from '@cardstack/pretui/components/empty-state'; import { COMPACT_EMPTY_STYLE } from '@cardstack/catalog/components/pretui-helpers'; // The isolated landing page, exported so SellerProfile can extend it. -export class SoleVaultPersonIsolated< - T extends typeof SoleVaultPerson = typeof SoleVaultPerson, +export class MarketplaceMemberIsolated< + T extends typeof MarketplaceMember = typeof MarketplaceMember, > extends Component { get realms() { let realmUrl = this.args.model?.[realmURL]; @@ -419,7 +419,7 @@ export class SoleVaultPersonIsolated< } -// SoleVaultPerson — the Sole Vault cards' own person profile, used wherever a +// MarketplaceMember — the Sole Vault cards' own person profile, used wherever a // plain identity is needed (a buyer, or anyone not specifically a seller). // `SellerProfile` (`seller-profile.gts`) extends it for the seller role, adding // the `rating` that has no meaning on a buyer. @@ -427,8 +427,8 @@ export class SoleVaultPersonIsolated< // A SUBCLASS of the catalog's `PersonBase`, not a modification of it: every // field is inherited, and everything Sole-Vault-specific (the isolated landing // page, the edit and fitted views, the dark-luxury visual language) lives here. -export class SoleVaultPerson extends PersonBase { - static displayName = 'Sole Vault Person'; +export class MarketplaceMember extends PersonBase { + static displayName = 'Marketplace Member'; static icon = UserIcon; // ISOLATED — the person's landing page. Object direction: the portrait (or @@ -439,13 +439,13 @@ export class SoleVaultPerson extends PersonBase { // the third is a REVERSE QUERY — listings point at their seller, not the // other way round. Same shape and the same cycle caveat as Order's // shipments query. - static isolated: BaseDefComponent = SoleVaultPersonIsolated; + static isolated: BaseDefComponent = MarketplaceMemberIsolated; // EDIT — four editable fields, so ONE section and no accordion (edit-card // Rule 0: a card with this few fields needs one section, not four). The // computed `initials`/`title` are deliberately absent. static edit: BaseDefComponent = class Edit extends Component< - typeof SoleVaultPerson + typeof MarketplaceMember > { }; - static embedded = class Embedded extends Component { + static embedded = class Embedded extends Component { get amount() { return formatMoney(this.args.model?.amount); } @@ -1378,4 +1378,4 @@ export class Offer extends CardDef { }; } -export default Offer; +export default MarketplaceOffer; diff --git a/cards/collectibles/seller-profile.gts b/cards/collectibles/seller-profile.gts index d4378977..282abb02 100644 --- a/cards/collectibles/seller-profile.gts +++ b/cards/collectibles/seller-profile.gts @@ -12,30 +12,31 @@ import TagIcon from '@cardstack/boxel-icons/tag'; import AlertTriangleIcon from '@cardstack/boxel-icons/alert-triangle'; import StarIcon from '@cardstack/boxel-icons/star'; import { FieldContainer } from '@cardstack/boxel-ui/components'; -import { SoleVaultPerson, SoleVaultPersonIsolated } from './sole-vault-person'; +import { + MarketplaceMember, + MarketplaceMemberIsolated, +} from './marketplace-member'; import ScoreField from '@cardstack/catalog/fields/rating/rating'; import { Spinner } from '@cardstack/pretui/components/spinner'; import { EmptyState } from '@cardstack/pretui/components/empty-state'; import { COMPACT_EMPTY_STYLE } from '@cardstack/catalog/components/pretui-helpers'; -// SellerProfile — a genuinely reusable marketplace-seller building block, not -// a Sole Vault–specific concept. Split out from `SoleVaultPerson` on purpose: -// `rating` has no meaning on a buyer, and `sole-vault-person.gts` is used for -// both roles (`Order.buyer` as well as `Order.seller`). Every other Sole -// Vault "person" link keeps using plain `SoleVaultPerson`; only -// `Order.seller` / `Listing.seller` narrow to this. +// SellerProfile — a reusable marketplace-seller building block. Split out from +// `MarketplaceMember` on purpose: `rating` has no meaning on a buyer, and a +// member can be either. Every other person link keeps using plain +// `MarketplaceMember`; only a seller link (`Listing.seller`) narrows to this. // // Not the procurement Vendor: a vendor is a supplier under a contract, while // this is a person selling to other people on a marketplace. // -// Extends `SoleVaultPerson`, not `PersonBase` directly, to inherit the +// Extends `MarketplaceMember`, not `PersonBase` directly, to inherit the // dark-luxury isolated/edit/fitted views for free rather than duplicating // ~400 lines of near-identical template — the reusable ESSENCE of this block // is the schema (PersonBase + rating), and a consuming app in a different // visual world would override these formats the same way any card override -// works, same as `SoleVaultPerson` itself overrides `PersonBase`'s plain +// works, same as `MarketplaceMember` itself overrides `PersonBase`'s plain // `embedded`. -export class SellerProfile extends SoleVaultPerson { +export class SellerProfile extends MarketplaceMember { static displayName = 'Seller Profile'; static icon = UserIcon; @@ -44,13 +45,13 @@ export class SellerProfile extends SoleVaultPerson { // contract — never a fabricated default score. @field rating = contains(ScoreField); - // ISOLATED — same layout as SoleVaultPerson's, with the rating added to the + // ISOLATED — same layout as MarketplaceMember's, with the rating added to the // hero as the one additional fact a seller's landing page needs over a // plain person's. Glimmer templates cannot call a parent's ` diff --git a/cards/collectibles/listing.gts b/cards/collectibles/listing.gts index 66442155..a75ae264 100644 --- a/cards/collectibles/listing.gts +++ b/cards/collectibles/listing.gts @@ -885,7 +885,7 @@ export class Listing extends CardDef { color: var(--smoke); } .err { - color: var(--destructive); + color: var(--destructive-ink); } /* Rule 1: fires because .card declares the container above. */ @@ -930,7 +930,7 @@ export class Listing extends CardDef { font-weight: 700; font-variant-numeric: tabular-nums; white-space: nowrap; - color: var(--accent); + color: var(--primary-ink); } .l-cond { font-size: 0.8125em; @@ -986,7 +986,7 @@ export class Listing extends CardDef { font-weight: 700; font-variant-numeric: tabular-nums; white-space: nowrap; - color: var(--accent); + color: var(--primary-ink); } .l-cond { white-space: nowrap; diff --git a/cards/collectibles/marketplace-member.gts b/cards/collectibles/marketplace-member.gts index 1695624f..f0f41981 100644 --- a/cards/collectibles/marketplace-member.gts +++ b/cards/collectibles/marketplace-member.gts @@ -728,7 +728,7 @@ export class MarketplaceMember extends PersonBase { font-weight: 700; font-size: 0.875rem; line-height: 1; - color: var(--background); + color: var(--primary-foreground); background: linear-gradient( 155deg, var(--primary) 0%, diff --git a/cards/collectibles/marketplace-offer.gts b/cards/collectibles/marketplace-offer.gts index 546d6ad0..f4c39fab 100644 --- a/cards/collectibles/marketplace-offer.gts +++ b/cards/collectibles/marketplace-offer.gts @@ -1305,7 +1305,7 @@ export class MarketplaceOffer extends CardDef { ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace; font-weight: 700; font-variant-numeric: tabular-nums; - color: var(--accent); + color: var(--primary-ink); white-space: nowrap; } /* A ratio is not an amount: it steps DOWN in size and weight so the