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);
+ }
+
+
+
+ {{#if this.paid}}{{this.paid}}{{/if}}
+ {{#if @model.source}}· {{@model.source}}{{/if}}
+
+
+
+ };
+
+ 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);
+ }
+
+
+
+
+
+
+ {{! THE PLAQUE — the family's filled-panel hero device (same
+ radius/shadow as CollectionItem's worth figure, CompletionSet's
+ percentage, Listing's price), recoloured per verdict rather than
+ rendered as a small inline pill. The verdict is the one loud
+ thing; turnaround is its quiet annotation, stepping DOWN in
+ weight beside the figure it modifies. }}
+
+
+
+ {{! AT A GLANCE — shape: ol. The lifecycle rail, the family signature.
+ The three steps are written out rather than looped: each row needs
+ its OWN date field, and a loop over a getter forced one shared
+ `<@fields.x>` into every row — which rendered the submission date
+ beside the verdict. Three explicit rows carrying one fact each beats
+ a loop that has to lie about one of them. }}
+
+
+ {{! DETAIL — shape: dl. The paper trail: the values someone types into
+ another system, so identifiers never ellipsis. }}
+
+
Paper trail
+ {{#if this.hasPaperTrail}}
+
+ {{#if @model.certificateId}}
+
+
Certificate
+
{{@model.certificateId}}
+
+ {{/if}}
+ {{#if @model.service}}
+
+
Service
+
{{@model.service}}
+
+ {{/if}}
+ {{#if @model.authenticator}}
+
+
Authenticator
+
{{@model.authenticator}}
+
+ {{/if}}
+
+ {{else}}
+ {{! Rule 5: an empty section with a muted glyph reads as designed;
+ the same sentence alone reads as a bug. }}
+
+ No
+ certificate recorded yet.
+
+ {{/if}}
+
+
+ {{! DETAIL — shape: prose. Deliberately a different shape from the dl
+ beside it, so the two sections do not read as one list twice. }}
+
+
+ This
+ record is not linked to a collection item.
+
+ {{/if}}
+
+
+
+
+
+ };
+
+ // 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 {
+
+
+ {{! Rule 2 anchor: tier 1 is the submitted photo; this is the tier-2
+ fallback and it is the card's OWN static icon — the same one its
+ isolated view and its breadcrumb use, which is what makes it identity
+ rather than filler. Never an empty grey square. }}
+ <:placeholder>
+
+
+
+ <:eyebrow>{{@model.service}}
+
+ <:title>{{if
+ @model.itemTitle
+ @model.itemTitle
+ 'Unlinked item'
+ }}
+
+ <:badgeRight>
+ {{! Delegated to the status field, whose StatePill derives its colour
+ from the option's hue. A hand-rolled per-outcome class list here
+ would be a second, drifting definition of the same three colours. }}
+ {{#if @model.outcome}}
+ <@fields.outcome @format='atom' />
+ {{/if}}
+
+
+ <:footer>
+ {{! Rule 1, data is all-or-nothing: an identifier is never ellipsised,
+ so the certificate gets `nowrap` and is hidden WHOLE at the narrow
+ quanta below rather than truncated to an unreadable stub. }}
+ {{#if @model.certificateId}}
+ {{@model.certificateId}}
+ {{/if}}
+ {{#if @model.turnaroundDays}}
+ {{@model.turnaroundDays}}
+ {{/if}}
+
+
+
+
+
+ };
+
+ static atom = class Atom extends Component {
+
+
+
+ {{#if @model.outcome}}
+ {{! Delegated to the field. The status block already renders a
+ StatePill whose colour is DERIVED from the option's hue — a
+ hand-rolled `.a-out--passed` colour list here would be a second,
+ drifting definition of the same thing. }}
+ <@fields.outcome @format='atom' />
+ {{else}}
+ not submitted
+ {{/if}}
+
+
+
+ };
+
+ 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,
+ );
+ }
+
+
+
+
+
+
+ {{! THE PLAQUE — real gold surface area, not a hairline accent.
+ Same device the app shell's collection-value figure uses. }}
+
+ {{#if this.retail}}
+ {{this.retail}}
+ Retail price
+ {{else}}
+ No retail price
+ recorded
+ {{/if}}
+
+
+
+
+ {{! AT A GLANCE — shape: a wide fact rail. Release date and collab status
+ are the two facts a browsing collector scans for before reading the
+ description. }}
+
+
At a glance
+
+
+ Released
+ <@fields.releaseDate @format='atom' />
+
+ {{/if}}
+
+
+ {{! shape: chip rows — a different shape from the prose block beside
+ it }}
+
+
Sizing & tags
+ {{#if this.hasSpecifics}}
+
+ {{#each @model.availableVariants as |v|}}
+ {{#if v}}
+
{{v}}
+ {{/if}}
+ {{/each}}
+
+ {{#if @model.tags.length}}
+
+ {{#each @model.tags as |t|}}
+ {{#if t}}
+
{{t}}
+ {{/if}}
+ {{/each}}
+
+ {{/if}}
+ {{else}}
+
+
+ No size run or tags recorded yet.
+
+ {{/if}}
+
+
+
+ {{! NO ACTIONS ZONE: "Add to collection" and "Find listings" belong to
+ the consuming app, which holds the commands and the reverse Listing
+ query. A control that looks live and does nothing here would teach
+ the user to distrust every other control on the card. }}
+
+
+
+
+ };
+
+ // 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 {
+
+
+ {{@model.displayTitle}}
+ {{#if @model.sku}}{{@model.sku}}{{/if}}
+
+
+
+ };
+
+ 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);
+ }
+
+
+
+ <:placeholder>
+
+
+ <:eyebrow>{{@model.sku}}
+ <:title>{{@model.displayTitle}}
+ <:footer>
+ {{#if this.retail}}{{this.retail}}{{/if}}
+ {{#if @model.collabPartner}}{{@model.collabPartner}}{{/if}}
+
+
+
+
+
+ };
+}
+
+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 {
+
+
+ {{@model.itemTitle}}
+ {{#if @model.variantLabel}}
+ {{@model.variantLabel}}
+ {{/if}}
+ <@fields.condition @format='atom' />
+
+
+
+ };
+
+ // 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,
+ );
+ }
+
+
+
+
+
+ {{#if @model.photos.primaryUrl}}
+
+ {{else}}
+ {{! Empty state, not a decorative placeholder box — it says the
+ photo is missing rather than filling the slot with grey. }}
+
+
+
+ {{! AT A GLANCE — shape: ul. Three figures, and the only mark on the card
+ is a proportion rail, because valueRetention IS a proportion of a
+ whole. The paid→worth relationship is a single delta, so it is a
+ figure with an arrow, not a chart. }}
+
+
At a glance
+
+
+ Paid
+ {{if this.paid this.paid '—'}}
+
+
+ Worth now
+ {{if this.worthNow this.worthNow '—'}}
+
+
+ Condition
+
+ {{#if
+ @model.condition.code
+ }}{{@model.condition.code}}{{else}}—{{/if}}
+ {{#if this.retention}}
+ {{this.retention}}% of market
+ {{/if}}
+
+ {{! boxel-ui ProgressBar, not a hand-rolled rail. It carries
+ role='progressbar' with aria-valuenow/min/max/valuetext, which
+ is strictly better semantics than the role='img' + aria-label
+ this used to be — a screen reader announces the value, not a
+ sentence someone remembered to write. It also removes the
+ htmlSafe style-string getter this needed; the component owns
+ its own fill width. Skinned to the vault hairline via its
+ published knobs below. }}
+ {{#if this.retention}}
+ {{! aria-label attribute, not @label — @label renders as
+ visible text inside this 5px rail and shears. }}
+
+ {{/if}}
+
+
+
+
+
+ {{! SIGNATURE ELEMENT — shape: ol. The record's own lifecycle, drawn
+ from the event-fact dates. }}
+
+
+ {{/if}}
+
+
+ {{! NO ACTIONS ZONE: listing for sale, submitting for authentication and
+ logging a valuation run through the Create Listing and Authenticate
+ Item commands, which the consuming app wires to its own controls. A
+ control here that looks live and does nothing would teach the user to
+ distrust every other control on the card. }}
+
+
+
+
+ };
+
+ // 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);
+ }
+
+
+
+ {{! Rule 2 anchor, tier 1 is the image; this is the tier-2 fallback and it
+ is the card's OWN static icon — the same one its isolated view and its
+ breadcrumb use, which is what makes it identity rather than filler.
+ Never an empty grey square. }}
+ <:placeholder>
+
+
+
+ <:eyebrow>{{@model.variantLabel}}
+
+ <:title>{{if
+ @model.itemTitle
+ @model.itemTitle
+ 'Untitled item'
+ }}
+
+ <:badgeRight>
+ {{#if @model.forSale}}
+ For sale
+ {{/if}}
+
+
+ <:footer>
+ {{#if @model.condition.code}}
+
+ {{@model.condition.code}}
+ {{#if @model.verified}}
+
+ {{/if}}
+
+ {{/if}}
+ {{! Rule 1, data is all-or-nothing: routed through the one money
+ formatter so minor units always render, and `nowrap` so the value is
+ never ellipsised into an unreadable price. It is hidden wholesale at
+ the narrow quanta below rather than truncated. }}
+ {{#if this.worth}}
+ {{this.worth}}
+ {{/if}}
+
+
+
+
+
+ };
+}
+
+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);
+
+
+
Public sets can show on a profile; private
+ ones stay just for tracking.
+
+
+
+
+
+
+
+
+
+}
+
+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;
+ }
+
+
+
+
+ {{! `resolvedUrl`, NOT `url`. ImageSourceField accepts either a pasted
+ URL or a file uploaded into the realm and exposes the winner as
+ `resolvedUrl` — reading `.url` silently renders nothing for every
+ uploaded cover, which is the majority case once someone uses the
+ file picker. Decorative here (the title names the set), so alt=''. }}
+ {{#if @model.coverImage.resolvedUrl}}
+
+
+ {{! THE VAULT PLAQUE. Gold covers real surface area here, not a
+ thin accent — a filled gold slab carrying the dominant
+ figure, matching the ledger-hero treatment the app shell
+ commits to for its own headline number. }}
+ {{#if this.isLoading}}
+
+
+ Reading your collection…
+
+ {{else}}
+
+
{{this.result.percent}}%
+
+ {{this.result.ownedCount}}
+ of
+ {{this.result.targetCount}}
+ owned
+
+ {{! aria-label attribute, not @label — @label renders as
+ visible text inside this 6px meter and shears. }}
+
+
+ {{/if}}
+
+
+
+ {{! DETAIL — shape: prose. The goal in the owner's own words. }}
+ {{#if @model.goal}}
+
+
The goal
+
<@fields.goal />
+
+ {{/if}}
+
+
+ {{! DETAIL — shape: ul of linked cards. Distinct from the prose above
+ and from the dl below, so the sections do not read as one shape
+ three times. }}
+
+
Owned{{this.owned.length}}
+ {{#if this.owned.length}}
+
+ {{#each this.owned as |p|}}
+
{{p.displayTitle}}
+ {{/each}}
+
+ {{else}}
+
+ Nothing from this set is in your collection yet.
+
+ {{/if}}
+
+
+ {{! The half a collector actually opens this card for — what is left
+ to hunt. Same shape as Owned on purpose: they are two halves of
+ one list, and reading them side by side is the point. }}
+
+
Still
+ needed{{this.missing.length}}
+ {{#if this.missing.length}}
+
+ {{#each this.missing as |p|}}
+
{{p.displayTitle}}
+ {{/each}}
+
+ {{else}}
+
+ Set
+ complete — every pair accounted for.
+
+ {{/if}}
+
+
+
+ {{! DETAIL — shape: dl. The rule the arithmetic above obeys, which is
+ otherwise invisible and is the first thing questioned when a
+ percentage looks wrong. }}
+
+
+
+
+
+
+
+ };
+
+ // 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`;
+ }
+
+
+
+ {{! Rule 2 anchor: tier 1 is the cover image; tier 2 is the card's own
+ static icon, the same one its isolated view and atom use. }}
+ <:placeholder>
+
+
+
+ <:eyebrow>{{if
+ (eq @model.completionRule 'own-all')
+ 'Own every variant'
+ 'Own any variant'
+ }}
+
+ <:title>{{if @model.cardTitle @model.cardTitle 'Untitled set'}}
+
+ <:badgeRight>
+ {{#if @model.isPublic}}
+ Public
+ {{/if}}
+
+
+ <:footer>
+ {{! The one real number this card owns without a query. Rendered in
+ full or not at all — never ellipsised. }}
+ {{this.countLabel}}
+
+
+
+
+
+ };
+
+ static atom = class Atom extends Component {
+
+
+
+ {{@model.cardTitle}}
+ {{#if @model.targetCount}}
+ {{@model.targetCount}}
+ {{/if}}
+
+
+
+ };
+
+ static embedded = class Embedded extends Component {
+
+
+ {{! No progress bar here. An embedded CompletionSet has no owner context —
+ whose collection would it measure? A bar rendered without that would
+ be asserting a number this card cannot know. The app draws progress,
+ because the app knows who is looking. }}
+
+
+
+ };
+}
+
+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);
+
+
+
+
+
+
+ <@fields.collectionItem />
+
The owned copy being sold — condition and
+ authentication read through it.
+
+
+
+}
+
+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);
+ }
+
+
+
+
+ {{#if @model.photos.primaryUrl}}
+
+ {{else}}
+
+
+ {{! THE PLAQUE — real gold surface area, not a hairline accent.
+ The asking price is the dominant figure on the whole card; the
+ status and verified badge qualify it, they do not compete. }}
+
+
+
+ {{! Offers — the "is anyone biting?" section. Full width: on an active
+ listing this is what the seller opens the card to check. }}
+
+
Offers{{this.offers.length}}
+
+ {{#if this.offersError}}
+
+ Could not load offers.
+
+ {{else if this.offersLoading}}
+
Looking for offers…
+ {{else if this.offers.length}}
+
+ {{! getCards instances have no `.component` — getComponent(card)
+ is the API; the property renders an empty row silently. }}
+ {{#each this.offers as |o|}}
+
{{#let (getComponent o) as |OfferCard|}}
+
+ {{/let}}
+ {{/each}}
+
+ {{else}}
+
+ No
+ offers yet. Buyers can offer below asking; each counter is its own
+ card linked back to the one it answers.
+
+ {{/if}}
+
+
+
+ {{! Shape: linked cards — the provenance chain behind this listing. }}
+
+
Item &
+ seller
+
+ {{#if @model.collectionItem}}
+
<@fields.collectionItem @format='embedded' />
+ {{else}}
+
+
+ No
+ collection item linked — condition and authentication read
+ through it.
+
+
+ {{/if}}
+ {{#if @model.product}}
+
<@fields.product @format='embedded' />
+ {{/if}}
+ {{#if @model.seller}}
+
<@fields.seller @format='embedded' />
+ {{/if}}
+
+
+
+ {{! Shape: dl — fulfilment terms and the sale window. }}
+
+
+
+
+ };
+
+ // 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);
+ }
+
+
+
+ <:placeholder>
+
+
+ <:eyebrow>{{@model.variantLabel}}
+ <:title>{{@model.productTitle}}
+ <:badgeRight>
+ {{#if @model.verified}}
+ Verified
+ {{/if}}
+
+ <:footer>
+ {{#if this.price}}{{this.price}}{{/if}}
+ {{#if @model.conditionCode}}{{@model.conditionCode}}{{/if}}
+
+
+
+
+
+ };
+}
+
+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 ``,
+ // so this re-declares the hero markup rather than composing it — the CSS
+ // block is identical to the parent's on purpose (one family, one token
+ // set), not copied by accident.
+ static isolated: BaseDefComponent = class Isolated extends SoleVaultPersonIsolated<
+ typeof SellerProfile
+ > {
+ get hasRating() {
+ return (this.args.model as SellerProfile)?.rating != null;
+ }
+
+
+
+
+ {{#if @model.photo.resolvedUrl}}
+
+ {{else}}
+
+
+
+
+ };
+
+ // FITTED — inherited from SoleVaultPerson unchanged: at fitted sizes the
+ // seller's name and photo carry the tile; a star rating at badge scale
+ // would compete with the name for the one row a 150×40 tile has.
+ static fitted: BaseDefComponent = SoleVaultPerson.fitted;
+}
+
+export default SellerProfile;
diff --git a/cards/collectibles/sole-vault-offer.gts b/cards/collectibles/sole-vault-offer.gts
new file mode 100644
index 00000000..772b14c4
--- /dev/null
+++ b/cards/collectibles/sole-vault-offer.gts
@@ -0,0 +1,1409 @@
+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 AmountWithCurrency from '@cardstack/base/amount-with-currency';
+import {
+ statusField,
+ canTransition,
+ nextStatuses,
+} from '@cardstack/catalog/fields/status/status';
+import { Listing } from './listing';
+import { SoleVaultPerson } from './sole-vault-person';
+import HandshakeIcon from '@cardstack/boxel-icons/handshake';
+import TagIcon from '@cardstack/boxel-icons/tag';
+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 { identifyCard } from '@cardstack/runtime-common';
+import { StatePill } from '@cardstack/catalog/components/state-pill';
+import { formatMoney } from './money-format';
+
+// Offer — a buyer proposing a price below asking, and the negotiation that
+// follows: offer, counter, negotiate.
+//
+// ONE CARD PER OFFER, NOT ONE PER NEGOTIATION. A counter-offer is a NEW Offer
+// linked back to the one it answers, rather than an edit to the original. Two
+// reasons, and the first is the one that matters:
+//
+// 1. 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 exactly what both parties argue about later.
+// 2. 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.
+//
+// `counterTo` is therefore the negotiation's spine: a null value means this is
+// an opening offer, and following the chain backwards reconstructs the whole
+// exchange in order.
+//
+// WHY THE AMOUNT IS NOT COMPARED TO ASKING IN A STORED FIELD: `listingPrice` is
+// denormalized for fitted (below), but the DIFFERENCE is computed at render
+// time. A stored discount goes stale the moment the seller re-prices, and a
+// stale "18% below asking" is worse than no badge at all.
+
+export type OfferStatus =
+ | 'open'
+ | 'countered'
+ | 'accepted'
+ | 'declined'
+ | 'withdrawn'
+ | 'expired';
+
+export const OfferStatusField = statusField({
+ displayName: 'Offer Status',
+ icon: HandshakeIcon,
+ options: [
+ {
+ value: 'open',
+ label: 'Open',
+ hue: 'blue',
+ meaning: 'Awaiting the other side. The only state that needs an answer.',
+ },
+ {
+ value: 'countered',
+ label: 'Countered',
+ hue: 'purple',
+ terminal: true,
+ holds: true,
+ meaning:
+ 'Answered with a different price. This offer is closed; the counter is its own card.',
+ },
+ {
+ value: 'accepted',
+ label: 'Accepted',
+ hue: 'green',
+ terminal: true,
+ holds: true,
+ meaning: 'Agreed. An Order should now exist at this amount.',
+ },
+ {
+ value: 'declined',
+ label: 'Declined',
+ hue: 'red',
+ terminal: true,
+ holds: true,
+ meaning: 'Refused outright, with no counter.',
+ },
+ {
+ value: 'withdrawn',
+ label: 'Withdrawn',
+ hue: 'slate',
+ terminal: true,
+ holds: true,
+ meaning: 'Pulled by the party who made it, before an answer.',
+ },
+ {
+ value: 'expired',
+ label: 'Expired',
+ hue: 'amber',
+ terminal: true,
+ holds: true,
+ meaning: 'Ran past its own expiry without an answer.',
+ },
+ ],
+ // `open` is the ONLY non-terminal state, and that is the whole design. Every
+ // other value closes this card permanently:
+ // * A counter does not reopen this offer — it terminates it and starts a new
+ // one, so `countered` leads nowhere.
+ // * `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.
+ // * `declined` / `withdrawn` / `expired` are terminal for the same reason:
+ // re-offering is a new offer, and the history of the refused one is
+ // evidence.
+ transitions: {
+ open: ['countered', 'accepted', 'declined', 'withdrawn', 'expired'],
+ countered: [],
+ accepted: [],
+ declined: [],
+ withdrawn: [],
+ expired: [],
+ },
+});
+
+export function canOfferTransition(from?: string | null, to?: string | null) {
+ return canTransition(OfferStatusField, from, to);
+}
+
+export function nextOfferStatuses(from?: string | null) {
+ return nextStatuses(OfferStatusField, from);
+}
+
+// Shared gap math — offer vs. asking, computed at render time everywhere it
+// appears (isolated hero, fitted footer, embedded row) so a re-price never
+// leaves a stale percentage behind.
+function computeGap(
+ offerAmt?: number | null,
+ askAmt?: number | null,
+): { pct: number; label: string } | null {
+ if (offerAmt == null || askAmt == null || askAmt === 0) {
+ return null;
+ }
+ let pct = Math.round(((askAmt - offerAmt) / askAmt) * 100);
+ if (pct === 0) {
+ return { pct, label: 'at asking' };
+ }
+ return pct > 0
+ ? { pct, label: `${pct}% below asking` }
+ : { pct, label: `${Math.abs(pct)}% above asking` };
+}
+
+class OfferEdit extends Component {
+ @tracked threadOpen = true;
+ @tracked datesOpen = false;
+
+ toggleThread = () => (this.threadOpen = !this.threadOpen);
+ toggleDates = () => (this.datesOpen = !this.datesOpen);
+
+
+
+
+
+ <@fields.listing />
+
+
+
+
+ <@fields.amount />
+
Compared against the listing’s asking price at
+ render time, never stored — so it stays correct if the seller
+ re-prices.
+
+
+ <@fields.offerStatus />
+
open
+ is the only state that can change. Every other value closes this
+ card for good.
+
+
+ <@fields.offeredBy />
+
On a counter this is the seller — the roles swap
+ down the chain.
Leave empty for an opening offer.
+ Set it only when this offer answers another one — following
+ the chain backwards is how the whole exchange is
+ reconstructed, so a wrong link rewrites history.
+
+ {{! THE PLAQUE — real gold surface area. The offered amount is the
+ dominant element; the gap annotates it as a delta pill (a ratio
+ is not an amount), the status qualifies it. }}
+
+
+
+ {{! The negotiation thread — the section that makes this view worth
+ opening. Given more visual weight than the utility panels below, so
+ it does not read as a third identical icon+heading box. }}
+
+
Negotiation
+
+ {{#if @model.message}}
+
+ “{{@model.message}}”
+
+ {{/if}}
+
+ {{#if @model.counterTo}}
+
Answers
+
+
<@fields.counterTo @format='embedded' />
+
+ {{/if}}
+
+
Countered by{{this.counters.length}}
+ {{#if this.countersError}}
+
+ Could not load counters.
+
+ {{else if this.countersLoading}}
+
Looking for counters…
+ {{else if this.counters.length}}
+
+ {{! getCards instances have no `.component` — getComponent(card)
+ is the API; the property renders an empty row silently. }}
+ {{#each this.counters as |c|}}
+
{{#let (getComponent c) as |CounterCard|}}
+
+ {{/let}}
+ {{/each}}
+
+ {{else}}
+
+ No
+ counter yet. A counter is a new Offer linked back to this one —
+ this card is never edited to answer it.
+
+ {{/if}}
+
+
+
+ {{! Shape: a compact party rail — quieter utility panel, distinct
+ from the thread's prose shape above and the ledger below. }}
+
+
Listing & parties
+
+ {{#if @model.listing}}
+
<@fields.listing @format='embedded' />
+ {{else}}
+
+
+ No
+ listing linked — an offer with nothing to buy.
+
+
+ {{/if}}
+ {{#if @model.offeredBy}}
+
<@fields.offeredBy @format='embedded' />
+ {{/if}}
+
+
+
+ {{! Shape: a mono ledger — a third, structurally different panel
+ (definition list of dated facts, not a link list or prose). }}
+
+
Past its expiry an offer must be MOVED to
+ expired — it does not lapse on its own.
+
+
+
+
+
+
+ };
+
+ // EDIT — nine editable fields, grouped.
+ //
+ // `counterTo` gets prominent helper text because it is the one field on this
+ // card whose EMPTY state carries meaning: blank means an opening offer, set
+ // means a reply. Someone filling this form without knowing that will either
+ // orphan a counter or accidentally chain an opening offer to an unrelated one.
+ static edit = OfferEdit;
+
+ // FITTED — FittedCard, same fork and knobs as the family's other supporting
+ // tiles.
+ //
+ // SLOT DISCIPLINE — four distinct facts, four slots:
+ // productTitle (eyebrow) · amount (title, the anchor) · status (badge) ·
+ // the offer-vs-asking gap (footer)
+ //
+ // NOTE ON THE GAP: it is computed here from `listingPrice`, which is
+ // denormalized onto the card precisely so a PRERENDERED tile can show it —
+ // prerendered fitted cannot resolve `linksTo`, so reading the listing
+ // directly would render blank. The comparison itself is still derived at
+ // render time, never stored, so it cannot go stale when the seller re-prices.
+ static fitted = class Fitted extends Component {
+ get amount() {
+ return formatMoney(this.args.model?.amount);
+ }
+
+ get gap() {
+ let g = computeGap(
+ this.args.model?.amount?.amount,
+ this.args.model?.listingPrice?.amount,
+ );
+ if (!g) {
+ return null;
+ }
+ return g.pct === 0
+ ? 'at ask'
+ : g.pct > 0
+ ? `${g.pct}% below ask`
+ : `${Math.abs(g.pct)}% above ask`;
+ }
+
+
+
+ {{! Rule 2 anchor: no image field on an offer, so the tier-2 icon — the
+ card's OWN static icon, shared with its isolated and atom formats. }}
+ <:placeholder>
+
+
+
+ <:eyebrow>{{if
+ @model.productTitle
+ @model.productTitle
+ 'Unlinked listing'
+ }}
+
+ {{! The amount is the main field of an offer, so it takes the title slot
+ and FittedCard's own "title is loudest" behaviour points at the right
+ value rather than being fought. }}
+ <:title>{{if this.amount this.amount '—'}}
+
+ <:badgeRight>
+ {{#if @model.offerStatus}}
+ <@fields.offerStatus @format='atom' />
+ {{/if}}
+
+
+ <:footer>
+ {{#if this.gap}}
+ {{this.gap}}
+ {{/if}}
+ {{#if @model.isCounter}}
+ Counter
+ {{/if}}
+
+
+
+
+
+ };
+
+ static atom = class Atom extends Component {
+ get amount() {
+ return formatMoney(this.args.model?.amount);
+ }
+
+
+
+ {{if this.amount this.amount '—'}}
+ {{#if @model.offerStatus}}
+ <@fields.offerStatus @format='atom' />
+ {{/if}}
+
+
+
+ };
+
+ static embedded = class Embedded extends Component {
+ get amount() {
+ return formatMoney(this.args.model?.amount);
+ }
+
+ // Computed at render time, never stored: the moment the seller re-prices,
+ // a stored discount would be a confident lie.
+ get gap() {
+ let g = computeGap(
+ this.args.model?.amount?.amount,
+ this.args.model?.listingPrice?.amount,
+ );
+ return g?.label ?? null;
+ }
+
+
+
+
+ {{! StatePill in CHROME mode, not a hand-rolled label span. "Counter"
+ is a TYPE the reader parses once, not a state they scan for — which
+ is precisely what `@chrome` exists for: it returns a transparent
+ fill with muted-foreground text (verified in state-pill.gts's
+ `colorArgs`), so it reads as chrome beside the coloured status pill
+ at the end of the row rather than competing with it. Using the
+ realm's own component also means the "Counter" marker matches every
+ other type label in the app instead of being a private variant. }}
+ {{#if @model.isCounter}}
+
+ {{/if}}
+ {{if
+ @model.productTitle
+ @model.productTitle
+ 'Unlinked listing'
+ }}
+
+
+ {{if this.amount this.amount '—'}}
+ {{#if this.gap}}{{this.gap}}{{/if}}
+
+ {{#if @model.offerStatus}}
+ <@fields.offerStatus @format='atom' />
+ {{/if}}
+
+
+
+ };
+}
+
+export default Offer;
diff --git a/cards/collectibles/sole-vault-order.gts b/cards/collectibles/sole-vault-order.gts
new file mode 100644
index 00000000..80e2182c
--- /dev/null
+++ b/cards/collectibles/sole-vault-order.gts
@@ -0,0 +1,1837 @@
+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 AmountWithCurrency from '@cardstack/base/amount-with-currency';
+import {
+ statusField,
+ canTransition,
+ nextStatuses,
+} from '@cardstack/catalog/fields/status/status';
+import { Listing } from './listing';
+import { SoleVaultPerson } from './sole-vault-person';
+import { SellerProfile } from './seller-profile';
+import ReceiptIcon from '@cardstack/boxel-icons/receipt';
+import CalendarIcon from '@cardstack/boxel-icons/calendar';
+import CoinsIcon from '@cardstack/boxel-icons/coins';
+import CreditCardIcon from '@cardstack/boxel-icons/credit-card';
+import PackageIcon from '@cardstack/boxel-icons/package';
+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 { 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
+// circular by construction. They are legal only because `identifyCard(...)` is
+// read inside the reverse-query getters below and never at module-evaluation
+// time. Do not lift them into a top-level constant.
+import { Shipment } from './sole-vault-shipment';
+import { Payment } from './sole-vault-payment';
+import { formatMoney } from './money-format';
+
+// Order — one escrow-protected purchase of one Listing.
+//
+// Without it there is no checkout, so the marketplace can only be browsed.
+//
+// THE ESCROW GRAPH IS THE POINT. An order is not a record with a status string
+// on it; it is a state machine holding someone's money. `statusField` carries the
+// transition graph so the field can answer "may I?" rather than only "what are
+// the choices?" — see the transitions block below for why each edge exists and,
+// more importantly, why the missing edges are missing.
+//
+// MONEY IS FIVE SEPARATE AMOUNTS, NOT ONE. Item price, shipping, authentication
+// fee and platform fee are each their own `AmountWithCurrency`, and the total is
+// COMPUTED from them rather than stored. A stored total is the classic
+// reconciliation bug: it survives a change to any component and then disagrees
+// with the sum of its own parts, and nothing in the UI reveals which is right.
+//
+// FEES ARE SNAPSHOTS, NOT RATES. `platformFee` holds the money actually charged,
+// not "3%". A rate re-derived at read time silently rewrites the history of every
+// past order the day the rate changes — the buyer's receipt must not move.
+//
+// WHAT THIS BLOCK DOES NOT DO: charge anything. A realm cannot hold funds, so
+// `Payment` is a RECORD of an external processor's state and the commands that
+// move this graph are simulations. That is stated here rather than implied,
+// because a card that looks like it takes money and does not is worse than one
+// that never pretended.
+
+export type OrderStatus =
+ | 'pending-payment'
+ | 'paid'
+ | 'shipped'
+ | 'authenticating'
+ | 'delivered'
+ | 'completed'
+ | 'disputed'
+ | 'refunded';
+
+export const OrderStatusField = statusField({
+ displayName: 'Order Status',
+ icon: ReceiptIcon,
+ options: [
+ {
+ value: 'pending-payment',
+ label: 'Pending payment',
+ hue: 'slate',
+ meaning: 'Placed, but the buyer has not paid yet. Nothing is held.',
+ },
+ {
+ value: 'paid',
+ label: 'Paid',
+ hue: 'blue',
+ holds: true,
+ meaning:
+ 'Funds are held in escrow. The seller owes a shipment; the platform owes a refund if anything fails.',
+ },
+ {
+ value: 'shipped',
+ label: 'Shipped',
+ hue: 'purple',
+ holds: true,
+ meaning: 'In transit to the authentication centre, not to the buyer.',
+ },
+ {
+ value: 'authenticating',
+ label: 'Authenticating',
+ hue: 'amber',
+ holds: true,
+ meaning:
+ 'Received and under inspection. The verdict decides whether this completes or refunds.',
+ },
+ {
+ value: 'delivered',
+ label: 'Delivered',
+ hue: 'teal',
+ holds: true,
+ meaning: 'With the buyer, awaiting their confirmation.',
+ },
+ {
+ value: 'completed',
+ label: 'Completed',
+ hue: 'green',
+ terminal: true,
+ holds: true,
+ meaning: 'Buyer confirmed. Funds released to the seller.',
+ },
+ {
+ value: 'disputed',
+ label: 'Disputed',
+ hue: 'orange',
+ holds: true,
+ meaning: 'Someone has raised a problem. Funds stay held until resolved.',
+ },
+ {
+ value: 'refunded',
+ label: 'Refunded',
+ hue: 'red',
+ terminal: true,
+ holds: true,
+ meaning: 'Money returned to the buyer. The item goes back to the seller.',
+ },
+ ],
+ // The edges that are ABSENT carry as much meaning as the ones present:
+ //
+ // * Nothing returns to `pending-payment`. Un-charging a card is a refund,
+ // which is its own terminal state with its own money movement.
+ // * `completed` leads nowhere. Once funds are released the platform no
+ // longer holds anything, so there is nothing left for this graph to move;
+ // a later problem is a new dispute record, not an edit to this one.
+ // * `refunded` leads nowhere for the same reason, in the other direction.
+ // * Authentication failure is `authenticating → refunded`, never
+ // `authenticating → delivered`. The spec is explicit: a failed legit check
+ // refunds the buyer and returns the item, so there must be no path that
+ // ships a fake onward.
+ // * Every money-holding state can reach `disputed`, because a problem can
+ // surface at any point while funds are held.
+ transitions: {
+ 'pending-payment': ['paid', 'refunded'],
+ paid: ['shipped', 'disputed', 'refunded'],
+ shipped: ['authenticating', 'disputed', 'refunded'],
+ authenticating: ['delivered', 'refunded', 'disputed'],
+ delivered: ['completed', 'disputed'],
+ disputed: ['completed', 'refunded'],
+ completed: [],
+ refunded: [],
+ },
+});
+
+/** Re-exported so a command can ask permission without importing the field. */
+export function canOrderTransition(from?: string | null, to?: string | null) {
+ return canTransition(OrderStatusField, from, to);
+}
+
+/** What an action menu should offer for the current state. */
+export function nextOrderStatuses(from?: string | null) {
+ return nextStatuses(OrderStatusField, from);
+}
+
+class OrderEdit extends Component {
+ @tracked moneyOpen = true;
+ @tracked timelineOpen = false;
+ @tracked fulfilmentOpen = false;
+
+ toggleMoney = () => (this.moneyOpen = !this.moneyOpen);
+ toggleTimeline = () => (this.timelineOpen = !this.timelineOpen);
+ toggleFulfilment = () => (this.fulfilmentOpen = !this.fulfilmentOpen);
+
+
+
+
+
+
+ <@fields.reference />
+
Issued once, then quoted in emails and disputes —
+ changing it breaks every reference to this order.
+
+
+ <@fields.orderStatus />
+
Moves along a fixed graph. A failed
+ authentication must refund, so there is no path from
+ authenticating straight to delivered.
These are the amounts actually charged, not
+ rates. The total is computed from them and is not editable — a
+ stored total drifts from the sum of its own parts.
These dates drive the escrow rail on the detail
+ view, and “funds held” is derived from the status — neither is
+ stored separately, so a date left blank shows as a step not yet
+ reached.
The summary reference. Per-leg tracking lives
+ on the Shipment cards, which is where an exception is actually
+ resolved.
+
+
+ <@fields.paymentReference />
+
The processor’s own id. This realm records
+ it; it never charges anything.
+
+
+
+
+
+
+
+
+
+}
+
+class OrderIsolated extends Component {
+ get realms() {
+ let realmUrl = this.args.model?.[realmURL];
+ return realmUrl ? [realmUrl.href] : [];
+ }
+
+ // REVERSE QUERIES. `{ eq: { 'order.id': } }` is the shape,
+ // and `on: ref` is not optional — without the anchor the field path resolves
+ // against CardDef and the request fails with HTTP 500.
+ //
+ // IMPORT CYCLE, ON PURPOSE: shipment.gts and payment.gts both import THIS
+ // module, so importing them back here is a cycle. ES modules tolerate it
+ // because `identifyCard(Shipment)` is only ever read inside this getter —
+ // never at module-evaluation time. Do not hoist these to a top-level const
+ // "for clarity"; that is what breaks it.
+ private shipmentsQuery = this.args.context?.getCards(
+ this,
+ () => {
+ let ref = identifyCard(Shipment);
+ let id = this.args.model?.id;
+ return ref && id
+ ? { filter: { on: ref, every: [{ eq: { 'order.id': id } }] } }
+ : undefined;
+ },
+ () => this.realms,
+ { isLive: true },
+ );
+
+ private paymentsQuery = this.args.context?.getCards(
+ this,
+ () => {
+ let ref = identifyCard(Payment);
+ let id = this.args.model?.id;
+ return ref && id
+ ? { filter: { on: ref, every: [{ eq: { 'order.id': id } }] } }
+ : undefined;
+ },
+ () => this.realms,
+ { isLive: true },
+ );
+
+ // `.instances` read at the point of use, dead slots filtered: a deleted
+ // target leaves its slot in place, so a raw iteration renders an empty row.
+ get shipments() {
+ return (this.shipmentsQuery?.instances ?? []).filter(Boolean);
+ }
+
+ get payments() {
+ return (this.paymentsQuery?.instances ?? []).filter(Boolean);
+ }
+
+ // Absent query (no `context` in some render modes) and unresolved query are
+ // different states, and only one of them may paint "none".
+ get shipmentsLoading() {
+ return Boolean(this.shipmentsQuery) && !this.shipmentsQuery?.instances;
+ }
+
+ get paymentsLoading() {
+ return Boolean(this.paymentsQuery) && !this.paymentsQuery?.instances;
+ }
+
+ // A failed query must render its message where the section would be, not
+ // silently as empty — otherwise an HTTP 500 reads as "no results".
+ get shipmentsError() {
+ return (this.shipmentsQuery as any)?.error;
+ }
+
+ get paymentsError() {
+ return (this.paymentsQuery as any)?.error;
+ }
+
+ get total() {
+ return formatMoney(this.args.model?.total);
+ }
+
+ get itemPrice() {
+ return formatMoney(this.args.model?.price);
+ }
+
+ get shipping() {
+ return formatMoney(this.args.model?.shippingPrice);
+ }
+
+ get authFee() {
+ return formatMoney(this.args.model?.authFee);
+ }
+
+ get platformFee() {
+ return formatMoney(this.args.model?.platformFee);
+ }
+
+ // The escrow rail. Each step is `done` from its own event DATE rather than
+ // from the status string, so the rail cannot disagree with the timeline —
+ // and a refunded order correctly shows the steps it did reach.
+ get steps() {
+ let m = this.args.model;
+ return [
+ { key: 'placed', label: 'Placed', done: Boolean(m?.placedAt) },
+ { key: 'paid', label: 'Paid into escrow', done: Boolean(m?.paidAt) },
+ { key: 'shipped', label: 'Shipped', done: Boolean(m?.shippedAt) },
+ {
+ key: 'authenticated',
+ label: 'Authenticated',
+ done: Boolean(m?.authenticatedAt),
+ },
+ {
+ key: 'delivered',
+ label: 'Delivered',
+ done: Boolean(m?.deliveredAt),
+ },
+ {
+ key: 'completed',
+ label: 'Funds released',
+ done: Boolean(m?.completedAt),
+ },
+ ];
+ }
+
+ // What the graph itself permits from here. Rendered as TEXT for the moves
+ // that still have no command (a shipment's own delivery events drive
+ // `authenticating`/`delivered`; `completed` and `disputed` have no tool at
+ // all) — a control that looks live and no-ops teaches the user to
+ // distrust every other control on the page. The three moves that DO have
+ // commands render as real buttons below instead of appearing here.
+ get allowedNext() {
+ // GUARD THE UNSET CASE. `nextStatuses` with no `from` falls back to
+ // "everything except the current value" — which, when the status is
+ // blank, is all eight states. Listing all eight as "permitted next" is
+ // worse than saying nothing, so an order with no status offers none.
+ let from = this.args.model?.orderStatus;
+ if (!from) {
+ return [];
+ }
+ return nextOrderStatuses(from).filter(
+ // These three are the buttons now — repeating them as text would be
+ // the fitted-card "one value in two slots" defect wearing a new hat.
+ (n) => !['paid', 'shipped', 'refunded'].includes(n.value),
+ );
+ }
+
+ // ---- command wiring ----
+ // Buttons render ONLY when a command context exists (interactive render)
+ // AND the state machine permits the move — otherwise nothing, per the
+ // no-lying-affordances rule. The command modules import this one for the
+ // transition guards, so importing them back statically is a cycle that
+ // evaluates `extends`-time code — the exact bug seller-profile hit. They
+ // are loaded dynamically inside the click instead, where the cycle cannot
+ // exist because the click happens after every module has evaluated.
+ @tracked private acting: string | undefined;
+ @tracked private actionError: string | undefined;
+
+ get isBusy() {
+ return Boolean(this.acting);
+ }
+
+ get commandContext() {
+ return this.args.context?.commandContext;
+ }
+
+ get canProcessPayment() {
+ return canOrderTransition(this.args.model?.orderStatus, 'paid');
+ }
+
+ get canDispatch() {
+ return canOrderTransition(this.args.model?.orderStatus, 'shipped');
+ }
+
+ get canRefund() {
+ return canOrderTransition(this.args.model?.orderStatus, 'refunded');
+ }
+
+ private runAction = async (name: string, make: () => Promise) => {
+ if (this.acting || !this.commandContext) {
+ return;
+ }
+ this.acting = name;
+ this.actionError = undefined;
+ try {
+ await make();
+ } catch (e: any) {
+ // Render at the action, not in a toast — the reader must see which
+ // move failed and why, next to the button that made it.
+ this.actionError = e?.message ?? String(e);
+ } finally {
+ this.acting = undefined;
+ }
+ };
+
+ processPayment = () =>
+ this.runAction('pay', async () => {
+ let { default: ProcessPaymentCommand } =
+ await import('./commands/process-payment-command.gts');
+ await new ProcessPaymentCommand(this.commandContext!).execute({
+ orderId: this.args.model?.id,
+ } as any);
+ });
+
+ dispatchShipment = () =>
+ this.runAction('ship', async () => {
+ let { default: DispatchShipmentCommand } =
+ await import('./commands/sole-vault-dispatch-shipment-command.gts');
+ await new DispatchShipmentCommand(this.commandContext!).execute({
+ orderId: this.args.model?.id,
+ } as any);
+ });
+
+ refundOrder = () =>
+ this.runAction('refund', async () => {
+ let { default: RefundOrderCommand } =
+ await import('./commands/refund-order-command.gts');
+ await new RefundOrderCommand(this.commandContext!).execute({
+ orderId: this.args.model?.id,
+ } as any);
+ });
+
+
+
+
+
+
+ {{! THE PLAQUE — the total is the dominant element, with the escrow
+ state beside it as the thing that qualifies it. Two facts, and the
+ money is the loud one. }}
+
+
+
+ {{! AT A GLANCE — shape: ol. The escrow rail, the family signature. }}
+
+
Escrow progress
+
+ {{#each this.steps as |s|}}
+
+ {{s.label}}
+
+ {{/each}}
+
+ {{! ACTIONS — real commands, state-machine gated, only in an
+ interactive render. Each button IS one of the ten spec tools. }}
+ {{#if this.commandContext}}
+
+ {{/if}}
+ {{/if}}
+ {{#if this.allowedNext.length}}
+ {{! Text, not buttons — these moves have no command of their own;
+ see `allowedNext`. }}
+
+ Permitted next
+ {{! `label` is optional on StatusOption, so fall back to the raw
+ value rather than rendering nothing. }}
+ {{#each this.allowedNext as |n|}}
+ {{if n.label n.label n.value}}
+ {{/each}}
+
+ {{/if}}
+
+
+
+ {{! DETAIL — shape: dl. The receipt. Every amount through the one
+ formatter so minor units survive, and the total is visually a step
+ above its components rather than a fifth row. }}
+
+
Receipt
+
+
+
Item
+
{{if this.itemPrice this.itemPrice '—'}}
+
+
+
Shipping
+
{{if this.shipping this.shipping '—'}}
+
+
+
Authentication
+
{{if this.authFee this.authFee '—'}}
+
+
+
Platform fee
+
{{if this.platformFee this.platformFee '—'}}
+
+
+
Total
+
{{if this.total this.total '—'}}
+
+
+
Fees are the amounts charged, not rates —
+ re-deriving them would rewrite this receipt if the rate ever
+ changed.
+
+
+ {{! DETAIL — shape: linked cards. A different shape from the dl beside
+ it, and the answer to "what happens next". }}
+
+
Shipments{{this.shipments.length}}
+
+ {{#if this.shipmentsError}}
+
+ Could not load shipments.
+
+ {{else if this.shipmentsLoading}}
+
Looking for shipments…
+ {{else if this.shipments.length}}
+
+ {{! getCards instances are bare card instances with NO
+ `.component` property — rendering `s.component` silently
+ yields an empty row. getComponent(card) is the API. }}
+ {{#each this.shipments as |s|}}
+
{{#let (getComponent s) as |ShipmentCard|}}
+
+ {{/let}}
+ {{/each}}
+
+ {{else}}
+
+ Nothing
+ shipped yet. The seller ships to the authenticator first, not to
+ the buyer.
+
+ {{/if}}
+
+
+
+ {{! DETAIL — shape: linked cards again, but this is the money ledger and
+ it earns its own full-width row: a partial refund is the case where
+ several rows matter at once. }}
+
+
Payments{{this.payments.length}}
+
+ {{#if this.paymentsError}}
+
+ Could
+ not load payments.
+
+ {{else if this.paymentsLoading}}
+
Looking for payments…
+ {{else if this.payments.length}}
+
+ {{#each this.payments as |p|}}
+
{{#let (getComponent p) as |PaymentCard|}}
+
+ {{/let}}
+ {{/each}}
+
+ {{else}}
+
+ No
+ payment recorded. Nothing is held.
+
+ {{/if}}
+
+
+
+
+
+}
+
+export class Order extends CardDef {
+ static displayName = 'Order';
+ static icon = ReceiptIcon;
+
+ // Human-facing reference, such as 'SV-2024-1234'. A StringField and not
+ // a computed one: it is issued once and then quoted in emails, support
+ // threads and disputes, so it must never change because a field it derived
+ // from changed.
+ @field reference = contains(StringField);
+
+ @field listing = linksTo(() => Listing, { searchable: true });
+ @field buyer = linksTo(() => SoleVaultPerson, { searchable: true });
+ // Narrowed to SellerProfile, not the plain SoleVaultPerson buyer uses one
+ // line up — a seller carries a rating, a buyer does not.
+ @field seller = linksTo(() => SellerProfile, { searchable: true });
+
+ // --- money, as four snapshots + one computed total ---
+ @field price = contains(AmountWithCurrency);
+ @field shippingPrice = contains(AmountWithCurrency);
+ @field authFee = contains(AmountWithCurrency);
+ @field platformFee = contains(AmountWithCurrency);
+
+ @field orderStatus = contains(OrderStatusField);
+
+ // --- lifecycle as event facts, same rule as the rest of this family: store
+ // the date the thing happened, derive every boolean from it. ---
+ @field placedAt = contains(DateField);
+ @field paidAt = contains(DateField);
+ @field shippedAt = contains(DateField);
+ @field authenticatedAt = contains(DateField);
+ @field deliveredAt = contains(DateField);
+ @field completedAt = contains(DateField);
+
+ @field trackingNumber = contains(StringField);
+
+ // The processor's own reference. Deliberately a plain string: this realm does
+ // not model a card, a token or a balance, and pretending otherwise would
+ // invite someone to treat it as authoritative.
+ @field paymentReference = contains(StringField);
+
+ // TOTAL IS COMPUTED. Currency comes from `price` — mixing currencies inside
+ // one order is not a sum, it is a bug, so the code is taken from the item
+ // price and the components are added as bare amounts.
+ @field total = contains(AmountWithCurrency, {
+ computeVia: function (this: Order) {
+ let parts = [
+ this.price,
+ this.shippingPrice,
+ this.authFee,
+ this.platformFee,
+ ];
+ let sum = 0;
+ let seen = false;
+ for (let p of parts) {
+ if (p?.amount != null) {
+ sum += p.amount;
+ seen = true;
+ }
+ }
+ if (!seen) {
+ return undefined;
+ }
+ // A REAL AmountWithCurrency instance, not a `{ amount, currency }`
+ // literal. The index's searchable walker calls peekAtField on this
+ // value, and a plain object throws "the card Object does not have a
+ // field 'amount'" — which error-rows not just this Order but every
+ // Payment/Shipment whose searchable `order` link walks into it.
+ return new AmountWithCurrency({
+ amount: sum,
+ currency: this.price?.currency,
+ });
+ },
+ });
+
+ // Escrow is holding money in every state the graph marks `holds`, minus the
+ // two terminals. Derived, so it cannot disagree with the status.
+ @field fundsHeld = contains(BooleanField, {
+ computeVia: function (this: Order) {
+ let s = this.orderStatus;
+ return (
+ s === 'paid' ||
+ s === 'shipped' ||
+ s === 'authenticating' ||
+ s === 'delivered' ||
+ s === 'disputed'
+ );
+ },
+ });
+
+ @field isPaid = contains(BooleanField, {
+ computeVia: function (this: Order) {
+ return this.paidAt != null;
+ },
+ });
+
+ // --- DENORMALIZED FOR PRERENDERED FITTED ---
+ //
+ // An Orders tab is a grid, a grid is prerendered fitted, and prerendered
+ // fitted CANNOT resolve linksTo. A tile that reads `listing.productTitle`
+ // directly renders blank — which is every tile in the tab, since the product
+ // name is the tile's headline. These are resolved at INDEX time and stored in
+ // the search document, so they are plain attributes by the time fitted runs.
+ //
+ // Defensive on purpose: every link here is optional and may be unloaded.
+ @field productTitle = contains(StringField, {
+ computeVia: function (this: Order) {
+ return this.listing?.productTitle ?? this.cardInfo?.name ?? '';
+ },
+ });
+
+ @field sellerName = contains(StringField, {
+ computeVia: function (this: Order) {
+ return this.seller?.title ?? '';
+ },
+ });
+
+ @field buyerName = contains(StringField, {
+ computeVia: function (this: Order) {
+ return this.buyer?.title ?? '';
+ },
+ });
+
+ @field conditionCode = contains(StringField, {
+ computeVia: function (this: Order) {
+ return this.listing?.conditionCode ?? '';
+ },
+ });
+
+ // ISOLATED — the order's landing page.
+ //
+ // DOMAIN QUESTION: "where is my money, and what happens next?" Everyone who
+ // opens an order — buyer, seller, support — wants those two things, and
+ // neither is a field on this card:
+ //
+ // * "where is my money" is the escrow state plus the Payment records that
+ // link TO this order — a reverse query.
+ // * "what happens next" is the shipment legs, also a reverse query, plus
+ // the transition graph's own answer for the current state.
+ //
+ // A version of this card that rendered its own fields tidily and answered
+ // neither would pass every technical check and still be useless — which is
+ // exactly the failure the domain-question rule exists to catch.
+ //
+ // Direction: Instrument, the family's operational register. Signature element:
+ // the vault plaque (gold hairline over the headline figure) plus the same
+ // lifecycle rail CollectionItem's provenance and AuthenticationRecord's
+ // progress use. One mark reused across the family, not a new marquee per card.
+ // EDIT — 17 editable fields, so grouping is not optional: a flat list in
+ // declaration order would bury the reference behind four money fields and six
+ // dates, and nothing would signal which of them someone actually touches.
+ //
+ // The identity row up top carries what is glanced at (reference, status), the
+ // parties sit just below, and the money, timeline and fulfilment fields go
+ // into independently collapsible sections. `total`, `fundsHeld`, `isPaid` and
+ // the four denormalized strings are computed and deliberately absent.
+ //
+ // HELPER TEXT EARNS ITS PLACE HERE more than on most cards, because two things
+ // about this form are genuinely counter-intuitive and both cost money if
+ // guessed wrong: the fee fields are recorded amounts rather than rates, and
+ // the status is a graph rather than a free dropdown.
+ static edit = OrderEdit;
+
+ static isolated = OrderIsolated;
+
+ // FITTED — hand-rolled, and this is the one card in the family where that
+ // fork is right rather than lazy.
+ //
+ // Order has NO image field, and its anchor is a FIGURE, not a title: the
+ // reader scanning an Orders grid is looking for the amount and the state, not
+ // the product name. FittedCard's slot model makes the title the loud element,
+ // so bending it to put a 2× gold serif number in the footer would fight the
+ // component the whole way. The isolated view already established the plaque
+ // idiom for exactly this figure, so the tile reuses it.
+ //
+ // SLOT DISCIPLINE — four distinct facts, four slots, zero repeats:
+ // reference (head) · total (anchor) · productTitle (body) · status (meta)
+ // Nothing is printed twice, and no slot is padded with a value already shown.
+ static fitted = class Fitted extends Component {
+ get total() {
+ return formatMoney(this.args.model?.total);
+ }
+
+
+
+ {{! THE ANCHOR — the total, decisively the loudest thing at every one
+ of the 16 sizes. Data is all-or-nothing: a money value is never
+ ellipsised, so this is nowrap and the whole row is hidden at the
+ quanta where it cannot fit. }}
+
+
+
+ };
+}
+
+export default Order;
diff --git a/cards/collectibles/sole-vault-payment.gts b/cards/collectibles/sole-vault-payment.gts
new file mode 100644
index 00000000..90d785f6
--- /dev/null
+++ b/cards/collectibles/sole-vault-payment.gts
@@ -0,0 +1,1261 @@
+import {
+ CardDef,
+ field,
+ contains,
+ linksTo,
+ StringField,
+ Component,
+} from '@cardstack/base/card-api';
+import DateField from '@cardstack/base/date';
+import AmountWithCurrency from '@cardstack/base/amount-with-currency';
+import {
+ statusField,
+ canTransition,
+ nextStatuses,
+} from '@cardstack/catalog/fields/status/status';
+import { Order } from './sole-vault-order';
+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 { eq } from '@cardstack/boxel-ui/helpers';
+import { formatMoney } from './money-format';
+
+// Payment — the RECORD of one money movement against one Order.
+//
+// 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 is a
+// record of 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, server-side
+// infrastructure a card does not impersonate.
+//
+// WHY IT IS SEPARATE FROM Order. An order has one lifecycle; its money can have
+// several movements — an authorization, a capture, a partial refund, a chargeback
+// reversal. Folding them into Order would force one status 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.
+//
+// THE AMOUNT IS SIGNED BY DIRECTION, NOT BY A NEGATIVE NUMBER. `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 every
+// aggregate then has to remember which convention it is summing.
+
+export type PaymentState =
+ | 'pending'
+ | 'authorized'
+ | 'captured'
+ | 'failed'
+ | 'refunded';
+
+export type PaymentDirection = 'charge' | 'refund';
+
+export const PaymentStateField = statusField({
+ displayName: 'Payment State',
+ icon: CreditCardIcon,
+ options: [
+ {
+ value: 'pending',
+ label: 'Pending',
+ hue: 'slate',
+ meaning: 'Submitted to the processor, no answer yet.',
+ },
+ {
+ value: 'authorized',
+ label: 'Authorized',
+ hue: 'blue',
+ holds: true,
+ meaning:
+ 'The processor is holding the funds. This is the escrow state — money reserved, not yet taken.',
+ },
+ {
+ value: 'captured',
+ label: 'Captured',
+ hue: 'green',
+ terminal: true,
+ holds: true,
+ meaning: 'Funds actually taken and settled to the platform.',
+ },
+ {
+ value: 'failed',
+ label: 'Failed',
+ hue: 'red',
+ terminal: true,
+ meaning: 'Declined or errored. No money moved.',
+ },
+ {
+ value: 'refunded',
+ label: 'Refunded',
+ hue: 'orange',
+ terminal: true,
+ holds: true,
+ meaning: 'Returned to the payer. Recorded, never edited away.',
+ },
+ ],
+ // A processor's own state machine, mirrored rather than invented:
+ // * `pending` can only resolve — it never goes back to itself.
+ // * An authorization can be captured, can expire into failure, or can be
+ // released as a refund before capture.
+ // * `captured → refunded` is the only edge out of a settled payment, and it
+ // is one-way. A refund is never "un-refunded"; that is a new charge.
+ // * `failed` is terminal because a retry is a NEW payment record. Reusing
+ // this one would erase the evidence that the first attempt was declined.
+ transitions: {
+ pending: ['authorized', 'captured', 'failed'],
+ authorized: ['captured', 'failed', 'refunded'],
+ captured: ['refunded'],
+ failed: [],
+ refunded: [],
+ },
+});
+
+export function canPaymentTransition(from?: string | null, to?: string | null) {
+ return canTransition(PaymentStateField, from, to);
+}
+
+export function nextPaymentStates(from?: string | null) {
+ return nextStatuses(PaymentStateField, from);
+}
+
+class PaymentEdit extends Component {
+ @tracked refsOpen = true;
+ @tracked datesOpen = false;
+
+ toggleRefs = () => (this.refsOpen = !this.refsOpen);
+ toggleDates = () => (this.datesOpen = !this.datesOpen);
+
+
+
+
+
+ <@fields.order />
+
+
+
+
+ <@fields.amount />
+
Always positive. Direction decides the sign — a
+ negative amount here renders inconsistently across formatters.
+
+
+ <@fields.direction />
+
Either
+ charge
+ or
+ refund. A chargeback is a refund with a reason, not a
+ third value.
+
+
+ <@fields.paymentState />
+
authorized
+ is the escrow state: reserved with the processor, not yet taken.
Write
+ simulated
+ unless a real processor produced this record. Naming the
+ simulator in the data is what stops a demo being read as a
+ settlement.
+
+
+ <@fields.processorReference />
+
The only handle support has when reconciling
+ against the processor’s dashboard.
Empty while the payment is only authorized —
+ settlement is when money actually moved.
+
+
+
+
+
+
+
+
+
+}
+
+export class Payment extends CardDef {
+ static displayName = 'Payment';
+ static icon = CreditCardIcon;
+
+ @field order = linksTo(() => Order, { searchable: true });
+
+ @field amount = contains(AmountWithCurrency);
+ @field paymentState = contains(PaymentStateField);
+
+ // 'charge' | 'refund'. Plain string with a documented pair rather than an
+ // enum field, because the two values never grow — a chargeback is a refund
+ // with a different reason, not a third direction.
+ @field direction = contains(StringField);
+
+ // The processor's reference, such as 'pi_3abc123'. The only
+ // handle support has when reconciling against the processor's dashboard, so
+ // it is treated as an identifier: never truncated in any format.
+ @field processorReference = contains(StringField);
+
+ // 'Stripe', 'simulated'. Naming the simulator explicitly in the data is what
+ // stops a demo record being mistaken for a real settlement later.
+ @field processor = contains(StringField);
+
+ @field initiatedAt = contains(DateField);
+ @field settledAt = contains(DateField);
+
+ // Why a refund happened — 'authentication failed', 'buyer dispute'. Empty on
+ // a charge.
+ @field reason = contains(StringField);
+
+ // --- denormalized for prerendered fitted (cannot resolve linksTo) ---
+ @field orderReference = contains(StringField, {
+ computeVia: function (this: Payment) {
+ return this.order?.reference ?? '';
+ },
+ });
+
+ @field productTitle = contains(StringField, {
+ computeVia: function (this: Payment) {
+ return this.order?.productTitle ?? this.cardInfo?.name ?? '';
+ },
+ });
+
+ // Signed display string, derived once here so the atom, the row and any future
+ // ledger view cannot disagree about how a refund is written.
+ @field signedAmount = contains(StringField, {
+ computeVia: function (this: Payment) {
+ let money = formatMoney(this.amount);
+ if (!money) {
+ return '';
+ }
+ return this.direction === 'refund' ? `−${money}` : money;
+ },
+ });
+
+ // ISOLATED — the payment's landing page. Instrument direction: this is a
+ // ledger entry someone opens while reconciling, so the layout is the plaque
+ // figure plus reference data, with no queries — a payment points AT its
+ // order; nothing points at a payment.
+ //
+ // Domain question: "did money move, how much, which way, and can I find it
+ // on the processor's dashboard?" The first three are the hero; the fourth is
+ // the reconciliation section, where the processor reference renders mono and
+ // whole — it is the only handle support has.
+ static isolated = class Isolated extends Component {
+
+
+
+
+
+ {{! THE PLAQUE — the signed amount is the dominant element; the
+ direction/processor caption sits BELOW the figure, never above it
+ as a kicker. A refund's minus sign is carried by `signedAmount`
+ itself, derived once for every format. }}
+
+ Reference
+
+ {{! The only handle support has against the processor's
+ dashboard: mono, tabular, never truncated. }}
+ {{if
+ @model.processorReference
+ @model.processorReference
+ '—'
+ }}
+
Empty settled while only authorized —
+ settlement is when money actually moved.
+
+
+
+ {{! Shape 3: the receipt stub — a linked card, torn from the top. }}
+
+
Order
+ {{#if @model.order}}
+
+ <@fields.order @format='embedded' />
+
+ {{else}}
+
+ No order
+ linked — a payment with nothing to settle.
+
+ {{/if}}
+
+
+
+
+
+ };
+
+ // EDIT — nine editable fields, so grouped rather than a flat list.
+ //
+ // The identity row carries the two facts that decide what this record MEANS
+ // (direction and state); everything else is reference data and dates.
+ //
+ // `direction` and `processor` both get helper text because both are free
+ // strings whose accepted values are not discoverable from the input, and
+ // getting either wrong is the difference between a demo record and something
+ // a reader believes settled real money.
+ static edit = PaymentEdit;
+
+ // FITTED — FittedCard, unlike Order's hand-rolled tile. A payment IS a
+ // standard composition (glyph + eyebrow + title + badge + footer), and the
+ // family already runs seven of these, so forking an eighth idiom would cost
+ // consistency for nothing.
+ //
+ // SLOT DISCIPLINE — four distinct facts, four slots:
+ // direction (eyebrow) · signedAmount (title, the anchor) · state (badge) ·
+ // processorReference (footer)
+ // `<:subtitle>` and `<:meta>` are NOT rendered: the values left over are the
+ // order reference and the product title, both of which belong to the Order
+ // tile, not here — a payment row that repeats them reads as a duplicate order.
+ static fitted = class Fitted extends Component {
+
+
+ {{! Rule 2 anchor: no image field on a payment, so this is the tier-2
+ icon — the card's OWN static icon, the same one its atom uses. }}
+ <:placeholder>
+
+
+
+ {{! The eyebrow carries the PROCESSOR, not the direction.
+ Direction was the obvious choice and it is wrong twice over: the
+ minus sign in `signedAmount` already states it, and on a settled
+ refund an eyebrow reading "REFUND" sits directly above a badge
+ reading "Refunded" — two slots saying one thing, which reads as a
+ single field wrapped onto two lines.
+ The processor is the fact nothing else on the tile carries, and it
+ is the one worth surfacing: 'simulated' is how a reader tells a demo
+ record from a real settlement. }}
+ <:eyebrow>{{@model.processor}}
+
+ {{! The amount is the title BECAUSE it is the main field of a payment —
+ which keeps FittedCard's own "title is loudest" behaviour pointing at
+ the right value instead of fighting it. }}
+ <:title>{{if @model.signedAmount @model.signedAmount '—'}}
+
+ <:badgeRight>
+ {{#if @model.paymentState}}
+ <@fields.paymentState @format='atom' />
+ {{/if}}
+
+
+ <:footer>
+ {{! A processor reference is reconciled by hand against a dashboard:
+ mono, nowrap, and hidden WHOLE at the narrow quanta rather than
+ truncated to an unusable stub. }}
+ {{#if @model.processorReference}}
+ {{@model.processorReference}}
+ {{/if}}
+
+
+
+
+
+ };
+
+ static atom = class Atom extends Component {
+
+
+ {{if @model.signedAmount @model.signedAmount '—'}}
+ {{#if @model.paymentState}}
+ <@fields.paymentState @format='atom' />
+ {{/if}}
+
+
+
+ };
+
+ static embedded = class Embedded extends Component {
+
+
+
+
+ };
+}
+
+export default Payment;
diff --git a/cards/collectibles/sole-vault-person.gts b/cards/collectibles/sole-vault-person.gts
new file mode 100644
index 00000000..0c836d3f
--- /dev/null
+++ b/cards/collectibles/sole-vault-person.gts
@@ -0,0 +1,861 @@
+import {
+ Component,
+ realmURL,
+ getComponent,
+ type BaseDefComponent,
+} from '@cardstack/base/card-api';
+import { tracked } from '@glimmer/tracking';
+import type Owner from '@ember/owner';
+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 {
+ FieldContainer,
+ FittedCard,
+ LoadingIndicator,
+} 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
+// 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
+// (unlike this file's own deferred `identifyCard` read), so a static import
+// of Listing here can hand seller-profile.gts an undefined SoleVaultPerson
+// 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.
+import type { Listing as ListingType } from './listing';
+
+// The isolated landing page, exported so SellerProfile can extend it.
+export class SoleVaultPersonIsolated<
+ T extends typeof SoleVaultPerson = typeof SoleVaultPerson,
+> extends Component {
+ get realms() {
+ let realmUrl = this.args.model?.[realmURL];
+ return realmUrl ? [realmUrl.href] : [];
+ }
+
+ // Resolved lazily via dynamic import (see the header comment on the
+ // `ListingType` type-only import above) — reading `this.listingClass`
+ // inside the query thunk below is what makes it reactive once this
+ // resolves from undefined to the real class.
+ @tracked private listingClass: typeof ListingType | undefined;
+
+ constructor(owner: Owner, args: any) {
+ super(owner, args);
+ import('./listing.gts').then((mod) => {
+ this.listingClass = mod.Listing;
+ });
+ }
+
+ private listingsQuery = this.args.context?.getCards(
+ this,
+ () => {
+ let ref = this.listingClass && identifyCard(this.listingClass);
+ let id = this.args.model?.id;
+ return ref && id
+ ? { filter: { on: ref, every: [{ eq: { 'seller.id': id } }] } }
+ : undefined;
+ },
+ () => this.realms,
+ { isLive: true },
+ );
+
+ get listings() {
+ return (this.listingsQuery?.instances ?? []).filter(Boolean);
+ }
+
+ get listingsLoading() {
+ return Boolean(this.listingsQuery) && !this.listingsQuery?.instances;
+ }
+
+ get listingsError() {
+ return (this.listingsQuery as any)?.error;
+ }
+
+
+
+
+ {{#if @model.photo.resolvedUrl}}
+
+ {{else}}
+
+
+
+ {{! What they are selling — reverse query over Listing.seller. }}
+
+
Listings{{this.listings.length}}
+
+ {{#if this.listingsError}}
+
+ Could
+ not load listings.
+
+ {{else if this.listingsLoading}}
+
Looking for listings…
+ {{else if this.listings.length}}
+
+ {{! getCards instances have no `.component` — getComponent(card)
+ is the API; the property renders an empty row silently. }}
+ {{#each this.listings as |l|}}
+
{{#let (getComponent l) as |ListingCard|}}
+
+ {{/let}}
+ {{/each}}
+
+ {{else}}
+
+ Nothing listed
+ for sale by this person yet.
+
+ {{/if}}
+
+
+
+
+
+}
+
+// SoleVaultPerson — 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.
+//
+// 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';
+ static icon = UserIcon;
+
+ // ISOLATED — the person's landing page. Object direction: the portrait (or
+ // the gold-filled initials plaque standing in for it) is the anchor.
+ //
+ // Domain question: "who is this, how do I reach them, what are they
+ // selling, and how well do buyers trust them?" The first two are the hero;
+ // 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;
+
+ // 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
+ > {
+
+
Paste a URL or upload a file — consumers read
+ whichever wins as
+ photo.resolvedUrl.
+
+
+
+
+
+ };
+
+ // FITTED — FittedCard, same fork and knobs as the rest of the Sole Vault
+ // family.
+ //
+ // SLOT DISCIPLINE — two distinct facts, two slots. No eyebrow: the only
+ // string left is the email, which is already the footer, and "Person" as a
+ // literal eyebrow is chrome that costs the title a row at the badge quantum.
+ static fitted: BaseDefComponent = class Fitted extends Component<
+ typeof SoleVaultPerson
+ > {
+
+
+ {{! Rule 2 anchor: tier 1 is the photo. The tier-2 fallback here is the
+ INITIALS rather than the generic user glyph — a person's initials
+ identify this record, where a placeholder avatar is exactly the
+ "decoration where content should be" the rules warn against. The
+ icon stays as the last resort for an unnamed person, where
+ `initialsOf` yields '?'. }}
+ <:placeholder>
+ {{#if @model.name}}
+ {{@model.initials}}
+ {{else}}
+
+ {{/if}}
+
+
+ <:title>{{@model.title}}
+
+ <:footer>
+ {{! An email is an identifier — it gets typed and read aloud — so it
+ never ellipsises. It is hidden WHOLE at the narrow quanta below. }}
+ {{#if @model.email}}
+ {{@model.email}}
+ {{/if}}
+
+
+
+
+
+ };
+
+ // Overrides PersonBase's plain embedded row with the Sole Vault family's own
+ // look, so the row matches the rest of the family.
+ static embedded: BaseDefComponent = class Embedded extends Component<
+ typeof SoleVaultPerson
+ > {
+
+
+
+
+ };
+}
+
+export default SoleVaultPerson;
diff --git a/cards/collectibles/sole-vault-shipment.gts b/cards/collectibles/sole-vault-shipment.gts
new file mode 100644
index 00000000..b019c72b
--- /dev/null
+++ b/cards/collectibles/sole-vault-shipment.gts
@@ -0,0 +1,1345 @@
+import {
+ CardDef,
+ field,
+ contains,
+ linksTo,
+ StringField,
+ Component,
+} from '@cardstack/base/card-api';
+import DateField from '@cardstack/base/date';
+import BooleanField from '@cardstack/base/boolean';
+import {
+ statusField,
+ canTransition,
+ nextStatuses,
+} from '@cardstack/catalog/fields/status/status';
+import { Order } from './sole-vault-order';
+import PackageIcon from '@cardstack/boxel-icons/package';
+import RouteIcon from '@cardstack/boxel-icons/route';
+import MapPinIcon from '@cardstack/boxel-icons/map-pin';
+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';
+
+// Shipment — one physical movement of one item, with its tracking.
+//
+// THE TWO-LEG SHAPE IS THE DOMAIN, NOT AN IMPLEMENTATION DETAIL. In an
+// authenticated marketplace the item does NOT go seller → buyer. It goes
+// seller → authentication centre → buyer, and those are two different shipments
+// with two different tracking numbers, two carriers and two failure modes, so
+// `leg` distinguishes them and an Order links to both.
+//
+// Collapsing 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.
+//
+// WHY THERE IS NO CARRIER API HERE. `carrier` and `trackingNumber` are recorded
+// facts, and `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.
+
+export type ShipmentLeg = 'to-authenticator' | 'to-buyer' | 'return-to-seller';
+
+export type ShipmentStatus =
+ | 'label-created'
+ | 'in-transit'
+ | 'delivered'
+ | 'exception'
+ | 'lost';
+
+export const ShipmentStatusField = statusField({
+ displayName: 'Shipment Status',
+ icon: PackageIcon,
+ options: [
+ {
+ value: 'label-created',
+ label: 'Label created',
+ hue: 'slate',
+ meaning: 'A label exists; the carrier has not scanned the parcel yet.',
+ },
+ {
+ value: 'in-transit',
+ label: 'In transit',
+ hue: 'blue',
+ holds: true,
+ meaning: 'Scanned and moving. Someone else physically holds the item.',
+ },
+ {
+ value: 'delivered',
+ label: 'Delivered',
+ hue: 'green',
+ terminal: true,
+ holds: true,
+ meaning: 'Arrived and signed for at this leg’s destination.',
+ },
+ {
+ value: 'exception',
+ label: 'Exception',
+ hue: 'amber',
+ holds: true,
+ meaning:
+ 'The carrier reported a problem — damage, refusal, a bad address. Recoverable.',
+ },
+ {
+ value: 'lost',
+ label: 'Lost',
+ hue: 'red',
+ terminal: true,
+ holds: true,
+ meaning:
+ 'Written off. On a held order this is a refund trigger, not a status tidy-up.',
+ },
+ ],
+ // `exception` is deliberately NOT terminal and NOT a dead end: most carrier
+ // exceptions resolve and the parcel moves again, so it returns to
+ // `in-transit`. Only `lost` and `delivered` end a leg.
+ //
+ // There is no edge from `delivered` to anything: a parcel that comes back is
+ // the `return-to-seller` leg — a different Shipment card — not this one
+ // reversing.
+ transitions: {
+ 'label-created': ['in-transit', 'exception', 'lost'],
+ 'in-transit': ['delivered', 'exception', 'lost'],
+ exception: ['in-transit', 'delivered', 'lost'],
+ delivered: [],
+ lost: [],
+ },
+});
+
+export function canShipmentTransition(
+ from?: string | null,
+ to?: string | null,
+) {
+ return canTransition(ShipmentStatusField, from, to);
+}
+
+export function nextShipmentStatuses(from?: string | null) {
+ return nextStatuses(ShipmentStatusField, from);
+}
+
+class ShipmentIsolated extends Component {
+ // ---- command wiring ----
+ // One real action: Mark delivered, which closes a leg and advances the
+ // linked Order (leg-aware; see commands/mark-delivered-command.gts).
+ // Rendered only when a command context exists AND the state machine
+ // permits `→ delivered`, per the no-lying-affordances rule. The command
+ // module imports this one for `canShipmentTransition`, so it is loaded
+ // dynamically inside the click — a static import back would be a cycle
+ // that evaluates `extends`-time code (the seller-profile bug).
+ @tracked private acting = false;
+ @tracked private actionError: string | undefined;
+
+ get showMarkDelivered() {
+ return Boolean(this.commandContext) && this.canMarkDelivered;
+ }
+
+ get commandContext() {
+ return this.args.context?.commandContext;
+ }
+
+ get canMarkDelivered() {
+ return canShipmentTransition(this.args.model?.shipmentStatus, 'delivered');
+ }
+
+ markDelivered = async () => {
+ if (this.acting || !this.commandContext) {
+ return;
+ }
+ this.acting = true;
+ this.actionError = undefined;
+ try {
+ let { default: MarkDeliveredCommand } =
+ await import('./commands/mark-delivered-command.gts');
+ await new MarkDeliveredCommand(this.commandContext).execute({
+ shipmentId: this.args.model?.id,
+ } as any);
+ } catch (e: any) {
+ this.actionError = e?.message ?? String(e);
+ } finally {
+ this.acting = false;
+ }
+ };
+
+
+
+
+ {{! No kicker above the heading — the leg IS the heading. }}
+
+ {{/if}}
+
+
+ {{! THE MANIFEST — status, carrier, tracking and ETA together, as one
+ stamped plaque rather than scattered field rows. Real gold surface,
+ not a hairline accent. }}
+
+
No tracking link recorded — this realm does not
+ poll carriers; a command records what a webhook reported.
+ {{/if}}
+
+
+ {{! THE SIGNATURE ELEMENT — a real tracking rail, each stop lit from
+ its own event date (rendered through the card's own date field, so
+ it cannot disagree with the manifest above), dated inline rather
+ than a bare checklist. }}
+
+
One of
+ to-authenticator,
+ to-buyer
+ or
+ return-to-seller. Each leg is its own card: an item
+ goes seller → authenticator → buyer, and those journeys have
+ different carriers and different failure modes.
+
+
+ <@fields.shipmentStatus />
+
An exception can return to in-transit — most
+ resolve. Only delivered and lost end a leg.
Gets read aloud and typed into a carrier’s
+ site, so it is never truncated anywhere it renders.
+
+
+ <@fields.trackingUrl />
+
A plain string on purpose — carrier URLs
+ carry query strings, and a URL field silently drops those,
+ taking the whole value out of the search index with it.
Setting this is what marks the leg delivered
+ — the boolean is derived, not stored.
+
+
+
+
+
+
+
+
+
+}
+
+export class Shipment extends CardDef {
+ static displayName = 'Shipment';
+ static icon = PackageIcon;
+
+ @field order = linksTo(() => Order, { searchable: true });
+
+ // Which leg of the journey. See the header note — this is the field that
+ // makes an authenticated marketplace's logistics representable at all.
+ @field leg = contains(StringField);
+
+ @field shipmentStatus = contains(ShipmentStatusField);
+
+ @field carrier = contains(StringField);
+
+ // An identifier a human reads aloud and types into a carrier's site: never
+ // truncated in any format, in any card that renders it.
+ @field trackingNumber = contains(StringField);
+
+ // A real link the user clicks. Kept as a StringField rather than base `url`
+ // deliberately: 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 local addendum, not a guess.
+ @field trackingUrl = contains(StringField);
+
+ @field shippedFrom = contains(StringField);
+ @field shippedTo = contains(StringField);
+
+ @field labelCreatedAt = contains(DateField);
+ @field shippedAt = contains(DateField);
+ @field deliveredAt = contains(DateField);
+ @field estimatedDelivery = contains(DateField);
+
+ // Event-fact derivation, same rule as the rest of the family.
+ @field isDelivered = contains(BooleanField, {
+ computeVia: function (this: Shipment) {
+ return this.deliveredAt != null;
+ },
+ });
+
+ // Needs attention = a carrier problem on a parcel nobody has yet resolved.
+ // A queue view filters on this, so it is worth deriving once here rather than
+ // re-deriving the same boolean in every consumer.
+ @field needsAttention = contains(BooleanField, {
+ computeVia: function (this: Shipment) {
+ return this.shipmentStatus === 'exception';
+ },
+ });
+
+ // --- denormalized for prerendered fitted (cannot resolve linksTo) ---
+ @field orderReference = contains(StringField, {
+ computeVia: function (this: Shipment) {
+ return this.order?.reference ?? '';
+ },
+ });
+
+ @field productTitle = contains(StringField, {
+ computeVia: function (this: Shipment) {
+ return this.order?.productTitle ?? this.cardInfo?.name ?? '';
+ },
+ });
+
+ // Human label for the leg, derived so no consumer hand-maps the three values
+ // and drifts from the others.
+ @field legLabel = contains(StringField, {
+ computeVia: function (this: Shipment) {
+ switch (this.leg) {
+ case 'to-authenticator':
+ return 'To authenticator';
+ case 'to-buyer':
+ return 'To buyer';
+ case 'return-to-seller':
+ return 'Return to seller';
+ default:
+ return '';
+ }
+ },
+ });
+
+ // ISOLATED — the shipment's landing page. Instrument direction: no image
+ // and no money figure, so the anchor is TYPOGRAPHIC and it is the leg, same
+ // reasoning as the fitted — which journey this is decides whose problem an
+ // exception is.
+ //
+ // Domain question: "where is the parcel, and on which journey?" The hero
+ // answers both (leg + route line); the manifest strip beneath carries
+ // status/carrier/tracking, and the SIGNATURE ELEMENT is the manifest rail —
+ // a real tracking timeline, each stop lit from its own event DATE so it
+ // cannot disagree with the dates list below it, same rule as Order's
+ // escrow rail.
+ static isolated = ShipmentIsolated;
+
+ // EDIT — twelve editable fields, grouped.
+ //
+ // `leg` is the field this form exists to get right. It is a free string with
+ // exactly three accepted values, none of them discoverable from the input, and
+ // it is what makes an authenticated marketplace's logistics representable at
+ // all — so the accepted literals are spelled out rather than left to a guess
+ // and a rejected save.
+ static edit = ShipmentEdit;
+
+ // FITTED — FittedCard, same fork and knobs as the family's supporting tiles.
+ //
+ // THE ANCHOR IS TYPOGRAPHIC AND IT IS THE LEG, NOT THE PRODUCT. A shipment has
+ // no image and no money figure; what a reader scanning a transit queue needs
+ // first is *which journey this is* ("To authenticator" vs "To buyer"), because
+ // that is what tells them whose problem an exception is. So the leg takes the
+ // title slot at 700 weight and the product name is the quiet eyebrow.
+ //
+ // SLOT DISCIPLINE — four distinct facts, four slots:
+ // productTitle (eyebrow) · legLabel (title, the anchor) · status (badge) ·
+ // trackingNumber (footer)
+ // `<:subtitle>`/`<:meta>` unrendered: what is left is the carrier and the
+ // order reference, and the carrier alone is not worth a row that costs the
+ // tracking number its space at the tighter quanta.
+ static fitted = class Fitted extends Component {
+
+
+ {{! Rule 2 anchor, tier 2: the card's OWN static icon, shared with its
+ isolated, embedded and atom formats — the icon is the identity. }}
+ <:placeholder>
+
+
+
+ <:eyebrow>{{if
+ @model.productTitle
+ @model.productTitle
+ 'Unlinked order'
+ }}
+
+ <:title>{{if @model.legLabel @model.legLabel 'Shipment'}}
+
+ <:badgeRight>
+ {{#if @model.shipmentStatus}}
+ <@fields.shipmentStatus @format='atom' />
+ {{/if}}
+
+
+ <:footer>
+ {{! A tracking number gets typed into a carrier's site — mono, tabular,
+ never ellipsised, hidden WHOLE at the narrow quanta instead. }}
+ {{#if @model.trackingNumber}}
+ {{@model.trackingNumber}}
+ {{/if}}
+
+
+
+
+
+ };
+
+ static atom = class Atom extends Component {
+
+
+
+ {{#if @model.shipmentStatus}}
+ <@fields.shipmentStatus @format='atom' />
+ {{else}}
+ not shipped
+ {{/if}}
+
+
+
+ };
+
+ static embedded = class Embedded extends Component {
+
+
+
+
+ };
+
+ static isolated = class Isolated extends Component {
+ get stars() {
+ let rating = this.args.model?.rating ?? 0;
+ return [1, 2, 3, 4, 5].map((n) => ({ on: n <= rating }));
+ }
+
+
+
+ {{#each this.stars as |star|}}
+
+ {{/each}}
+
+ {{#if @model.body}}
+
{{@model.body}}
+ {{/if}}
+
+
+
+ };
+}
diff --git a/cards/fulfilment/PickList/pk-0031.json b/cards/fulfilment/PickList/pk-0031.json
new file mode 100644
index 00000000..dc9d1c8c
--- /dev/null
+++ b/cards/fulfilment/PickList/pk-0031.json
@@ -0,0 +1,69 @@
+{
+ "data": {
+ "type": "card",
+ "attributes": {
+ "pickListNumber": "PK-2026-0031",
+ "assignedTo": "Priya Raman",
+ "status": "in_progress",
+ "createdAt": "2026-08-14T08:00:00.000Z",
+ "items": [
+ {
+ "sku": "BW-001",
+ "productName": "Cobalt Wall Hook",
+ "barcode": "5012345678901",
+ "binLocation": "A3-B-14",
+ "quantity": 4,
+ "pickedQuantity": 4,
+ "isShort": false,
+ "orderNumbers": ["ORD-2026-0997"]
+ },
+ {
+ "sku": "PC-100",
+ "productName": "Braided Power Cable 2m",
+ "barcode": "5012345678925",
+ "binLocation": "A1-D-06",
+ "quantity": 2,
+ "pickedQuantity": 0,
+ "isShort": false,
+ "orderNumbers": ["ORD-2026-0998"]
+ },
+ {
+ "sku": "CT-330",
+ "productName": "Cast Iron Trivet",
+ "barcode": "5012345678956",
+ "binLocation": "C1-A-09",
+ "quantity": 1,
+ "pickedQuantity": 0,
+ "isShort": false,
+ "orderNumbers": ["ORD-2026-0997"]
+ }
+ ],
+ "cardInfo": {
+ "name": "PK-2026-0031"
+ }
+ },
+ "relationships": {
+ "warehouse": {
+ "links": {
+ "self": "../Warehouse/wh-main"
+ }
+ },
+ "orders.0": {
+ "links": {
+ "self": "../FulfilmentOrder/ord-0997"
+ }
+ },
+ "orders.1": {
+ "links": {
+ "self": "../FulfilmentOrder/ord-0998"
+ }
+ }
+ },
+ "meta": {
+ "adoptsFrom": {
+ "module": "../pick-list",
+ "name": "PickList"
+ }
+ }
+ }
+}
diff --git a/cards/fulfilment/ProductReturn/rma-0089.json b/cards/fulfilment/ProductReturn/rma-0089.json
new file mode 100644
index 00000000..d02da155
--- /dev/null
+++ b/cards/fulfilment/ProductReturn/rma-0089.json
@@ -0,0 +1,54 @@
+{
+ "data": {
+ "type": "card",
+ "attributes": {
+ "rmaNumber": "RMA-2026-0089",
+ "customerName": "Elena Marchetti",
+ "status": "inspecting",
+ "reason": "defective",
+ "reasonDetails": "Arrived with a hairline crack across the underside. Photo sent by email.",
+ "lineItems": [
+ {
+ "sku": "CT-330",
+ "productName": "Cast Iron Trivet",
+ "quantity": 1,
+ "unitPrice": {
+ "amount": 26,
+ "currency": {
+ "code": "GBP"
+ }
+ },
+ "itemCondition": "damaged",
+ "disposition": "dispose",
+ "inspectionNotes": "Cracked casting, not salvageable. Photographed for the vendor claim."
+ }
+ ],
+ "returnTrackingNumber": "AB777712345GB",
+ "refundMethod": "original",
+ "requestedAt": "2026-08-08T10:12:00.000Z",
+ "receivedAt": "2026-08-13T09:40:00.000Z",
+ "inspectedAt": "2026-08-14T08:05:00.000Z",
+ "cardInfo": {
+ "name": "RMA-2026-0089"
+ }
+ },
+ "relationships": {
+ "order": {
+ "links": {
+ "self": "../FulfilmentOrder/ord-0978"
+ }
+ },
+ "originalShipment": {
+ "links": {
+ "self": "../Shipment/shp-0129"
+ }
+ }
+ },
+ "meta": {
+ "adoptsFrom": {
+ "module": "../product-return",
+ "name": "ProductReturn"
+ }
+ }
+ }
+}
diff --git a/cards/fulfilment/Spec/fulfilment-pick-list.json b/cards/fulfilment/Spec/fulfilment-pick-list.json
new file mode 100644
index 00000000..72fe1504
--- /dev/null
+++ b/cards/fulfilment/Spec/fulfilment-pick-list.json
@@ -0,0 +1,33 @@
+{
+ "data": {
+ "type": "card",
+ "attributes": {
+ "ref": {
+ "module": "../pick-list",
+ "name": "PickList"
+ },
+ "specType": "card",
+ "cardTitle": "Pick List",
+ "cardDescription": "A batched walk through the warehouse — one oversized item at a time, with keyboard-wedge barcode scanning that works with real hardware.",
+ "readMe": "## Pick List\n\nBatching is the point: the same SKU picked once can serve three orders, so `PickItemField.orderNumbers` is plural and the picker is told the total to grab.\n\nThe route is a plain sort of bin labels, and `routeOrder()` says why: bins are named so that sorting them lexically walks the aisles in order. That is a property of how bins are labelled, not an optimisation this card invents — calling it a solver would overclaim.\n\nThe isolated view is the mobile pick walk: **one item at a time**, oversized, because it is read at arm's length in an aisle by someone holding a box. A USB barcode scanner is a keyboard that types fast and presses Enter, so handling Enter on a text input supports real hardware with no device integration at all — and a scan that does not match the current item says so rather than silently advancing.\n\nPicks persist through `store.patch`, which replaces arrays wholesale, so the whole item list is re-serialized on each write. With no store present (prerender, a read-only surface) the walk renders as a plan and offers no buttons that would silently do nothing.\n\n### Example\n\n`PK-2026-0031` is mid-walk: one line picked, two to go, batched across two orders. `PK-2026-0030` is complete.\n\n### Non-goals\n\nNo wave picking or sort-to-order. Grouping many orders into a wave and sorting items back to orders at the packing station is out of scope.1 in the source spec.",
+ "containedExamples": [],
+ "cardInfo": {
+ "name": "Pick List",
+ "summary": "A batched walk through the warehouse — one oversized item at a time, with keyboard-wedge barcode scanning that works with real hardware."
+ }
+ },
+ "relationships": {
+ "linkedExamples.0": {
+ "links": {
+ "self": "../PickList/pk-0031"
+ }
+ }
+ },
+ "meta": {
+ "adoptsFrom": {
+ "name": "Spec",
+ "module": "@cardstack/base/spec"
+ }
+ }
+ }
+}
diff --git a/cards/fulfilment/Spec/fulfilment-product-return.json b/cards/fulfilment/Spec/fulfilment-product-return.json
new file mode 100644
index 00000000..311bd61f
--- /dev/null
+++ b/cards/fulfilment/Spec/fulfilment-product-return.json
@@ -0,0 +1,33 @@
+{
+ "data": {
+ "type": "card",
+ "attributes": {
+ "ref": {
+ "module": "../product-return",
+ "name": "ProductReturn"
+ },
+ "specType": "card",
+ "cardTitle": "Product Return",
+ "cardDescription": "The full returns workflow — reason, per-item condition and disposition, and a refund computed from policy rather than typed in.",
+ "readMe": "## Product Return (PR)\n\n**The refund is computed, never typed in.** Item value comes from the lines; the restocking fee comes from the reason's own policy rate, waived entirely when the fault was ours. Two people processing the same return get the same number, and the arithmetic is visible on the card rather than in someone's head.\n\n`RETURN_REASONS` carries `restockable` and `feeRate` per reason, so a change of returns policy is an edit to one list. `isOurFault()` is exported separately because \"waive the fee\" is a different question from \"can it go back on the shelf\".\n\nPer item: a **condition** (what arrived) and a **disposition** (what happens to it). Only `restock` puts stock back; refurbish, liquidate, dispose and return-to-vendor all remove it from sellable inventory in different ways, and the difference matters to the month-end numbers.\n\nThe four timestamps are event facts — requested, received, inspected, completed — each written once. Progress is a question about which are set.\n\n### Example\n\n`RMA-2026-0089`: a defective trivet, inspected and dispositioned to dispose, fee waived because the fault was ours. `RMA-2026-0091` is the other shape — changed mind, restockable, 15% fee applied.\n\n### Non-goals\n\nThe RMA records the restock decision, not the stock movement. Adjusting the matching `InventoryStock` row when the units are physically put away is a separate, deliberate action — the isolated view says so on the card.",
+ "containedExamples": [],
+ "cardInfo": {
+ "name": "Product Return / RMA (PR)",
+ "summary": "The full returns workflow — reason, per-item condition and disposition, and a refund computed from policy rather than typed in."
+ }
+ },
+ "relationships": {
+ "linkedExamples.0": {
+ "links": {
+ "self": "../ProductReturn/rma-0089"
+ }
+ }
+ },
+ "meta": {
+ "adoptsFrom": {
+ "name": "Spec",
+ "module": "@cardstack/base/spec"
+ }
+ }
+ }
+}
diff --git a/cards/fulfilment/pick-list.gts b/cards/fulfilment/pick-list.gts
new file mode 100644
index 00000000..7218b5dc
--- /dev/null
+++ b/cards/fulfilment/pick-list.gts
@@ -0,0 +1,1030 @@
+import {
+ CardDef,
+ Component,
+ FieldDef,
+ StringField,
+ contains,
+ containsMany,
+ field,
+ linksTo,
+ linksToMany,
+} from '@cardstack/base/card-api';
+import NumberField from '@cardstack/base/number';
+import BooleanField from '@cardstack/base/boolean';
+import DatetimeField from '@cardstack/base/datetime';
+import enumField from '@cardstack/base/enum';
+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';
+
+export const PICK_LIST_STATUSES = [
+ { value: 'pending', label: 'Not started' },
+ { value: 'in_progress', label: 'In progress' },
+ { value: 'complete', label: 'Complete' },
+];
+
+export const PickListStatusField = enumField(StringField, {
+ options: PICK_LIST_STATUSES,
+ displayName: 'Pick List Status',
+});
+
+// One stop on the walk. `orderNumbers` is plural because batching is the whole
+// point of a pick list: the same SKU picked once can serve three orders, and
+// the picker needs to know how many to grab in total.
+export class PickItemField extends FieldDef {
+ static displayName = 'Pick Item';
+
+ @field sku = contains(StringField);
+ @field productName = contains(StringField);
+ @field barcode = contains(StringField);
+ @field binLocation = contains(StringField);
+ @field quantity = contains(NumberField);
+ @field pickedQuantity = contains(NumberField);
+ @field isShort = contains(BooleanField);
+ @field orderNumbers = containsMany(StringField);
+
+ get isDone() {
+ return (
+ this.isShort === true ||
+ (this.pickedQuantity ?? 0) >= (this.quantity ?? 0)
+ );
+ }
+
+ static embedded = class Embedded extends Component {
+
+
+ Issued to
+ <@fields.refundMethod @format='atom' />. This card
+ records the amount; moving the money happens in the sales channel
+ that took the payment.
+
+
+
+ {{#if @model.restockingLines.length}}
+
+ {{@model.restockingLines.length}}
+ of these lines go back on the shelf. Adjust the matching stock rows
+ when the units are physically put away — the RMA records the
+ decision, not the movement.
+
+
+
+
+ };
+
+ static atom = class Atom extends Component {
+
+ {{@model.rmaNumber}}
+
+
+ };
+
+ static fitted = class Fitted extends Component {
+
+
+
+
+
+ {{@model.rmaNumber}}
+
+ {{@model.statusStyle.label}}
+
+
+
{{@model.reasonLabel}}
+
{{if @model.customerName @model.customerName ''}}
+ {{! Only rendered at the tall quantum. Without it a 170x250 tile spent
+ ~200px of its body row on nothing, which is what made the card read
+ as unfinished rather than as spacious. }}
+
+
+
+
+
+ };
+}
+
+function dotStyle(hue: string | undefined) {
+ return htmlSafe(`--st-hue: ${hue ?? 'var(--muted-foreground)'}`);
+}
+
+export default ProductReturn;
diff --git a/components/collection-panel.gts b/components/collection-panel.gts
new file mode 100644
index 00000000..69ef269b
--- /dev/null
+++ b/components/collection-panel.gts
@@ -0,0 +1,690 @@
+import GlimmerComponent from '@glimmer/component';
+import PlusIcon from '@cardstack/boxel-icons/plus';
+import { tracked } from '@glimmer/tracking';
+import { on } from '@ember/modifier';
+import { fn } from '@ember/helper';
+import { consume } from 'ember-provide-consume-context';
+import { debounce } from 'lodash-es';
+import { eq } from '@cardstack/boxel-ui/helpers';
+import {
+ BoxelInput,
+ Button,
+ ViewSelector,
+} from '@cardstack/boxel-ui/components';
+import TableIcon from '@cardstack/boxel-icons/table';
+import type { CardCrudFunctions } from '@cardstack/base/card-api';
+import {
+ identifyCard,
+ searchEntryWireQueryFromQuery,
+ CardCrudFunctionsContextName,
+ type Query,
+ type Filter,
+ type CodeRef,
+ type SearchEntryWireQuery,
+ type getCards,
+} from '@cardstack/runtime-common';
+import type { ViewItem } from '@cardstack/boxel-ui/components';
+import {
+ Grid3x3 as GridIcon,
+ Rows4 as StripIcon,
+} from '@cardstack/boxel-ui/icons';
+import type { CardContext, CardDef } from '@cardstack/base/card-api';
+
+import { Table, type TableColumn } from '@cardstack/catalog/components/table';
+
+type View = 'grid' | 'strip' | 'table';
+
+interface Signature {
+ Args: {
+ /** The card type this panel lists. A class, not a display name — see below. */
+ cardClass: typeof CardDef;
+ context?: CardContext;
+ realms: string[];
+ /** Table columns. Keys are read straight off the resolved instance. */
+ columns: TableColumn[];
+ /** What the panel is called, for the table caption and the empty state. */
+ label: string;
+ searchPlaceholder?: string;
+ newLabel?: string;
+ /**
+ * Field to sort by. Defaults to `title`.
+ *
+ * It has to be an argument rather than a constant because a sort naming a
+ * field the card does not have returns an EMPTY result, not an unsorted
+ * one — so a card whose human label lives under another name (Clause.name,
+ * Signatory.signingTitle) silently renders as "none yet" instead of its
+ * rows. The default keeps every existing consumer unchanged.
+ */
+ sortBy?: string;
+ /** Which view the panel opens in. Defaults to `table`. */
+ defaultView?: 'grid' | 'strip' | 'table';
+ /**
+ * Whether this collection may be added to. Defaults to true.
+ *
+ * Exists for collections where creating a row by hand is not merely
+ * unnecessary but wrong — an audit trail whose entries can be typed in is
+ * not an audit trail, and a derived view (contracts inside a notice window)
+ * has nothing to create because membership is computed.
+ */
+ allowCreate?: boolean;
+ /**
+ * Optional scope applied to the resolved rows.
+ *
+ * The panel runs its own query, so a consumer holding a scope (a saved
+ * view) otherwise has no way to narrow this list and the two disagree on
+ * screen. Omitting it keeps every row.
+ */
+ rowFilter?: (card: CardDef) => boolean;
+ /**
+ * Optional filter composed into the panel's ONE query — so unlike
+ * `rowFilter` (client-side, table rows only) it narrows the grid, the
+ * strip AND the table identically, by construction.
+ *
+ * API ADDITION (Sole Vault, 2026-08-31), not upstream yet. Exists because
+ * a consumer scoping by a field value (`{ eq: { listingStatus: 'active' } }`)
+ * had no way to express it that both views would honor: `rowFilter` runs
+ * on resolved instances, and the grid renders prerendered search entries
+ * that never pass through it — the two views would disagree on which rows
+ * exist, which is the exact mirroring bug decision 3 below exists to
+ * prevent. Composed under the same `every` as the type filter. Should be
+ * offered upstream with the next pull.
+ */
+ extraFilter?: Filter;
+ };
+ /**
+ * Optional custom cell renderer, forwarded to `Table`.
+ *
+ * Without it a column marked `custom: true` renders the raw field object —
+ * an AmountWithCurrency prints as "[object Object]" and a boolean as "true".
+ * Callers that pass no block keep the `{{get row column.key}}` fallback.
+ */
+ Blocks: {
+ cell: [CardDef, TableColumn];
+ };
+ Element: HTMLElement;
+}
+
+/**
+ * One catalogue panel: the same set of cards as a tile grid or as a table,
+ * with one search box over both.
+ *
+ * Three decisions worth stating, because each replaces something that was
+ * wrong in the version before it:
+ *
+ * 1. **The type filter is a CodeRef, not `_cardType`.** Matching on the
+ * display name is an exact string match, so it silently drops subclasses —
+ * that is how Incident and ServiceRequest went missing from the ticket
+ * queue. `identifyCard` derives the ref from the class, so a subclass is
+ * included by construction.
+ *
+ * 2. **Grid tiles come from `@context.searchResultsComponent`.** That gets
+ * prerendered fitted HTML and the operator-mode overlay — hover highlight
+ * and click-to-open — with no per-tile wiring. The cost is that a
+ * prerendered tile cannot resolve `linksTo`, which is why every card in
+ * this app carries denormalized name fields.
+ *
+ * 3. **Both views read ONE query object.** The skill's shape for this is a
+ * wire query for the grid and a hand-written client-side mirror for the
+ * table, with a comment explaining that the two must agree. Here they
+ * cannot disagree: `query` is built once and the grid passes it through
+ * `searchEntryWireQueryFromQuery` while the table hands the same thing to
+ * `getCards`. Toggling grid ↔ table changes how rows are drawn and never
+ * which rows are present — which is the rule the mirror exists to keep,
+ * obtained by construction rather than by discipline.
+ */
+// A row cell as display text: rows carry arbitrary values, and only text,
+// numbers and booleans render as themselves.
+function cellText(row: unknown, key: string): string {
+ let value = (row as Record | null)?.[key];
+ if (value == null) return '';
+ return typeof value === 'object' ? '' : String(value);
+}
+
+export class CollectionPanel extends GlimmerComponent {
+ get canAdd(): boolean {
+ return Boolean(this.canCreate && this.cardCrudFunctions?.createCard);
+ }
+
+ @consume(CardCrudFunctionsContextName)
+ declare cardCrudFunctions: CardCrudFunctions | undefined;
+
+ /**
+ * Which view a panel opens in. Defaults to `grid`, unchanged.
+ *
+ * A table is the better default for a register — all six researched CLM
+ * products present their repository as one, and a grid buries the columns a
+ * reader scans by. But this component is shared with apps already published
+ * in the target realm, and changing a default changes THEIR first screen
+ * without anyone asking. So the default stays as it was and a consumer that
+ * wants a table says so: `@defaultView='table'`.
+ */
+ @tracked view: View = this.args.defaultView ?? 'grid';
+ @tracked search = '';
+ @tracked sortKey: string | undefined;
+ @tracked sortDescending = false;
+
+ private tableQuery: ReturnType | undefined;
+
+ constructor(owner: unknown, args: Signature['Args']) {
+ super(owner as never, args as never);
+ // Live: a card created in another tab appears here without a reload, and
+ // nothing has to be appended to a list to make that happen.
+ this.tableQuery = this.args.context?.getCards(
+ this,
+ () => this.query,
+ () => this.args.realms,
+ { isLive: true },
+ );
+ }
+
+ private setSearchNow = debounce((value: string) => {
+ this.search = value;
+ }, 250);
+
+ // Debounce the tracked assignment, not the input's value — the box must feel
+ // immediate; it is the query that waits.
+ setSearch = (value: string) => {
+ this.setSearchNow(value);
+ };
+
+ viewOptions: ViewItem[] = [
+ { id: 'grid', icon: GridIcon },
+ { id: 'strip', icon: StripIcon },
+ { id: 'table', icon: TableIcon },
+ ];
+
+ setView = (view: string) => {
+ this.view = view as View;
+ };
+
+ clear = (_event?: Event) => {
+ // Cancel first: a Clear pressed inside the 250ms window would otherwise
+ // let the trailing invocation put the term straight back.
+ this.setSearchNow.cancel();
+ this.search = '';
+ };
+
+ willDestroy() {
+ super.willDestroy();
+ this.setSearchNow.cancel();
+ }
+
+ get ref(): CodeRef | undefined {
+ return identifyCard(this.args.cardClass);
+ }
+
+ // No text → a plain type filter. Text → the type AND (full-document
+ // `matches` OR title `contains`). Both halves of the `any` are needed:
+ // `matches` finds a term buried in a description but can miss the title the
+ // reader obviously meant; `contains` on cardTitle guarantees that case.
+ private get filter(): Filter | undefined {
+ let ref = this.ref;
+ if (!ref) {
+ return undefined;
+ }
+ let text = this.search.trim();
+ // `extraFilter` joins the same `every` as the type filter, so every view
+ // reads an identically-scoped query — see the arg's own doc note.
+ let scope: Filter[] = this.args.extraFilter ? [this.args.extraFilter] : [];
+ if (!text) {
+ return scope.length
+ ? { every: [{ type: ref }, ...scope] }
+ : { type: ref };
+ }
+ return {
+ every: [
+ { type: ref },
+ ...scope,
+ { any: [{ matches: text }, { contains: { cardTitle: text } }] },
+ ],
+ };
+ }
+
+ get query(): Query | undefined {
+ let filter = this.filter;
+ let ref = this.ref;
+ // Returning undefined while the ref or the realms are unresolved renders
+ // nothing, rather than querying every realm for every type.
+ if (!filter || !ref || !this.args.realms.length) {
+ return undefined;
+ }
+ // `sort` without `on` returns an EMPTY result rather than an unsorted one.
+ let by = this.args.sortBy ?? 'title';
+ return { filter, sort: [{ by, on: ref, direction: 'asc' }] };
+ }
+
+ get wireQuery(): SearchEntryWireQuery | undefined {
+ let query = this.query;
+ if (!query) {
+ return undefined;
+ }
+ return {
+ ...searchEntryWireQueryFromQuery(query),
+ realms: this.args.realms,
+ };
+ }
+
+ get canCreate(): boolean {
+ return this.args.allowCreate !== false;
+ }
+
+ get rows(): CardDef[] {
+ // Filter the dead slots: a deleted target leaves its slot in place, so a
+ // raw iteration renders an empty row and a raw `.length` counts a card
+ // that no longer exists.
+ let instances = ((this.tableQuery?.instances ?? []) as CardDef[]).filter(
+ Boolean,
+ );
+ if (this.args.rowFilter) {
+ instances = instances.filter(this.args.rowFilter);
+ }
+ let key = this.sortKey;
+ if (!key) {
+ return instances;
+ }
+ let direction = this.sortDescending ? -1 : 1;
+ return [...instances].sort((a, b) => {
+ let left = String((a as any)[key!] ?? '');
+ let right = String((b as any)[key!] ?? '');
+ return left.localeCompare(right) * direction;
+ });
+ }
+
+ get isLoading(): boolean {
+ return Boolean(this.tableQuery?.isLoading);
+ }
+
+ // Same rule as the queue: a live query reports isLoading on every
+ // revalidation, and any save in the realm triggers one. Dim the rows,
+ // never replace them.
+ get isRefreshing(): boolean {
+ return this.isLoading && this.rows.length > 0;
+ }
+
+ /**
+ * A failed query is not an empty collection.
+ *
+ * Without this branch a realm that could not be reached rendered
+ * "No commitments yet" — the panel reported a fact about the data when what
+ * had actually happened was a failure, and an administrator would go and
+ * create a policy that already existed.
+ */
+ /**
+ * A rejected write is reported here too, not only a failing query.
+ *
+ * `create` used to await with only a `finally`, so a realm that refused the
+ * write stopped the spinner, produced no card, said nothing, and let the
+ * rejection escape unhandled.
+ */
+ @tracked private createProblem: string | undefined;
+
+ get problem(): string | undefined {
+ if (this.createProblem) {
+ return this.createProblem;
+ }
+ let errors = (this.tableQuery as any)?.errors as
+ | { message?: string }[]
+ | undefined;
+ if (!errors?.length) {
+ return undefined;
+ }
+ return errors[0]?.message ?? 'The query could not run.';
+ }
+
+ get emptyMessage(): string {
+ return this.search.trim()
+ ? `Nothing in ${this.args.label} matches “${this.search.trim()}”.`
+ : `No ${this.args.label.toLowerCase()} yet.`;
+ }
+
+ sortBy = (key: string) => {
+ if (this.sortKey === key) {
+ this.sortDescending = !this.sortDescending;
+ return;
+ }
+ this.sortKey = key;
+ this.sortDescending = false;
+ };
+
+ openRow = (row: CardDef) => {
+ if (row?.id) {
+ this.cardCrudFunctions?.viewCard?.(new URL(row.id));
+ }
+ };
+
+ @tracked creating = false;
+
+ /**
+ * Every type the app displays can be created from inside the app.
+ *
+ * Six panels listed queues, categories, agents, contacts, policies and
+ * schedules and offered no way to add one — an administrator had to leave
+ * for the file tree. The panel knows its own type, so the button is the
+ * same one line everywhere.
+ */
+ create = async (_event?: Event) => {
+ let make = this.cardCrudFunctions?.createCard;
+ let ref = this.ref;
+ let realm = this.args.realms[0];
+ if (!make || !ref || !realm) {
+ return;
+ }
+ this.creating = true;
+ this.createProblem = undefined;
+ try {
+ await make(ref, new URL(realm), { realmURL: new URL(realm) });
+ } catch (error: any) {
+ this.createProblem = error?.message ?? String(error);
+ } finally {
+ this.creating = false;
+ }
+ };
+
+ openEntry = (id: string, _event?: Event) => {
+ if (id) {
+ this.cardCrudFunctions?.viewCard?.(new URL(id));
+ }
+ };
+
+
+
+
+
+ {{! The wrapper is not decoration. A `class` passed to BoxelInput lands on
+ its inner , whose parent is BoxelInput's own GRID — so every
+ flex property written for it sat on a grid item and did nothing at
+ all, silently. `.cp-search` has to be the flex child the bar sizes. }}
+
+
+
+ {{#if this.search}}
+
+ {{/if}}
+
+ {{! View selector first, then the add action — §7's order. A `+ Add`
+ sitting before the switcher reads as a fourth view option, which is
+ the confusion the comment below was already fighting. }}
+
+ {{#if this.canAdd}}
+ {{! The one thing this bar is FOR. It was grey-on-grey and sitting
+ flush against the view switcher, so the only action in the
+ toolbar looked like a third view option. Filled, iconed, and
+ separated: "add" is a different kind of verb from "look at
+ differently". }}
+
+ {{/if}}
+
+
+
+ {{#if this.problem}}
+
+ {{@label}} could not load.
+
{{this.problem}}
+
+
+ {{else if (eq this.view 'table')}}
+ {{! The realm's shared Table, the same one order-fulfilment, revenue-os
+ and table-demo use. It was a separate DataTable here because the
+ shared component lived in another realm at the time and could not be
+ imported; now that both sit in this realm they are siblings, so the
+ duplicate has no reason to exist. Sort stays OWNED HERE — passing
+ `onSort` puts the table in controlled mode so it reflects this
+ component's order instead of re-sorting rows that are already sorted. }}
+
+
+ {{else if @context.searchResultsComponent}}
+ {{#let (component @context.searchResultsComponent) as |Search|}}
+ {{! @mode='none' keeps every tile inert. It is not lazy loading —
+ 'hover' would fetch one card per hover, which a catalogue people
+ scan is exactly the wrong place for. }}
+
+
+ {{#each results.entries key='id' as |entry|}}
+
+
+
+ {{else}}
+ {{#if results.isLoading}}
+
Loading…
+ {{else}}
+
{{this.emptyMessage}}
+ {{/if}}
+ {{/each}}
+
+
+ {{/let}}
+
+ {{else}}
+
This view has no card context, so the grid cannot
+ render. The table above works without one.
+ {{/if}}
+
+
+
+
+}
+
+export default CollectionPanel;
diff --git a/components/collection-shell.gts b/components/collection-shell.gts
new file mode 100644
index 00000000..cbc43883
--- /dev/null
+++ b/components/collection-shell.gts
@@ -0,0 +1,552 @@
+import GlimmerComponent from '@glimmer/component';
+import { on } from '@ember/modifier';
+import { fn } from '@ember/helper';
+import { tracked } from '@glimmer/tracking';
+import { debounce } from 'lodash-es';
+import {
+ type Query,
+ type SearchEntryWireQuery,
+ searchEntryWireQueryFromQuery,
+} from '@cardstack/runtime-common';
+import {
+ BoxelInput,
+ Button,
+ ViewSelector,
+} from '@cardstack/boxel-ui/components';
+import PlusIcon from '@cardstack/boxel-icons/plus';
+import ChevronRightIcon from '@cardstack/boxel-icons/chevron-right';
+import LayoutGridIcon from '@cardstack/boxel-icons/layout-grid';
+import LayoutListIcon from '@cardstack/boxel-icons/layout-list';
+import TableIcon from '@cardstack/boxel-icons/table';
+
+import { Table, type TableColumn } from '@cardstack/catalog/components/table';
+
+export interface CollectionBadge {
+ id: string;
+ label: string;
+ count: number;
+ /** Field path + value that this badge filters on (omit for 'all'). */
+ field?: string;
+ value?: string;
+}
+
+export type CollectionView = 'grid' | 'list' | 'table';
+
+interface Signature {
+ Args: {
+ /** Plural noun for the hero count and the empty state, e.g. "learners". */
+ noun: string;
+ /** Singular noun for the Add button, e.g. "Learner". */
+ singular: string;
+ /** The type codeRef this collection is made of. */
+ cardTypeRef?: { module: string; name: string };
+ /** Resolved instances (the live query's `.instances`) — feeds counts and the table. */
+ items: any[];
+ realms: string[];
+ context?: any;
+ badges: CollectionBadge[];
+ columns: TableColumn[];
+ onOpen: (card: any) => void;
+ onAdd?: () => void;
+ /** Extra sort for the prerendered views. */
+ sort?: Query['sort'];
+ /** Slot name for the table's custom cell rendering. */
+ };
+ Blocks: {
+ cell: [any, TableColumn];
+ };
+ Element: HTMLElement;
+}
+
+/**
+ * The academy's collection shell — the boxel-search-with-filter *concept*
+ * (live query, one toolbar, badges with All first, grid/list/table sharing a
+ * 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 view switcher on the
+ * right. Grid and list are prerendered fitted through the host's search
+ * results component (click-to-open comes with it); the table is the shared
+ * Table block with a chevron cue and viewCard on the row.
+ *
+ * The wire query and the client-side table filter are deliberate mirrors of
+ * each other: toggling views must never change which rows are present.
+ */
+export class CollectionShell extends GlimmerComponent {
+ @tracked search = '';
+ @tracked typed = '';
+ @tracked badge = 'all';
+ @tracked view: CollectionView = 'list';
+
+ private commit = debounce((v: string) => (this.search = v), 250);
+ setSearch = (v: string) => {
+ this.typed = v;
+ this.commit(v);
+ };
+ clearSearch = () => {
+ this.typed = '';
+ this.search = '';
+ };
+ setBadge = (id: string) => (this.badge = id);
+ setView = (id: string) => (this.view = id as CollectionView);
+ isBadge = (id: string) => this.badge === id;
+
+ viewItems = [
+ { id: 'grid', icon: LayoutGridIcon },
+ { id: 'list', icon: LayoutListIcon },
+ { id: 'table', icon: TableIcon },
+ ];
+
+ get activeBadge(): CollectionBadge | undefined {
+ return this.args.badges.find((b) => b.id === this.badge);
+ }
+
+ /** Wire filter: type AND (matches OR title contains) AND badge eq. */
+ get filter(): any {
+ let ref = this.args.cardTypeRef;
+ if (!ref) return undefined;
+ let every: any[] = [{ type: ref }];
+ let q = this.search.trim();
+ if (q)
+ every.push({ any: [{ matches: q }, { contains: { cardTitle: q } }] });
+ let b = this.activeBadge;
+ if (b?.field && b.value != null) every.push({ eq: { [b.field]: b.value } });
+ return every.length === 1 ? every[0] : { every };
+ }
+
+ wireQuery = (format: 'fitted'): SearchEntryWireQuery | undefined => {
+ let filter = this.filter;
+ if (!filter || !this.args.realms?.length) return undefined;
+ let q = searchEntryWireQueryFromQuery({ filter, sort: this.args.sort });
+ return {
+ ...q,
+ realms: this.args.realms,
+ filter: {
+ ...q.filter,
+ eq: { ...q.filter?.eq, htmlQuery: { eq: { format } } },
+ },
+ } as SearchEntryWireQuery;
+ };
+
+ get gridQuery() {
+ return this.wireQuery('fitted');
+ }
+
+ /** Client-side mirror of `filter` for the table and the counts. */
+ get filteredItems(): any[] {
+ let q = this.search.trim().toLowerCase();
+ let b = this.activeBadge;
+ return (this.args.items ?? []).filter((c) => {
+ if (!c) return false;
+ let badgeOk = !b?.field || (c[b.field] ?? '') === b.value;
+ let textOk =
+ !q ||
+ JSON.stringify(c.cardTitle ?? '')
+ .toLowerCase()
+ .includes(q) ||
+ Object.values(c).some(
+ (v) => typeof v === 'string' && v.toLowerCase().includes(q),
+ );
+ return badgeOk && textOk;
+ });
+ }
+
+ get total() {
+ return (this.args.items ?? []).filter(Boolean).length;
+ }
+ get shown() {
+ return this.filteredItems.length;
+ }
+ get isFiltered() {
+ return this.badge !== 'all' || !!this.search.trim();
+ }
+
+ /**
+ * The prerendered entry carries no click behaviour of its own (the base
+ * CardList puts the handler on its
), so the tile does the same:
+ * resolve the entry back to the loaded instance and hand it to @onOpen.
+ */
+ openEntry = (entryId: string, ev?: Event) => {
+ ev?.preventDefault();
+ let id = stripJson(entryId);
+ let card = (this.args.items ?? []).find((c) => c && stripJson(c.id) === id);
+ this.args.onOpen(card ?? new URL(entryId));
+ };
+ onTileKey = (entryId: string, ev: KeyboardEvent) => {
+ if (ev.key === 'Enter' || ev.key === ' ') this.openEntry(entryId, ev);
+ };
+
+ get tableColumns(): TableColumn[] {
+ return [
+ ...this.args.columns,
+ {
+ key: '__open',
+ label: '',
+ width: '2.5rem',
+ custom: true,
+ sortable: false,
+ },
+ ];
+ }
+
+
+
+
+
+ {{/if}}
+ {{/if}}
+
+
+
+}
+
+function eq(a: unknown, b: unknown) {
+ return a === b;
+}
+function stripJson(id?: string) {
+ return (id ?? '').replace(/\.json$/, '');
+}
+function isZero(n?: number) {
+ return !n;
+}
+function concat(...parts: unknown[]) {
+ return parts.map((p) => (p == null ? '' : String(p))).join('');
+}
+function emptyMsg(noun: string, filtered: boolean) {
+ return filtered
+ ? `No ${noun} match this search or filter.`
+ : `No ${noun} yet.`;
+}
+
+export default CollectionShell;
diff --git a/components/collection-toolbar.gts b/components/collection-toolbar.gts
new file mode 100644
index 00000000..e2a1fcef
--- /dev/null
+++ b/components/collection-toolbar.gts
@@ -0,0 +1,241 @@
+import type { TemplateOnlyComponent } from '@ember/component/template-only';
+import { on } from '@ember/modifier';
+import { fn } from '@ember/helper';
+import { eq } from '@cardstack/boxel-ui/helpers';
+import {
+ BoxelInput,
+ BoxelButton,
+ BoxelDropdown,
+ Pill,
+} from '@cardstack/boxel-ui/components';
+import { DropdownArrowFilled } from '@cardstack/boxel-ui/icons';
+import FilterIcon from '@cardstack/boxel-icons/filter';
+
+export interface ToolbarBadge {
+ id: string;
+ label: string;
+ count: number;
+}
+
+interface Signature {
+ Args: {
+ search: string;
+ onSearch: (value: string) => void;
+ placeholder?: string;
+ /** Single visible filter row — omit when the consumer has more than one
+ filter axis and uses the `filters` block (a dropdown) instead.
+ 'all' MUST be index 0 — the caller builds the array in that order. */
+ badges?: ToolbarBadge[];
+ activeBadge?: string;
+ onBadge?: (id: string) => void;
+ /** How many filter values differ from their defaults — shown as a count
+ on the Filters button so a collapsed filter set stays legible. */
+ activeFilterCount?: number;
+ };
+ Blocks: {
+ /** More than one filter axis (Assets: status AND criticality) — rendered
+ inside one "Filters" dropdown instead of stacking a badge row per
+ axis. Mutually exclusive with `@badges` in practice, not enforced. */
+ filters: [{ close: () => void }];
+ /** Right group: view switcher, then the `+ Add ` action. */
+ right: [];
+ };
+ Element: HTMLElement;
+}
+
+/**
+ * The one collection shell (boxel-search-with-filter §7), built ONCE and
+ * reused on every collection tab: search (with the icon, un-inverted) +
+ * either an inline badge row (one filter axis) or a single "Filters"
+ * dropdown (two or more axes, e.g. Asset status + criticality) pinned left;
+ * the caller's view switcher + add action pinned right. Everything fits on
+ * one line at any width — a second or third always-visible filter row is
+ * exactly the layout this block exists to prevent.
+ *
+ * Wrapping the input in `.search` is deliberate — BoxelInput puts a passed
+ * class on the , whose parent is its own grid, so flex sizing must
+ * land on this wrapper.
+ */
+export const CollectionToolbar: TemplateOnlyComponent =
+
+
+
+
+
+
+}
+
+/** Shows the three collection components over the catalog's Collectible Products. */
+export class CollectionExample extends CardDef {
+ static displayName = 'Collection Example';
+
+ @field title = contains(StringField);
+
+ static isolated = CollectionExampleIsolated;
+}
diff --git a/fields/provenance-vocabulary/Spec/authorship-metadata-field.json b/fields/provenance-vocabulary/Spec/authorship-metadata-field.json
new file mode 100644
index 00000000..5f27e1ae
--- /dev/null
+++ b/fields/provenance-vocabulary/Spec/authorship-metadata-field.json
@@ -0,0 +1,36 @@
+{
+ "data": {
+ "type": "card",
+ "attributes": {
+ "ref": {
+ "name": "AuthorshipMetadataField",
+ "module": "../provenance-vocabulary"
+ },
+ "specType": "field",
+ "readMe": "## Authorship Metadata (AM)\n\nWho made this — the creative credit, not the legal owner. That split is the whole point of having two fields: `PublishingMetadataField` answers *who put it out and on what terms*, and a reader checking whether they may reuse something does not care who drew it.\n\n### Shape\n\n| Field | Why it is shaped this way |\n|---|---|\n| `creators` | **containsMany**, not a single `author`. Most real work has more than one hand on it, and one string forces the first person to stand for all of them. |\n| `organization` | Separate from the people. A studio is not a co-author, and collapsing them loses the distinction a credit line needs. |\n| `tool` | What produced the artifact — a slicer, a camera, a generator. Sits here because it answers \"how was this made\", and because a file whose only authorship fact is `Bambu Studio` still tells a reader something real. |\n| `sourceUrl`, `createdOn`, `note` | Provenance detail. |\n\n### `credit` — one line, always something\n\nComputed. Falls through **creators → organization → tool**, so a credit slot can print it without knowing which facts were extractable. `hasCredit` is the absence signal.\n\n### Why this is new rather than a rename\n\nThese facts are already captured, but **per family and under family names**: a 3D model stores `designer` on `model3d`, a Word document stores its author inside `DocumentInfoField`, an image carries EXIF. Nothing reads \"who made this?\" across families, because there is nowhere to read it from.\n\n### The consolidation trap — read before using\n\n**Adding this field on its own makes things worse, not better.** A document would then carry authorship in two places that can disagree.\n\nThis is only finished when the per-family facts are *projected into* it — the same way every 3D format projects onto one `Model3dMetadataField` instead of each carrying its own mesh counts. Until that projection exists, treat this as **the destination, not the source of truth**.\n\n### Non-goals\n\nNot a permissions model. Not a `Person` card — the creators are names, because the systems these facts come from store names, and forcing a card link would mean inventing a person record for every EXIF string.",
+ "cardInfo": {
+ "name": "Authorship Metadata Field",
+ "summary": "Creative credit — creators, organization, tool — with a computed one-line credit. A destination for facts currently scattered per file family."
+ },
+ "containedExamples": [
+ {
+ "creators": ["Fadhlan Ridhwanallah", "Luke Melia"],
+ "organization": "Cardstack",
+ "tool": "Bambu Studio 1.9",
+ "createdOn": "2026-08-14"
+ },
+ {
+ "tool": "Bambu Studio 1.9"
+ }
+ ],
+ "cardTitle": "Authorship Metadata Field",
+ "cardDescription": "Who made a thing: creators, organisation, tool and date, kept apart from who published it."
+ },
+ "meta": {
+ "adoptsFrom": {
+ "module": "https://cardstack.com/base/spec",
+ "name": "Spec"
+ }
+ }
+ }
+}
diff --git a/fields/provenance-vocabulary/Spec/publishing-metadata-field.json b/fields/provenance-vocabulary/Spec/publishing-metadata-field.json
new file mode 100644
index 00000000..36538fe7
--- /dev/null
+++ b/fields/provenance-vocabulary/Spec/publishing-metadata-field.json
@@ -0,0 +1,37 @@
+{
+ "data": {
+ "type": "card",
+ "attributes": {
+ "ref": {
+ "name": "PublishingMetadataField",
+ "module": "../provenance-vocabulary"
+ },
+ "specType": "field",
+ "readMe": "## Publishing Metadata (PM)\n\nWho put this out, and on what terms.\n\nDeliberately separate from `AuthorshipMetadataField`: the person who made a thing and the entity that licenses it are routinely different, and the two questions have different readers.\n\n### The licence registry is a lookup, never a gate\n\n`license` holds an SPDX identifier where one applies — `CC-BY-4.0`, `MIT`, `Apache-2.0`, `proprietary`. `LICENSES` in `utils/file-metadata` maps the ones worth recognising to a label, a canonical URL, and whether they permit commercial reuse.\n\n**Free text is accepted.** A licence the registry has not heard of is a real licence, not an error, so an unrecognised value stores fine and `licenseName` displays it as itself rather than blank.\n\n### `permitsCommercialUse` returns undefined, not false\n\nThe one fact a reader actually scans for — and the one place a boolean would lie. When the licence is unrecognised the answer is **undefined**, because *\"we do not know\"* and *\"commercial reuse is not permitted\"* are different answers and must not render the same. A consumer that treats undefined as false turns an unknown into a prohibition.\n\n### Fields\n\n`publisher` · `publishedOn` · `license` · `rightsHolder` (copyright holder when that is not the publisher) · `edition` (version as the publisher names it) · `attributionRequired`.\n\nComputed: `licenseName`, `licenseUrl`, `permitsCommercialUse`, `isLicensed`.\n\n### Same consolidation trap as its sibling\n\nOnly `model3d.license` captures this today. Adding a shared field without projecting the per-family facts into it creates two places licence can live. This is the destination, not yet the source of truth.\n\n### Non-goals\n\nNot licence enforcement — it records terms, it does not check compliance. Not a rights-clearance workflow.",
+ "cardInfo": {
+ "name": "Publishing Metadata Field",
+ "summary": "Publisher, licence and rights, with an SPDX lookup that stays a presentation aid — unknown licences store and display, never error."
+ },
+ "containedExamples": [
+ {
+ "publisher": "Cardstack",
+ "publishedOn": "2026-08-14",
+ "license": "MIT",
+ "attributionRequired": false
+ },
+ {
+ "publisher": "Unknown press",
+ "license": "Some-Bespoke-Licence-1.0"
+ }
+ ],
+ "cardTitle": "Publishing Metadata Field",
+ "cardDescription": "Who published a thing and on what terms, kept apart from who made it."
+ },
+ "meta": {
+ "adoptsFrom": {
+ "module": "https://cardstack.com/base/spec",
+ "name": "Spec"
+ }
+ }
+ }
+}
diff --git a/fields/provenance-vocabulary/provenance-vocabulary.gts b/fields/provenance-vocabulary/provenance-vocabulary.gts
new file mode 100644
index 00000000..0078a870
--- /dev/null
+++ b/fields/provenance-vocabulary/provenance-vocabulary.gts
@@ -0,0 +1,240 @@
+import {
+ FieldDef,
+ field,
+ contains,
+ containsMany,
+ StringField,
+} from '@cardstack/base/card-api';
+import DateField from '@cardstack/base/date';
+import UrlField from '@cardstack/base/url';
+import BooleanField from '@cardstack/base/boolean';
+import { Component } from '@cardstack/base/card-api';
+
+import {
+ LICENSES,
+ joinNames,
+ licenseInfo,
+ licenseLabel,
+} from '@cardstack/catalog/utils/file-metadata';
+
+// The shared vocabulary for provenance — who made a thing, and who put it
+// out. Two fields rather than one, because they answer different questions
+// and a consumer usually wants only one of them: a photo credit needs the
+// creator, a compliance check needs the licence.
+//
+// ## Why these are new fields and not a rename of something existing
+//
+// These facts are already being captured, but per family and under family
+// names: a 3D model stores `designer` and `license` on `model3d`, a Word
+// document stores its author inside `DocumentInfoField`, an image stores
+// EXIF. Nothing reads "who made this?" across families, because there is
+// nowhere to read it from.
+//
+// ## The consolidation trap, stated up front
+//
+// Adding a shared field on its own makes things WORSE, not better: a
+// document would then have authorship in two places that can disagree.
+// These are only finished when the per-family facts are projected into them
+// — the same way every 3D format projects onto one `Model3dMetadataField`
+// instead of each carrying its own mesh counts. Until that projection
+// exists, treat these as the destination, not as the source of truth.
+
+// ── Authorship ──────────────────────────────────────────────────────────────
+
+/**
+ * Who made this — the creative credit, not the legal owner.
+ *
+ * `creators` is a list because most real work has more than one hand on it,
+ * and a single `author` string forces the first person to stand for all of
+ * them. `organization` is separate from the people: a studio is not a
+ * co-author, and collapsing them loses the distinction a credit line needs.
+ *
+ * `tool` records what produced the artifact — a slicer, a camera, a
+ * generator. It belongs with authorship rather than publishing because it
+ * answers "how was this made", and because a file whose only authorship fact
+ * is `generator: Bambu Studio` still tells a reader something real.
+ */
+export class AuthorshipMetadataField extends FieldDef {
+ static displayName = 'Authorship Metadata';
+
+ @field creators = containsMany(StringField, {
+ description: 'People credited with making this, in credit order.',
+ });
+ @field organization = contains(StringField, {
+ description: 'The studio, agency or company — not a co-author.',
+ });
+ @field tool = contains(StringField, {
+ description:
+ 'What produced the artifact: a slicer, camera, generator, application.',
+ });
+ @field sourceUrl = contains(UrlField, {
+ description: 'Where this came from, when it came from somewhere.',
+ });
+ @field createdOn = contains(DateField);
+ @field note = contains(StringField);
+
+ // A single line a credit slot can print without knowing the shape. Falls
+ // through creators → organization → tool, so something legible survives
+ // even when only one fact was extractable.
+ @field credit = contains(StringField, {
+ computeVia: function (this: AuthorshipMetadataField) {
+ let people = joinNames(this.creators, 3);
+ if (people && this.organization) {
+ return `${people} · ${this.organization}`;
+ }
+ return people || this.organization || this.tool || '';
+ },
+ });
+
+ @field hasCredit = contains(BooleanField, {
+ computeVia: function (this: AuthorshipMetadataField) {
+ return Boolean(this.credit);
+ },
+ });
+
+ static embedded = class Embedded extends Component {
+
+
+ {{! credit falls through creators -> organization -> tool, so this
+ line prints something legible whichever facts were extractable. }}
+ {{if
+ @model.hasCredit
+ @model.credit
+ 'No credit recorded'
+ }}
+ {{#if @model.tool}}
+ made with {{@model.tool}}
+ {{/if}}
+
+
+
+ };
+}
+
+// ── Publishing ──────────────────────────────────────────────────────────────
+
+/**
+ * Who put this out, and on what terms.
+ *
+ * Deliberately separate from authorship: the person who made a thing and the
+ * entity that licenses it are routinely different, and a reader checking
+ * whether they may reuse something does not care who drew it.
+ *
+ * `license` holds an SPDX identifier where one applies. `LICENSES` is a
+ * presentation lookup, never a validation gate — a licence the registry has
+ * not heard of is a real licence, not an error, so an unrecognised value
+ * stores fine and displays as itself.
+ */
+export class PublishingMetadataField extends FieldDef {
+ static displayName = 'Publishing Metadata';
+
+ @field publisher = contains(StringField);
+ @field publishedOn = contains(DateField);
+ @field license = contains(StringField, {
+ description:
+ 'SPDX identifier where one applies (CC-BY-4.0, MIT, Apache-2.0, proprietary…). Free text is accepted.',
+ });
+ @field rightsHolder = contains(StringField, {
+ description: 'Who holds copyright, when that is not the publisher.',
+ });
+ @field edition = contains(StringField, {
+ description: 'Version, edition or revision as the publisher names it.',
+ });
+ @field attributionRequired = contains(BooleanField);
+
+ @field licenseName = contains(StringField, {
+ computeVia: function (this: PublishingMetadataField) {
+ return licenseLabel(this.license);
+ },
+ });
+
+ @field licenseUrl = contains(UrlField, {
+ computeVia: function (this: PublishingMetadataField) {
+ return licenseInfo(this.license)?.url || undefined;
+ },
+ });
+
+ // The one fact a reader actually scans for. Undefined — not false — when
+ // the licence is unrecognised, because "we do not know" and "commercial
+ // reuse is not permitted" are different answers and must not render the
+ // same.
+ @field permitsCommercialUse = contains(BooleanField, {
+ computeVia: function (this: PublishingMetadataField) {
+ return licenseInfo(this.license)?.permissive;
+ },
+ });
+
+ @field isLicensed = contains(BooleanField, {
+ computeVia: function (this: PublishingMetadataField) {
+ return Boolean(this.license);
+ },
+ });
+
+ static embedded = class Embedded extends Component {
+
+
+
{{/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
> {
@@ -588,7 +588,7 @@ export class SoleVaultPerson extends PersonBase {
// string left is the email, which is already the footer, and "Person" as a
// literal eyebrow is chrome that costs the title a row at the badge quantum.
static fitted: BaseDefComponent = class Fitted extends Component<
- typeof SoleVaultPerson
+ typeof MarketplaceMember
> {
{
@@ -830,4 +830,4 @@ export class SoleVaultPerson extends PersonBase {
};
}
-export default SoleVaultPerson;
+export default MarketplaceMember;
diff --git a/cards/collectibles/sole-vault-offer.gts b/cards/collectibles/marketplace-offer.gts
similarity index 97%
rename from cards/collectibles/sole-vault-offer.gts
rename to cards/collectibles/marketplace-offer.gts
index 9dbe2b7b..387526c4 100644
--- a/cards/collectibles/sole-vault-offer.gts
+++ b/cards/collectibles/marketplace-offer.gts
@@ -17,7 +17,7 @@ import {
nextStatuses,
} from '@cardstack/catalog/fields/status/status';
import { Listing } from './listing';
-import { SoleVaultPerson } from './sole-vault-person';
+import { MarketplaceMember } from './marketplace-member';
import HandshakeIcon from '@cardstack/boxel-icons/handshake';
import TagIcon from '@cardstack/boxel-icons/tag';
import CalendarIcon from '@cardstack/boxel-icons/calendar';
@@ -163,7 +163,7 @@ function computeGap(
: { pct, label: `${Math.abs(pct)}% above asking` };
}
-class OfferEdit extends Component {
+class MarketplaceOfferEdit extends Component {
@tracked threadOpen = true;
@tracked datesOpen = false;
@@ -381,21 +381,21 @@ class OfferEdit extends Component {
}
-export class Offer extends CardDef {
- static displayName = 'Offer';
+export class MarketplaceOffer extends CardDef {
+ static displayName = 'Marketplace Offer';
static icon = HandshakeIcon;
@field listing = linksTo(() => Listing, { searchable: true });
// Who made THIS offer. On a counter this is the seller, which is why the
// field is `offeredBy` rather than `buyer` — the roles swap down the chain.
- @field offeredBy = linksTo(() => SoleVaultPerson, { searchable: true });
+ @field offeredBy = linksTo(() => MarketplaceMember, { searchable: true });
@field amount = contains(AmountWithCurrency);
@field offerStatus = contains(OfferStatusField);
// The negotiation spine. Null = an opening offer.
- @field counterTo = linksTo(() => Offer, { searchable: true });
+ @field counterTo = linksTo(() => MarketplaceOffer, { searchable: true });
@field message = contains(StringField);
@@ -404,7 +404,7 @@ export class Offer extends CardDef {
@field expiresAt = contains(DateField);
@field isOpen = contains(BooleanField, {
- computeVia: function (this: Offer) {
+ computeVia: function (this: MarketplaceOffer) {
return this.offerStatus === 'open';
},
});
@@ -412,20 +412,20 @@ export class Offer extends CardDef {
// A counter is identifiable without loading the chain — cheap, and a tile
// needs it.
@field isCounter = contains(BooleanField, {
- computeVia: function (this: Offer) {
+ computeVia: function (this: MarketplaceOffer) {
return this.counterTo != null;
},
});
// --- denormalized for prerendered fitted (cannot resolve linksTo) ---
@field productTitle = contains(StringField, {
- computeVia: function (this: Offer) {
+ computeVia: function (this: MarketplaceOffer) {
return this.listing?.productTitle ?? this.cardInfo?.name ?? '';
},
});
@field offeredByName = contains(StringField, {
- computeVia: function (this: Offer) {
+ computeVia: function (this: MarketplaceOffer) {
return this.offeredBy?.title ?? '';
},
});
@@ -434,7 +434,7 @@ export class Offer extends CardDef {
// resolving the listing. The COMPARISON is computed at render time — see the
// header note on why a stored discount goes stale.
@field listingPrice = contains(AmountWithCurrency, {
- computeVia: function (this: Offer) {
+ computeVia: function (this: MarketplaceOffer) {
return this.listing?.price;
},
});
@@ -450,7 +450,7 @@ export class Offer extends CardDef {
// counters that answer THIS offer look forwards — which is a REVERSE QUERY
// over Offer itself ({ eq: { 'counterTo.id': id } }), no import cycle since
// the query target is this very class.
- static isolated = class Isolated extends Component {
+ static isolated = class Isolated extends Component {
get realms() {
let realmUrl = this.args.model?.[realmURL];
return realmUrl ? [realmUrl.href] : [];
@@ -459,7 +459,7 @@ export class Offer extends CardDef {
private countersQuery = 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: { 'counterTo.id': id } }] } }
@@ -1034,7 +1034,7 @@ export class Offer extends CardDef {
// card whose EMPTY state carries meaning: blank means an opening offer, set
// means a reply. Someone filling this form without knowing that will either
// orphan a counter or accidentally chain an opening offer to an unrelated one.
- static edit = OfferEdit;
+ static edit = MarketplaceOfferEdit;
// FITTED — FittedCard, same fork and knobs as the family's other supporting
// tiles.
@@ -1048,7 +1048,7 @@ export class Offer extends CardDef {
// prerendered fitted cannot resolve `linksTo`, so reading the listing
// directly would render blank. The comparison itself is still derived at
// render time, never stored, so it cannot go stale when the seller re-prices.
- static fitted = class Fitted extends Component {
+ static fitted = class Fitted extends Component {
get amount() {
return formatMoney(this.args.model?.amount);
}
@@ -1248,7 +1248,7 @@ export class Offer extends CardDef {
};
- static atom = class Atom extends Component {
+ static atom = class Atom extends Component {
get amount() {
return formatMoney(this.args.model?.amount);
}
@@ -1277,7 +1277,7 @@ export class Offer extends CardDef {
};
- 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 ``,
// so this re-declares the hero markup rather than composing it — the CSS
// block is identical to the parent's on purpose (one family, one token
// set), not copied by accident.
- static isolated: BaseDefComponent = class Isolated extends SoleVaultPersonIsolated<
+ static isolated: BaseDefComponent = class Isolated extends MarketplaceMemberIsolated<
typeof SellerProfile
> {
get hasRating() {
@@ -117,7 +118,7 @@ export class SellerProfile extends SoleVaultPerson {
{{! What they are selling — reverse query over Listing.seller,
- inherited from SoleVaultPerson.isolated's own getters. }}
+ inherited from MarketplaceMember.isolated's own getters. }}
Listings
};
- // FITTED — inherited from SoleVaultPerson unchanged: at fitted sizes the
+ // FITTED — inherited from MarketplaceMember unchanged: at fitted sizes the
// seller's name and photo carry the tile; a star rating at badge scale
// would compete with the name for the one row a 150×40 tile has.
- static fitted: BaseDefComponent = SoleVaultPerson.fitted;
+ static fitted: BaseDefComponent = MarketplaceMember.fitted;
}
export default SellerProfile;
diff --git a/cards/collectibles/sole-vault-order.gts b/cards/collectibles/sole-vault-order.gts
deleted file mode 100644
index b1e4b140..00000000
--- a/cards/collectibles/sole-vault-order.gts
+++ /dev/null
@@ -1,1803 +0,0 @@
-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 AmountWithCurrency from '@cardstack/base/amount-with-currency';
-import {
- statusField,
- canTransition,
- nextStatuses,
-} from '@cardstack/catalog/fields/status/status';
-import { Listing } from './listing';
-import { SoleVaultPerson } from './sole-vault-person';
-import { SellerProfile } from './seller-profile';
-import ReceiptIcon from '@cardstack/boxel-icons/receipt';
-import CalendarIcon from '@cardstack/boxel-icons/calendar';
-import CoinsIcon from '@cardstack/boxel-icons/coins';
-import CreditCardIcon from '@cardstack/boxel-icons/credit-card';
-import PackageIcon from '@cardstack/boxel-icons/package';
-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 { 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
-// circular by construction. They are legal only because `identifyCard(...)` is
-// read inside the reverse-query getters below and never at module-evaluation
-// time. Do not lift them into a top-level constant.
-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.
-//
-// Without it there is no checkout, so the marketplace can only be browsed.
-//
-// THE ESCROW GRAPH IS THE POINT. An order is not a record with a status string
-// on it; it is a state machine holding someone's money. `statusField` carries the
-// transition graph so the field can answer "may I?" rather than only "what are
-// the choices?" — see the transitions block below for why each edge exists and,
-// more importantly, why the missing edges are missing.
-//
-// MONEY IS FIVE SEPARATE AMOUNTS, NOT ONE. Item price, shipping, authentication
-// fee and platform fee are each their own `AmountWithCurrency`, and the total is
-// COMPUTED from them rather than stored. A stored total is the classic
-// reconciliation bug: it survives a change to any component and then disagrees
-// with the sum of its own parts, and nothing in the UI reveals which is right.
-//
-// FEES ARE SNAPSHOTS, NOT RATES. `platformFee` holds the money actually charged,
-// not "3%". A rate re-derived at read time silently rewrites the history of every
-// past order the day the rate changes — the buyer's receipt must not move.
-//
-// WHAT THIS BLOCK DOES NOT DO: charge anything. A realm cannot hold funds, so
-// `Payment` is a RECORD of an external processor's state and the commands that
-// move this graph are simulations. That is stated here rather than implied,
-// because a card that looks like it takes money and does not is worse than one
-// that never pretended.
-
-export type OrderStatus =
- | 'pending-payment'
- | 'paid'
- | 'shipped'
- | 'authenticating'
- | 'delivered'
- | 'completed'
- | 'disputed'
- | 'refunded';
-
-export const OrderStatusField = statusField({
- displayName: 'Order Status',
- icon: ReceiptIcon,
- options: [
- {
- value: 'pending-payment',
- label: 'Pending payment',
- hue: 'slate',
- meaning: 'Placed, but the buyer has not paid yet. Nothing is held.',
- },
- {
- value: 'paid',
- label: 'Paid',
- hue: 'blue',
- holds: true,
- meaning:
- 'Funds are held in escrow. The seller owes a shipment; the platform owes a refund if anything fails.',
- },
- {
- value: 'shipped',
- label: 'Shipped',
- hue: 'purple',
- holds: true,
- meaning: 'In transit to the authentication centre, not to the buyer.',
- },
- {
- value: 'authenticating',
- label: 'Authenticating',
- hue: 'amber',
- holds: true,
- meaning:
- 'Received and under inspection. The verdict decides whether this completes or refunds.',
- },
- {
- value: 'delivered',
- label: 'Delivered',
- hue: 'teal',
- holds: true,
- meaning: 'With the buyer, awaiting their confirmation.',
- },
- {
- value: 'completed',
- label: 'Completed',
- hue: 'green',
- terminal: true,
- holds: true,
- meaning: 'Buyer confirmed. Funds released to the seller.',
- },
- {
- value: 'disputed',
- label: 'Disputed',
- hue: 'orange',
- holds: true,
- meaning: 'Someone has raised a problem. Funds stay held until resolved.',
- },
- {
- value: 'refunded',
- label: 'Refunded',
- hue: 'red',
- terminal: true,
- holds: true,
- meaning: 'Money returned to the buyer. The item goes back to the seller.',
- },
- ],
- // The edges that are ABSENT carry as much meaning as the ones present:
- //
- // * Nothing returns to `pending-payment`. Un-charging a card is a refund,
- // which is its own terminal state with its own money movement.
- // * `completed` leads nowhere. Once funds are released the platform no
- // longer holds anything, so there is nothing left for this graph to move;
- // a later problem is a new dispute record, not an edit to this one.
- // * `refunded` leads nowhere for the same reason, in the other direction.
- // * Authentication failure is `authenticating → refunded`, never
- // `authenticating → delivered`. The spec is explicit: a failed legit check
- // refunds the buyer and returns the item, so there must be no path that
- // ships a fake onward.
- // * Every money-holding state can reach `disputed`, because a problem can
- // surface at any point while funds are held.
- transitions: {
- 'pending-payment': ['paid', 'refunded'],
- paid: ['shipped', 'disputed', 'refunded'],
- shipped: ['authenticating', 'disputed', 'refunded'],
- authenticating: ['delivered', 'refunded', 'disputed'],
- delivered: ['completed', 'disputed'],
- disputed: ['completed', 'refunded'],
- completed: [],
- refunded: [],
- },
-});
-
-/** Re-exported so a command can ask permission without importing the field. */
-export function canOrderTransition(from?: string | null, to?: string | null) {
- return canTransition(OrderStatusField, from, to);
-}
-
-/** What an action menu should offer for the current state. */
-export function nextOrderStatuses(from?: string | null) {
- return nextStatuses(OrderStatusField, from);
-}
-
-class OrderEdit extends Component {
- @tracked moneyOpen = true;
- @tracked timelineOpen = false;
- @tracked fulfilmentOpen = false;
-
- toggleMoney = () => (this.moneyOpen = !this.moneyOpen);
- toggleTimeline = () => (this.timelineOpen = !this.timelineOpen);
- toggleFulfilment = () => (this.fulfilmentOpen = !this.fulfilmentOpen);
-
-
-
-
-
-
- <@fields.reference />
-
Issued once, then quoted in emails and disputes —
- changing it breaks every reference to this order.
-
-
- <@fields.orderStatus />
-
Moves along a fixed graph. A failed
- authentication must refund, so there is no path from
- authenticating straight to delivered.
These are the amounts actually charged, not
- rates. The total is computed from them and is not editable — a
- stored total drifts from the sum of its own parts.
These dates drive the escrow rail on the detail
- view, and “funds held” is derived from the status — neither is
- stored separately, so a date left blank shows as a step not yet
- reached.
The summary reference. Per-leg tracking lives
- on the Shipment cards, which is where an exception is actually
- resolved.
-
-
- <@fields.paymentReference />
-
The processor’s own id. This realm records
- it; it never charges anything.
-
-
-
-
-
-
-
-
-
-}
-
-class OrderIsolated extends Component {
- get realms() {
- let realmUrl = this.args.model?.[realmURL];
- return realmUrl ? [realmUrl.href] : [];
- }
-
- // REVERSE QUERIES. `{ eq: { 'order.id': } }` is the shape,
- // and `on: ref` is not optional — without the anchor the field path resolves
- // against CardDef and the request fails with HTTP 500.
- //
- // IMPORT CYCLE, ON PURPOSE: shipment.gts and payment.gts both import THIS
- // module, so importing them back here is a cycle. ES modules tolerate it
- // because `identifyCard(Shipment)` is only ever read inside this getter —
- // never at module-evaluation time. Do not hoist these to a top-level const
- // "for clarity"; that is what breaks it.
- private shipmentsQuery = this.args.context?.getCards(
- this,
- () => {
- let ref = identifyCard(Shipment);
- let id = this.args.model?.id;
- return ref && id
- ? { filter: { on: ref, every: [{ eq: { 'order.id': id } }] } }
- : undefined;
- },
- () => this.realms,
- { isLive: true },
- );
-
- private paymentsQuery = this.args.context?.getCards(
- this,
- () => {
- let ref = identifyCard(Payment);
- let id = this.args.model?.id;
- return ref && id
- ? { filter: { on: ref, every: [{ eq: { 'order.id': id } }] } }
- : undefined;
- },
- () => this.realms,
- { isLive: true },
- );
-
- // `.instances` read at the point of use, dead slots filtered: a deleted
- // target leaves its slot in place, so a raw iteration renders an empty row.
- get shipments() {
- return (this.shipmentsQuery?.instances ?? []).filter(Boolean);
- }
-
- get payments() {
- return (this.paymentsQuery?.instances ?? []).filter(Boolean);
- }
-
- // Absent query (no `context` in some render modes) and unresolved query are
- // different states, and only one of them may paint "none".
- get shipmentsLoading() {
- return Boolean(this.shipmentsQuery) && !this.shipmentsQuery?.instances;
- }
-
- get paymentsLoading() {
- return Boolean(this.paymentsQuery) && !this.paymentsQuery?.instances;
- }
-
- // A failed query must render its message where the section would be, not
- // silently as empty — otherwise an HTTP 500 reads as "no results".
- get shipmentsError() {
- return (this.shipmentsQuery as any)?.error;
- }
-
- get paymentsError() {
- return (this.paymentsQuery as any)?.error;
- }
-
- get total() {
- return formatMoney(this.args.model?.total);
- }
-
- get itemPrice() {
- return formatMoney(this.args.model?.price);
- }
-
- get shipping() {
- return formatMoney(this.args.model?.shippingPrice);
- }
-
- get authFee() {
- return formatMoney(this.args.model?.authFee);
- }
-
- get platformFee() {
- return formatMoney(this.args.model?.platformFee);
- }
-
- // The escrow rail. Each step is `done` from its own event DATE rather than
- // from the status string, so the rail cannot disagree with the timeline —
- // and a refunded order correctly shows the steps it did reach.
- get steps() {
- let m = this.args.model;
- return [
- { key: 'placed', label: 'Placed', done: Boolean(m?.placedAt) },
- { key: 'paid', label: 'Paid into escrow', done: Boolean(m?.paidAt) },
- { key: 'shipped', label: 'Shipped', done: Boolean(m?.shippedAt) },
- {
- key: 'authenticated',
- label: 'Authenticated',
- done: Boolean(m?.authenticatedAt),
- },
- {
- key: 'delivered',
- label: 'Delivered',
- done: Boolean(m?.deliveredAt),
- },
- {
- key: 'completed',
- label: 'Funds released',
- done: Boolean(m?.completedAt),
- },
- ];
- }
-
- // What the graph itself permits from here. Rendered as TEXT for the moves
- // that still have no command (a shipment's own delivery events drive
- // `authenticating`/`delivered`; `completed` and `disputed` have no tool at
- // all) — a control that looks live and no-ops teaches the user to
- // distrust every other control on the page. The three moves that DO have
- // commands render as real buttons below instead of appearing here.
- get allowedNext() {
- // GUARD THE UNSET CASE. `nextStatuses` with no `from` falls back to
- // "everything except the current value" — which, when the status is
- // blank, is all eight states. Listing all eight as "permitted next" is
- // worse than saying nothing, so an order with no status offers none.
- let from = this.args.model?.orderStatus;
- if (!from) {
- return [];
- }
- return nextOrderStatuses(from).filter(
- // These three are the buttons now — repeating them as text would be
- // the fitted-card "one value in two slots" defect wearing a new hat.
- (n) => !['paid', 'shipped', 'refunded'].includes(n.value),
- );
- }
-
- // ---- command wiring ----
- // Buttons render ONLY when a command context exists (interactive render)
- // AND the state machine permits the move — otherwise nothing, per the
- // no-lying-affordances rule. The command modules import this one for the
- // transition guards, so importing them back statically is a cycle that
- // evaluates `extends`-time code — the exact bug seller-profile hit. They
- // are loaded dynamically inside the click instead, where the cycle cannot
- // exist because the click happens after every module has evaluated.
- @tracked private acting: string | undefined;
- @tracked private actionError: string | undefined;
-
- get isBusy() {
- return Boolean(this.acting);
- }
-
- get commandContext() {
- return this.args.context?.commandContext;
- }
-
- get canProcessPayment() {
- return canOrderTransition(this.args.model?.orderStatus, 'paid');
- }
-
- get canDispatch() {
- return canOrderTransition(this.args.model?.orderStatus, 'shipped');
- }
-
- get canRefund() {
- return canOrderTransition(this.args.model?.orderStatus, 'refunded');
- }
-
- private runAction = async (name: string, make: () => Promise) => {
- if (this.acting || !this.commandContext) {
- return;
- }
- this.acting = name;
- this.actionError = undefined;
- try {
- await make();
- } catch (e: any) {
- // Render at the action, not in a toast — the reader must see which
- // move failed and why, next to the button that made it.
- this.actionError = e?.message ?? String(e);
- } finally {
- this.acting = undefined;
- }
- };
-
- processPayment = () =>
- this.runAction('pay', async () => {
- let { default: ProcessPaymentCommand } =
- await import('./commands/process-payment-command.gts');
- await new ProcessPaymentCommand(this.commandContext!).execute({
- orderId: this.args.model?.id,
- } as any);
- });
-
- dispatchShipment = () =>
- this.runAction('ship', async () => {
- let { default: DispatchShipmentCommand } =
- await import('./commands/sole-vault-dispatch-shipment-command.gts');
- await new DispatchShipmentCommand(this.commandContext!).execute({
- orderId: this.args.model?.id,
- } as any);
- });
-
- refundOrder = () =>
- this.runAction('refund', async () => {
- let { default: RefundOrderCommand } =
- await import('./commands/refund-order-command.gts');
- await new RefundOrderCommand(this.commandContext!).execute({
- orderId: this.args.model?.id,
- } as any);
- });
-
-
-
-
-
-
- {{! THE PLAQUE — the total is the dominant element, with the escrow
- state beside it as the thing that qualifies it. Two facts, and the
- money is the loud one. }}
-
-
-
- {{! AT A GLANCE — shape: ol. The escrow rail, the family signature. }}
-
-
Escrow progress
-
- {{#each this.steps as |s|}}
-
- {{s.label}}
-
- {{/each}}
-
- {{! ACTIONS — real commands, state-machine gated, only in an
- interactive render. Each button IS one of the ten spec tools. }}
- {{#if this.commandContext}}
-
- {{/if}}
- {{/if}}
- {{#if this.allowedNext.length}}
- {{! Text, not buttons — these moves have no command of their own;
- see `allowedNext`. }}
-
- Permitted next
- {{! `label` is optional on StatusOption, so fall back to the raw
- value rather than rendering nothing. }}
- {{#each this.allowedNext as |n|}}
- {{if n.label n.label n.value}}
- {{/each}}
-
- {{/if}}
-
-
-
- {{! DETAIL — shape: dl. The receipt. Every amount through the one
- formatter so minor units survive, and the total is visually a step
- above its components rather than a fifth row. }}
-
-
Receipt
-
-
-
Item
-
{{if this.itemPrice this.itemPrice '—'}}
-
-
-
Shipping
-
{{if this.shipping this.shipping '—'}}
-
-
-
Authentication
-
{{if this.authFee this.authFee '—'}}
-
-
-
Platform fee
-
{{if this.platformFee this.platformFee '—'}}
-
-
-
Total
-
{{if this.total this.total '—'}}
-
-
-
Fees are the amounts charged, not rates —
- re-deriving them would rewrite this receipt if the rate ever
- changed.
-
-
- {{! DETAIL — shape: linked cards. A different shape from the dl beside
- it, and the answer to "what happens next". }}
-
-
Shipments{{this.shipments.length}}
-
- {{#if this.shipmentsError}}
-
- Could not load shipments.
-
- {{else if this.shipmentsLoading}}
-
Looking for shipments…
- {{else if this.shipments.length}}
-
- {{! getCards instances are bare card instances with NO
- `.component` property — rendering `s.component` silently
- yields an empty row. getComponent(card) is the API. }}
- {{#each this.shipments as |s|}}
-
{{#let (getComponent s) as |ShipmentCard|}}
-
- {{/let}}
- {{/each}}
-
- {{else}}
-
- {{/if}}
-
-
-
- {{! DETAIL — shape: linked cards again, but this is the money ledger and
- it earns its own full-width row: a partial refund is the case where
- several rows matter at once. }}
-
-
Payments{{this.payments.length}}
-
- {{#if this.paymentsError}}
-
- Could
- not load payments.
-
- {{else if this.paymentsLoading}}
-
Looking for payments…
- {{else if this.payments.length}}
-
- {{#each this.payments as |p|}}
-
{{#let (getComponent p) as |PaymentCard|}}
-
- {{/let}}
- {{/each}}
-
- {{else}}
-
- {{/if}}
-
-
-
-
-
-}
-
-export class Order extends CardDef {
- static displayName = 'Order';
- static icon = ReceiptIcon;
-
- // Human-facing reference, such as 'SV-2024-1234'. A StringField and not
- // a computed one: it is issued once and then quoted in emails, support
- // threads and disputes, so it must never change because a field it derived
- // from changed.
- @field reference = contains(StringField);
-
- @field listing = linksTo(() => Listing, { searchable: true });
- @field buyer = linksTo(() => SoleVaultPerson, { searchable: true });
- // Narrowed to SellerProfile, not the plain SoleVaultPerson buyer uses one
- // line up — a seller carries a rating, a buyer does not.
- @field seller = linksTo(() => SellerProfile, { searchable: true });
-
- // --- money, as four snapshots + one computed total ---
- @field price = contains(AmountWithCurrency);
- @field shippingPrice = contains(AmountWithCurrency);
- @field authFee = contains(AmountWithCurrency);
- @field platformFee = contains(AmountWithCurrency);
-
- @field orderStatus = contains(OrderStatusField);
-
- // --- lifecycle as event facts, same rule as the rest of this family: store
- // the date the thing happened, derive every boolean from it. ---
- @field placedAt = contains(DateField);
- @field paidAt = contains(DateField);
- @field shippedAt = contains(DateField);
- @field authenticatedAt = contains(DateField);
- @field deliveredAt = contains(DateField);
- @field completedAt = contains(DateField);
-
- @field trackingNumber = contains(StringField);
-
- // The processor's own reference. Deliberately a plain string: this realm does
- // not model a card, a token or a balance, and pretending otherwise would
- // invite someone to treat it as authoritative.
- @field paymentReference = contains(StringField);
-
- // TOTAL IS COMPUTED. Currency comes from `price` — mixing currencies inside
- // one order is not a sum, it is a bug, so the code is taken from the item
- // price and the components are added as bare amounts.
- @field total = contains(AmountWithCurrency, {
- computeVia: function (this: Order) {
- let parts = [
- this.price,
- this.shippingPrice,
- this.authFee,
- this.platformFee,
- ];
- let sum = 0;
- let seen = false;
- for (let p of parts) {
- if (p?.amount != null) {
- sum += p.amount;
- seen = true;
- }
- }
- if (!seen) {
- return undefined;
- }
- // A REAL AmountWithCurrency instance, not a `{ amount, currency }`
- // literal. The index's searchable walker calls peekAtField on this
- // value, and a plain object throws "the card Object does not have a
- // field 'amount'" — which error-rows not just this Order but every
- // Payment/Shipment whose searchable `order` link walks into it.
- return new AmountWithCurrency({
- amount: sum,
- currency: this.price?.currency,
- });
- },
- });
-
- // Escrow is holding money in every state the graph marks `holds`, minus the
- // two terminals. Derived, so it cannot disagree with the status.
- @field fundsHeld = contains(BooleanField, {
- computeVia: function (this: Order) {
- let s = this.orderStatus;
- return (
- s === 'paid' ||
- s === 'shipped' ||
- s === 'authenticating' ||
- s === 'delivered' ||
- s === 'disputed'
- );
- },
- });
-
- @field isPaid = contains(BooleanField, {
- computeVia: function (this: Order) {
- return this.paidAt != null;
- },
- });
-
- // --- DENORMALIZED FOR PRERENDERED FITTED ---
- //
- // An Orders tab is a grid, a grid is prerendered fitted, and prerendered
- // fitted CANNOT resolve linksTo. A tile that reads `listing.productTitle`
- // directly renders blank — which is every tile in the tab, since the product
- // name is the tile's headline. These are resolved at INDEX time and stored in
- // the search document, so they are plain attributes by the time fitted runs.
- //
- // Defensive on purpose: every link here is optional and may be unloaded.
- @field productTitle = contains(StringField, {
- computeVia: function (this: Order) {
- return this.listing?.productTitle ?? this.cardInfo?.name ?? '';
- },
- });
-
- @field sellerName = contains(StringField, {
- computeVia: function (this: Order) {
- return this.seller?.title ?? '';
- },
- });
-
- @field buyerName = contains(StringField, {
- computeVia: function (this: Order) {
- return this.buyer?.title ?? '';
- },
- });
-
- @field conditionCode = contains(StringField, {
- computeVia: function (this: Order) {
- return this.listing?.conditionCode ?? '';
- },
- });
-
- // ISOLATED — the order's landing page.
- //
- // DOMAIN QUESTION: "where is my money, and what happens next?" Everyone who
- // opens an order — buyer, seller, support — wants those two things, and
- // neither is a field on this card:
- //
- // * "where is my money" is the escrow state plus the Payment records that
- // link TO this order — a reverse query.
- // * "what happens next" is the shipment legs, also a reverse query, plus
- // the transition graph's own answer for the current state.
- //
- // A version of this card that rendered its own fields tidily and answered
- // neither would pass every technical check and still be useless — which is
- // exactly the failure the domain-question rule exists to catch.
- //
- // Direction: Instrument, the family's operational register. Signature element:
- // the vault plaque (gold hairline over the headline figure) plus the same
- // lifecycle rail CollectionItem's provenance and AuthenticationRecord's
- // progress use. One mark reused across the family, not a new marquee per card.
- // EDIT — 17 editable fields, so grouping is not optional: a flat list in
- // declaration order would bury the reference behind four money fields and six
- // dates, and nothing would signal which of them someone actually touches.
- //
- // The identity row up top carries what is glanced at (reference, status), the
- // parties sit just below, and the money, timeline and fulfilment fields go
- // into independently collapsible sections. `total`, `fundsHeld`, `isPaid` and
- // the four denormalized strings are computed and deliberately absent.
- //
- // HELPER TEXT EARNS ITS PLACE HERE more than on most cards, because two things
- // about this form are genuinely counter-intuitive and both cost money if
- // guessed wrong: the fee fields are recorded amounts rather than rates, and
- // the status is a graph rather than a free dropdown.
- static edit = OrderEdit;
-
- static isolated = OrderIsolated;
-
- // FITTED — hand-rolled, and this is the one card in the family where that
- // fork is right rather than lazy.
- //
- // Order has NO image field, and its anchor is a FIGURE, not a title: the
- // reader scanning an Orders grid is looking for the amount and the state, not
- // the product name. FittedCard's slot model makes the title the loud element,
- // so bending it to put a 2× gold serif number in the footer would fight the
- // component the whole way. The isolated view already established the plaque
- // idiom for exactly this figure, so the tile reuses it.
- //
- // SLOT DISCIPLINE — four distinct facts, four slots, zero repeats:
- // reference (head) · total (anchor) · productTitle (body) · status (meta)
- // Nothing is printed twice, and no slot is padded with a value already shown.
- static fitted = class Fitted extends Component {
- get total() {
- return formatMoney(this.args.model?.total);
- }
-
-
-
- {{! THE ANCHOR — the total, decisively the loudest thing at every one
- of the 16 sizes. Data is all-or-nothing: a money value is never
- ellipsised, so this is nowrap and the whole row is hidden at the
- quanta where it cannot fit. }}
-
-
-
- };
-}
-
-export default Order;
diff --git a/cards/collectibles/sole-vault-payment.gts b/cards/collectibles/sole-vault-payment.gts
deleted file mode 100644
index 1bf3fb42..00000000
--- a/cards/collectibles/sole-vault-payment.gts
+++ /dev/null
@@ -1,1217 +0,0 @@
-import {
- CardDef,
- field,
- contains,
- linksTo,
- StringField,
- Component,
-} from '@cardstack/base/card-api';
-import DateField from '@cardstack/base/date';
-import AmountWithCurrency from '@cardstack/base/amount-with-currency';
-import {
- statusField,
- canTransition,
- nextStatuses,
-} from '@cardstack/catalog/fields/status/status';
-import { Order } from './sole-vault-order';
-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 { 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.
-//
-// 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 is a
-// record of 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, server-side
-// infrastructure a card does not impersonate.
-//
-// WHY IT IS SEPARATE FROM Order. An order has one lifecycle; its money can have
-// several movements — an authorization, a capture, a partial refund, a chargeback
-// reversal. Folding them into Order would force one status 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.
-//
-// THE AMOUNT IS SIGNED BY DIRECTION, NOT BY A NEGATIVE NUMBER. `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 every
-// aggregate then has to remember which convention it is summing.
-
-export type PaymentState =
- | 'pending'
- | 'authorized'
- | 'captured'
- | 'failed'
- | 'refunded';
-
-export type PaymentDirection = 'charge' | 'refund';
-
-export const PaymentStateField = statusField({
- displayName: 'Payment State',
- icon: CreditCardIcon,
- options: [
- {
- value: 'pending',
- label: 'Pending',
- hue: 'slate',
- meaning: 'Submitted to the processor, no answer yet.',
- },
- {
- value: 'authorized',
- label: 'Authorized',
- hue: 'blue',
- holds: true,
- meaning:
- 'The processor is holding the funds. This is the escrow state — money reserved, not yet taken.',
- },
- {
- value: 'captured',
- label: 'Captured',
- hue: 'green',
- terminal: true,
- holds: true,
- meaning: 'Funds actually taken and settled to the platform.',
- },
- {
- value: 'failed',
- label: 'Failed',
- hue: 'red',
- terminal: true,
- meaning: 'Declined or errored. No money moved.',
- },
- {
- value: 'refunded',
- label: 'Refunded',
- hue: 'orange',
- terminal: true,
- holds: true,
- meaning: 'Returned to the payer. Recorded, never edited away.',
- },
- ],
- // A processor's own state machine, mirrored rather than invented:
- // * `pending` can only resolve — it never goes back to itself.
- // * An authorization can be captured, can expire into failure, or can be
- // released as a refund before capture.
- // * `captured → refunded` is the only edge out of a settled payment, and it
- // is one-way. A refund is never "un-refunded"; that is a new charge.
- // * `failed` is terminal because a retry is a NEW payment record. Reusing
- // this one would erase the evidence that the first attempt was declined.
- transitions: {
- pending: ['authorized', 'captured', 'failed'],
- authorized: ['captured', 'failed', 'refunded'],
- captured: ['refunded'],
- failed: [],
- refunded: [],
- },
-});
-
-export function canPaymentTransition(from?: string | null, to?: string | null) {
- return canTransition(PaymentStateField, from, to);
-}
-
-export function nextPaymentStates(from?: string | null) {
- return nextStatuses(PaymentStateField, from);
-}
-
-class PaymentEdit extends Component {
- @tracked refsOpen = true;
- @tracked datesOpen = false;
-
- toggleRefs = () => (this.refsOpen = !this.refsOpen);
- toggleDates = () => (this.datesOpen = !this.datesOpen);
-
-
-
-
-
- <@fields.order />
-
-
-
-
- <@fields.amount />
-
Always positive. Direction decides the sign — a
- negative amount here renders inconsistently across formatters.
-
-
- <@fields.direction />
-
Either
- charge
- or
- refund. A chargeback is a refund with a reason, not a
- third value.
-
-
- <@fields.paymentState />
-
authorized
- is the escrow state: reserved with the processor, not yet taken.
Write
- simulated
- unless a real processor produced this record. Naming the
- simulator in the data is what stops a demo being read as a
- settlement.
-
-
- <@fields.processorReference />
-
The only handle support has when reconciling
- against the processor’s dashboard.
Empty while the payment is only authorized —
- settlement is when money actually moved.
-
-
-
-
-
-
-
-
-
-}
-
-export class Payment extends CardDef {
- static displayName = 'Payment';
- static icon = CreditCardIcon;
-
- @field order = linksTo(() => Order, { searchable: true });
-
- @field amount = contains(AmountWithCurrency);
- @field paymentState = contains(PaymentStateField);
-
- // 'charge' | 'refund'. Plain string with a documented pair rather than an
- // enum field, because the two values never grow — a chargeback is a refund
- // with a different reason, not a third direction.
- @field direction = contains(StringField);
-
- // The processor's reference, such as 'pi_3abc123'. The only
- // handle support has when reconciling against the processor's dashboard, so
- // it is treated as an identifier: never truncated in any format.
- @field processorReference = contains(StringField);
-
- // 'Stripe', 'simulated'. Naming the simulator explicitly in the data is what
- // stops a demo record being mistaken for a real settlement later.
- @field processor = contains(StringField);
-
- @field initiatedAt = contains(DateField);
- @field settledAt = contains(DateField);
-
- // Why a refund happened — 'authentication failed', 'buyer dispute'. Empty on
- // a charge.
- @field reason = contains(StringField);
-
- // --- denormalized for prerendered fitted (cannot resolve linksTo) ---
- @field orderReference = contains(StringField, {
- computeVia: function (this: Payment) {
- return this.order?.reference ?? '';
- },
- });
-
- @field productTitle = contains(StringField, {
- computeVia: function (this: Payment) {
- return this.order?.productTitle ?? this.cardInfo?.name ?? '';
- },
- });
-
- // Signed display string, derived once here so the atom, the row and any future
- // ledger view cannot disagree about how a refund is written.
- @field signedAmount = contains(StringField, {
- computeVia: function (this: Payment) {
- let money = formatMoney(this.amount);
- if (!money) {
- return '';
- }
- return this.direction === 'refund' ? `−${money}` : money;
- },
- });
-
- // ISOLATED — the payment's landing page. Instrument direction: this is a
- // ledger entry someone opens while reconciling, so the layout is the plaque
- // figure plus reference data, with no queries — a payment points AT its
- // order; nothing points at a payment.
- //
- // Domain question: "did money move, how much, which way, and can I find it
- // on the processor's dashboard?" The first three are the hero; the fourth is
- // the reconciliation section, where the processor reference renders mono and
- // whole — it is the only handle support has.
- static isolated = class Isolated extends Component {
-
-
-
-
-
- {{! THE PLAQUE — the signed amount is the dominant element; the
- direction/processor caption sits BELOW the figure, never above it
- as a kicker. A refund's minus sign is carried by `signedAmount`
- itself, derived once for every format. }}
-
- Reference
-
- {{! The only handle support has against the processor's
- dashboard: mono, tabular, never truncated. }}
- {{if
- @model.processorReference
- @model.processorReference
- '—'
- }}
-
Empty settled while only authorized —
- settlement is when money actually moved.
-
-
-
- {{! Shape 3: the receipt stub — a linked card, torn from the top. }}
-
-
Order
- {{#if @model.order}}
-
- <@fields.order @format='embedded' />
-
- {{else}}
-
- {{/if}}
-
-
-
-
-
- };
-
- // EDIT — nine editable fields, so grouped rather than a flat list.
- //
- // The identity row carries the two facts that decide what this record MEANS
- // (direction and state); everything else is reference data and dates.
- //
- // `direction` and `processor` both get helper text because both are free
- // strings whose accepted values are not discoverable from the input, and
- // getting either wrong is the difference between a demo record and something
- // a reader believes settled real money.
- static edit = PaymentEdit;
-
- // FITTED — FittedCard, unlike Order's hand-rolled tile. A payment IS a
- // standard composition (glyph + eyebrow + title + badge + footer), and the
- // family already runs seven of these, so forking an eighth idiom would cost
- // consistency for nothing.
- //
- // SLOT DISCIPLINE — four distinct facts, four slots:
- // direction (eyebrow) · signedAmount (title, the anchor) · state (badge) ·
- // processorReference (footer)
- // `<:subtitle>` and `<:meta>` are NOT rendered: the values left over are the
- // order reference and the product title, both of which belong to the Order
- // tile, not here — a payment row that repeats them reads as a duplicate order.
- static fitted = class Fitted extends Component {
-
-
- {{! Rule 2 anchor: no image field on a payment, so this is the tier-2
- icon — the card's OWN static icon, the same one its atom uses. }}
- <:placeholder>
-
-
-
- {{! The eyebrow carries the PROCESSOR, not the direction.
- Direction was the obvious choice and it is wrong twice over: the
- minus sign in `signedAmount` already states it, and on a settled
- refund an eyebrow reading "REFUND" sits directly above a badge
- reading "Refunded" — two slots saying one thing, which reads as a
- single field wrapped onto two lines.
- The processor is the fact nothing else on the tile carries, and it
- is the one worth surfacing: 'simulated' is how a reader tells a demo
- record from a real settlement. }}
- <:eyebrow>{{@model.processor}}
-
- {{! The amount is the title BECAUSE it is the main field of a payment —
- which keeps FittedCard's own "title is loudest" behaviour pointing at
- the right value instead of fighting it. }}
- <:title>{{if @model.signedAmount @model.signedAmount '—'}}
-
- <:badgeRight>
- {{#if @model.paymentState}}
- <@fields.paymentState @format='atom' />
- {{/if}}
-
-
- <:footer>
- {{! A processor reference is reconciled by hand against a dashboard:
- mono, nowrap, and hidden WHOLE at the narrow quanta rather than
- truncated to an unusable stub. }}
- {{#if @model.processorReference}}
- {{@model.processorReference}}
- {{/if}}
-
-
-
-
-
- };
-
- static atom = class Atom extends Component {
-
-
- {{if @model.signedAmount @model.signedAmount '—'}}
- {{#if @model.paymentState}}
- <@fields.paymentState @format='atom' />
- {{/if}}
-
-
-
- };
-
- static embedded = class Embedded extends Component {
-
-
-
-
- };
-}
-
-export default Payment;
diff --git a/cards/collectibles/sole-vault-shipment.gts b/cards/collectibles/sole-vault-shipment.gts
deleted file mode 100644
index 469f7bf9..00000000
--- a/cards/collectibles/sole-vault-shipment.gts
+++ /dev/null
@@ -1,1301 +0,0 @@
-import {
- CardDef,
- field,
- contains,
- linksTo,
- StringField,
- Component,
-} from '@cardstack/base/card-api';
-import DateField from '@cardstack/base/date';
-import BooleanField from '@cardstack/base/boolean';
-import {
- statusField,
- canTransition,
- nextStatuses,
-} from '@cardstack/catalog/fields/status/status';
-import { Order } from './sole-vault-order';
-import PackageIcon from '@cardstack/boxel-icons/package';
-import RouteIcon from '@cardstack/boxel-icons/route';
-import MapPinIcon from '@cardstack/boxel-icons/map-pin';
-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 { 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.
-//
-// THE TWO-LEG SHAPE IS THE DOMAIN, NOT AN IMPLEMENTATION DETAIL. In an
-// authenticated marketplace the item does NOT go seller → buyer. It goes
-// seller → authentication centre → buyer, and those are two different shipments
-// with two different tracking numbers, two carriers and two failure modes, so
-// `leg` distinguishes them and an Order links to both.
-//
-// Collapsing 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.
-//
-// WHY THERE IS NO CARRIER API HERE. `carrier` and `trackingNumber` are recorded
-// facts, and `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.
-
-export type ShipmentLeg = 'to-authenticator' | 'to-buyer' | 'return-to-seller';
-
-export type ShipmentStatus =
- | 'label-created'
- | 'in-transit'
- | 'delivered'
- | 'exception'
- | 'lost';
-
-export const ShipmentStatusField = statusField({
- displayName: 'Shipment Status',
- icon: PackageIcon,
- options: [
- {
- value: 'label-created',
- label: 'Label created',
- hue: 'slate',
- meaning: 'A label exists; the carrier has not scanned the parcel yet.',
- },
- {
- value: 'in-transit',
- label: 'In transit',
- hue: 'blue',
- holds: true,
- meaning: 'Scanned and moving. Someone else physically holds the item.',
- },
- {
- value: 'delivered',
- label: 'Delivered',
- hue: 'green',
- terminal: true,
- holds: true,
- meaning: 'Arrived and signed for at this leg’s destination.',
- },
- {
- value: 'exception',
- label: 'Exception',
- hue: 'amber',
- holds: true,
- meaning:
- 'The carrier reported a problem — damage, refusal, a bad address. Recoverable.',
- },
- {
- value: 'lost',
- label: 'Lost',
- hue: 'red',
- terminal: true,
- holds: true,
- meaning:
- 'Written off. On a held order this is a refund trigger, not a status tidy-up.',
- },
- ],
- // `exception` is deliberately NOT terminal and NOT a dead end: most carrier
- // exceptions resolve and the parcel moves again, so it returns to
- // `in-transit`. Only `lost` and `delivered` end a leg.
- //
- // There is no edge from `delivered` to anything: a parcel that comes back is
- // the `return-to-seller` leg — a different Shipment card — not this one
- // reversing.
- transitions: {
- 'label-created': ['in-transit', 'exception', 'lost'],
- 'in-transit': ['delivered', 'exception', 'lost'],
- exception: ['in-transit', 'delivered', 'lost'],
- delivered: [],
- lost: [],
- },
-});
-
-export function canShipmentTransition(
- from?: string | null,
- to?: string | null,
-) {
- return canTransition(ShipmentStatusField, from, to);
-}
-
-export function nextShipmentStatuses(from?: string | null) {
- return nextStatuses(ShipmentStatusField, from);
-}
-
-class ShipmentIsolated extends Component {
- // ---- command wiring ----
- // One real action: Mark delivered, which closes a leg and advances the
- // linked Order (leg-aware; see commands/mark-delivered-command.gts).
- // Rendered only when a command context exists AND the state machine
- // permits `→ delivered`, per the no-lying-affordances rule. The command
- // module imports this one for `canShipmentTransition`, so it is loaded
- // dynamically inside the click — a static import back would be a cycle
- // that evaluates `extends`-time code (the seller-profile bug).
- @tracked private acting = false;
- @tracked private actionError: string | undefined;
-
- get showMarkDelivered() {
- return Boolean(this.commandContext) && this.canMarkDelivered;
- }
-
- get commandContext() {
- return this.args.context?.commandContext;
- }
-
- get canMarkDelivered() {
- return canShipmentTransition(this.args.model?.shipmentStatus, 'delivered');
- }
-
- markDelivered = async () => {
- if (this.acting || !this.commandContext) {
- return;
- }
- this.acting = true;
- this.actionError = undefined;
- try {
- let { default: MarkDeliveredCommand } =
- await import('./commands/mark-delivered-command.gts');
- await new MarkDeliveredCommand(this.commandContext).execute({
- shipmentId: this.args.model?.id,
- } as any);
- } catch (e: any) {
- this.actionError = e?.message ?? String(e);
- } finally {
- this.acting = false;
- }
- };
-
-
-
-
- {{! No kicker above the heading — the leg IS the heading. }}
-
- {{/if}}
-
-
- {{! THE MANIFEST — status, carrier, tracking and ETA together, as one
- stamped plaque rather than scattered field rows. Real gold surface,
- not a hairline accent. }}
-
-
No tracking link recorded — this realm does not
- poll carriers; a command records what a webhook reported.
- {{/if}}
-
-
- {{! THE SIGNATURE ELEMENT — a real tracking rail, each stop lit from
- its own event date (rendered through the card's own date field, so
- it cannot disagree with the manifest above), dated inline rather
- than a bare checklist. }}
-
-
One of
- to-authenticator,
- to-buyer
- or
- return-to-seller. Each leg is its own card: an item
- goes seller → authenticator → buyer, and those journeys have
- different carriers and different failure modes.
-
-
- <@fields.shipmentStatus />
-
An exception can return to in-transit — most
- resolve. Only delivered and lost end a leg.
Gets read aloud and typed into a carrier’s
- site, so it is never truncated anywhere it renders.
-
-
- <@fields.trackingUrl />
-
A plain string on purpose — carrier URLs
- carry query strings, and a URL field silently drops those,
- taking the whole value out of the search index with it.
Setting this is what marks the leg delivered
- — the boolean is derived, not stored.
-
-
-
-
-
-
-
-
-
-}
-
-export class Shipment extends CardDef {
- static displayName = 'Shipment';
- static icon = PackageIcon;
-
- @field order = linksTo(() => Order, { searchable: true });
-
- // Which leg of the journey. See the header note — this is the field that
- // makes an authenticated marketplace's logistics representable at all.
- @field leg = contains(StringField);
-
- @field shipmentStatus = contains(ShipmentStatusField);
-
- @field carrier = contains(StringField);
-
- // An identifier a human reads aloud and types into a carrier's site: never
- // truncated in any format, in any card that renders it.
- @field trackingNumber = contains(StringField);
-
- // A real link the user clicks. Kept as a StringField rather than base `url`
- // deliberately: 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 local addendum, not a guess.
- @field trackingUrl = contains(StringField);
-
- @field shippedFrom = contains(StringField);
- @field shippedTo = contains(StringField);
-
- @field labelCreatedAt = contains(DateField);
- @field shippedAt = contains(DateField);
- @field deliveredAt = contains(DateField);
- @field estimatedDelivery = contains(DateField);
-
- // Event-fact derivation, same rule as the rest of the family.
- @field isDelivered = contains(BooleanField, {
- computeVia: function (this: Shipment) {
- return this.deliveredAt != null;
- },
- });
-
- // Needs attention = a carrier problem on a parcel nobody has yet resolved.
- // A queue view filters on this, so it is worth deriving once here rather than
- // re-deriving the same boolean in every consumer.
- @field needsAttention = contains(BooleanField, {
- computeVia: function (this: Shipment) {
- return this.shipmentStatus === 'exception';
- },
- });
-
- // --- denormalized for prerendered fitted (cannot resolve linksTo) ---
- @field orderReference = contains(StringField, {
- computeVia: function (this: Shipment) {
- return this.order?.reference ?? '';
- },
- });
-
- @field productTitle = contains(StringField, {
- computeVia: function (this: Shipment) {
- return this.order?.productTitle ?? this.cardInfo?.name ?? '';
- },
- });
-
- // Human label for the leg, derived so no consumer hand-maps the three values
- // and drifts from the others.
- @field legLabel = contains(StringField, {
- computeVia: function (this: Shipment) {
- switch (this.leg) {
- case 'to-authenticator':
- return 'To authenticator';
- case 'to-buyer':
- return 'To buyer';
- case 'return-to-seller':
- return 'Return to seller';
- default:
- return '';
- }
- },
- });
-
- // ISOLATED — the shipment's landing page. Instrument direction: no image
- // and no money figure, so the anchor is TYPOGRAPHIC and it is the leg, same
- // reasoning as the fitted — which journey this is decides whose problem an
- // exception is.
- //
- // Domain question: "where is the parcel, and on which journey?" The hero
- // answers both (leg + route line); the manifest strip beneath carries
- // status/carrier/tracking, and the SIGNATURE ELEMENT is the manifest rail —
- // a real tracking timeline, each stop lit from its own event DATE so it
- // cannot disagree with the dates list below it, same rule as Order's
- // escrow rail.
- static isolated = ShipmentIsolated;
-
- // EDIT — twelve editable fields, grouped.
- //
- // `leg` is the field this form exists to get right. It is a free string with
- // exactly three accepted values, none of them discoverable from the input, and
- // it is what makes an authenticated marketplace's logistics representable at
- // all — so the accepted literals are spelled out rather than left to a guess
- // and a rejected save.
- static edit = ShipmentEdit;
-
- // FITTED — FittedCard, same fork and knobs as the family's supporting tiles.
- //
- // THE ANCHOR IS TYPOGRAPHIC AND IT IS THE LEG, NOT THE PRODUCT. A shipment has
- // no image and no money figure; what a reader scanning a transit queue needs
- // first is *which journey this is* ("To authenticator" vs "To buyer"), because
- // that is what tells them whose problem an exception is. So the leg takes the
- // title slot at 700 weight and the product name is the quiet eyebrow.
- //
- // SLOT DISCIPLINE — four distinct facts, four slots:
- // productTitle (eyebrow) · legLabel (title, the anchor) · status (badge) ·
- // trackingNumber (footer)
- // `<:subtitle>`/`<:meta>` unrendered: what is left is the carrier and the
- // order reference, and the carrier alone is not worth a row that costs the
- // tracking number its space at the tighter quanta.
- static fitted = class Fitted extends Component {
-
-
- {{! Rule 2 anchor, tier 2: the card's OWN static icon, shared with its
- isolated, embedded and atom formats — the icon is the identity. }}
- <:placeholder>
-
-
-
- <:eyebrow>{{if
- @model.productTitle
- @model.productTitle
- 'Unlinked order'
- }}
-
- <:title>{{if @model.legLabel @model.legLabel 'Shipment'}}
-
- <:badgeRight>
- {{#if @model.shipmentStatus}}
- <@fields.shipmentStatus @format='atom' />
- {{/if}}
-
-
- <:footer>
- {{! A tracking number gets typed into a carrier's site — mono, tabular,
- never ellipsised, hidden WHOLE at the narrow quanta instead. }}
- {{#if @model.trackingNumber}}
- {{@model.trackingNumber}}
- {{/if}}
-
-
-
-
-
- };
-
- static atom = class Atom extends Component {
-
-
-
- {{#if @model.shipmentStatus}}
- <@fields.shipmentStatus @format='atom' />
- {{else}}
- not shipped
- {{/if}}
-
-
-
- };
-
- static embedded = class Embedded extends Component {
-
-