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/MarketplaceMember/vaultkeeper.json b/cards/collectibles/MarketplaceMember/vaultkeeper.json new file mode 100644 index 00000000..2a5294c7 --- /dev/null +++ b/cards/collectibles/MarketplaceMember/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": "../marketplace-member", + "name": "MarketplaceMember" + } + } + } +} diff --git a/cards/collectibles/MarketplaceOffer/yeezy-350-zebra-185.json b/cards/collectibles/MarketplaceOffer/yeezy-350-zebra-185.json new file mode 100644 index 00000000..92875040 --- /dev/null +++ b/cards/collectibles/MarketplaceOffer/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 — 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": "../MarketplaceMember/vaultkeeper" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "../marketplace-offer", + "name": "MarketplaceOffer" + } + } + } +} 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/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..0633d0a0 --- /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 a separate, 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/marketplace-member.json b/cards/collectibles/Spec/marketplace-member.json new file mode 100644 index 00000000..ce1071e9 --- /dev/null +++ b/cards/collectibles/Spec/marketplace-member.json @@ -0,0 +1,35 @@ +{ + "data": { + "type": "card", + "attributes": { + "cardTitle": "Marketplace Member", + "cardDescription": "A marketplace person profile: extends PersonBase with isolated, edit and fitted views. Used for a buyer, or anyone not specifically a seller.", + "specType": "card", + "ref": { + "module": "../marketplace-member", + "name": "MarketplaceMember" + }, + "readMe": "### A subclass of PersonBase\n\n`MarketplaceMember` extends the catalog's `PersonBase` and inherits every field. It adds the formats: an isolated landing page, edit and fitted views, and a restyled `embedded` row. `PersonBase` stays as it is.\n\n### Not the seller profile\n\nThis card has no `rating` field; that lives on `SellerProfile`, which extends this card for the seller role. A consumer narrows a person link to `linksTo(MarketplaceMember)` for a buyer or any participant (`CollectionItem.owner`, `MarketplaceOffer.offeredBy`), and to `linksTo(SellerProfile)` only for a seller (`Listing.seller`). 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": "Marketplace Member", + "summary": null, + "notes": null, + "cardThumbnailURL": null + } + }, + "relationships": { + "linkedExamples.0": { + "links": { + "self": "../MarketplaceMember/vaultkeeper" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} diff --git a/cards/collectibles/Spec/marketplace-offer.json b/cards/collectibles/Spec/marketplace-offer.json new file mode 100644 index 00000000..ff64e14c --- /dev/null +++ b/cards/collectibles/Spec/marketplace-offer.json @@ -0,0 +1,32 @@ +{ + "data": { + "type": "card", + "attributes": { + "cardTitle": "Marketplace Offer", + "cardDescription": "A price proposed below asking, and the negotiation chain that follows.", + "specType": "card", + "ref": { + "module": "../marketplace-offer", + "name": "MarketplaceOffer" + }, + "readMe": "## Marketplace Offer\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 in the consuming app. 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": "Marketplace Offer" + } + }, + "relationships": { + "linkedExamples.0": { + "links": { + "self": "../MarketplaceOffer/yeezy-350-zebra-185" + } + } + }, + "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..16b02086 --- /dev/null +++ b/cards/collectibles/Spec/seller-profile.json @@ -0,0 +1,35 @@ +{ + "data": { + "type": "card", + "attributes": { + "cardTitle": "Seller Profile", + "cardDescription": "A marketplace seller: a person's identity plus a numeric rating. Extends MarketplaceMember and inherits its views.", + "specType": "card", + "ref": { + "module": "../seller-profile", + "name": "SellerProfile" + }, + "readMe": "### A subclass of MarketplaceMember\n\nThe reusable part of this card is the schema: person identity plus `rating`. Extending `MarketplaceMember` inherits its isolated, edit and fitted views; a consumer that wants different views overrides the formats like any card override.\n\n### Split from MarketplaceMember on purpose\n\n`rating` has no meaning on a buyer. A consumer narrows a seller link to `linksTo(SellerProfile)` (`Listing.seller`) and every other person link to `linksTo(MarketplaceMember)`. One 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", + "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/acquisition.gts b/cards/collectibles/acquisition.gts new file mode 100644 index 00000000..328e4d1e --- /dev/null +++ b/cards/collectibles/acquisition.gts @@ -0,0 +1,168 @@ +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'; +import { Money } from '@cardstack/catalog/cards/crm/money'; + +// Acquisition — how an item entered someone's possession: what was paid, when, +// from where, and against which reference. +// +// Reused rather than rebuilt: `price` is base `amount-with-currency` +// ({ amount, currency }), not a NumberField plus a separate code string. + +/** + * Format a Date as a calendar day (`YYYY-MM-DD`) using its LOCAL components. + * + * `new Date('9/30/2026').toISOString()` yields the 29th anywhere east of UTC, + * so an acquisition date round-tripped through ISO silently moves back a day. + * An acquisition happened on a calendar day, not at an instant — read + * getFullYear/getMonth/getDate and build the string. + */ +export function toCalendarDay(date: Date | null | undefined): string { + if (!date) { + return ''; + } + let year = date.getFullYear(); + let month = String(date.getMonth() + 1).padStart(2, '0'); + let day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +export class Acquisition extends FieldDef { + static displayName = 'Acquisition'; + static icon = ReceiptIcon; + + @field price = contains(AmountWithCurrency); + @field acquiredOn = contains(DateField); + + // Where it came from: 'Retail', 'StockX', 'Local meetup', 'Estate sale'. + // A fixed-ish list — the consumer supplies enum options if it wants a select. + @field source = contains(StringField); + + // Order number, receipt id, lot number — whatever proves the purchase. + @field reference = contains(StringField); + + @field acquiredOnDay = contains(StringField, { + computeVia: function (this: Acquisition) { + return toCalendarDay(this.acquiredOn); + }, + }); + + static atom = class Atom extends Component { + // Routed through the one formatter, NOT `<@fields.price @format='atom'/>`. + // The field atom does no number formatting at all, so it prints 11.5 for + // 11.50 — a price missing a digit is not a price. + get paid() { + return formatMoney(this.args.model?.price); + } + + + }; + + static embedded = class Embedded extends Component { + get paid() { + return formatMoney(this.args.model?.price); + } + + + }; +} + +export default Acquisition; diff --git a/cards/collectibles/authentication-record.gts b/cards/collectibles/authentication-record.gts new file mode 100644 index 00000000..d9b75912 --- /dev/null +++ b/cards/collectibles/authentication-record.gts @@ -0,0 +1,1150 @@ +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 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 } from '@cardstack/boxel-ui/components'; +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'; + +// 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(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. + @field service = contains(StringField); + + @field outcome = contains(AuthOutcomeField); + + @field submittedAt = contains(DateField); + @field completedAt = contains(DateField); + + @field certificateId = contains(StringField); + @field authenticator = contains(StringField); + @field authenticatorNotes = contains(TextAreaField); + @field submittedPhotos = contains(MultiImageSourceField); + + // Denormalized for fitted, same reason as everywhere else in this family. + @field itemTitle = contains(StringField, { + computeVia: function (this: AuthenticationRecord) { + return this.item?.itemTitle ?? this.cardInfo?.name ?? ''; + }, + }); + + // Turnaround is a real fact a queue view wants, and it is cheap and bounded — + // both dates are on this card, so it is safe as a computed field. Calendar-day + // arithmetic, not instants: both sides are read as local Y/M/D by DateField. + @field turnaroundDays = contains(StringField, { + computeVia: function (this: AuthenticationRecord) { + let a = this.submittedAt; + let b = this.completedAt; + if (!a || !b) { + return ''; + } + let ms = b.getTime() - a.getTime(); + if (ms < 0) { + return ''; + } + let days = Math.round(ms / 86400000); + return days === 1 ? '1 day' : `${days} days`; + }, + }); + + // EDIT — compact identity row (item / order / outcome, Rule 0's "glanced at + // every time" set), everything else grouped into independently collapsible + // sections. Timeline opens by default (the fields most often touched while a + // check is in flight); certificate and evidence start collapsed. + static edit = AuthenticationRecordEdit; + + // ISOLATED — the record's landing page. + // + // DOMAIN QUESTION: "did this pass, and how long did it take?" A dispute or a + // buyer opens this card for the verdict and the paper trail, so the verdict is + // the hero and the certificate id is the thing they copy into another system. + // + // Direction: Instrument (the family's operational register) — hairline rules, + // tabular figures, status as colour. The signature element is the family's + // vault plaque: a gold hairline over the headline value, matching + // CollectionItem's worth figure and the fitted tiles' gold edge. + static isolated = class Isolated extends Component< + typeof AuthenticationRecord + > { + // The check's own lifecycle, drawn from the two dates this card owns. Same + // rail shape as CollectionItem's provenance section — one signature element + // reused across the family, not a new marquee per card. + // + // In review = submitted but not yet concluded. Reading it off the dates + // rather than storing it keeps it from drifting, the same rule the schema + // applies to `verified` / `forSale` on CollectionItem. + get isUnderReview() { + let m = this.args.model; + return Boolean(m?.submittedAt) && !m?.completedAt; + } + + get verdictLabel() { + let outcome = this.args.model?.outcome; + if (outcome === 'passed') { + return 'Verdict — passed'; + } + if (outcome === 'failed') { + return 'Verdict — failed'; + } + return 'Verdict'; + } + + // The plaque's own big label + colour, derived from the SAME option list + // AuthOutcomeField already exports — no second hand-mapped status→colour + // list to drift from it. `passed` gets the family's gold treatment + // (the brand's positive colour, reused everywhere else); `failed` gets + // the rose Payment's refund plaque uses, so "negative" reads the same + // hue across the whole app; `pending` stays a neutral ink panel, since + // an undecided verdict has not earned a colour commitment yet. + get verdictOption() { + return statusOption(AuthOutcomeField, this.args.model?.outcome); + } + + get verdictModifierClass() { + let outcome = this.args.model?.outcome; + if (outcome === 'passed') { + return 'plaque--passed'; + } + if (outcome === 'failed') { + return 'plaque--failed'; + } + return 'plaque--pending'; + } + + get hasPaperTrail() { + let m = this.args.model; + return Boolean(m?.certificateId || m?.authenticator || m?.service); + } + + + }; + + // FITTED — Step 0 fork: FittedCard, not hand-rolled. This is a standard + // composition (image/placeholder + eyebrow + title + badge + footer) and the + // three fitted templates already shipped in this family use it, so forking a + // fourth idiom here would break the family for no gain. + // + // SLOT DISCIPLINE — four distinct facts, four slots, zero repeats. + // `<:subtitle>` and `<:meta>` are deliberately NOT rendered: the only values + // left to put in them are the service (already the eyebrow) and the outcome + // (already the badge), and a slot filled with a value shown elsewhere is the + // documented failure that survives source review because the repeat only + // appears at the quanta where both slots happen to be visible. + static fitted = class Fitted extends Component { + + }; + + static atom = class Atom extends Component { + + }; + + static embedded = class Embedded extends Component< + typeof AuthenticationRecord + > { + + }; +} + +export default AuthenticationRecord; diff --git a/cards/collectibles/collectible-product.gts b/cards/collectibles/collectible-product.gts new file mode 100644 index 00000000..f4666d74 --- /dev/null +++ b/cards/collectibles/collectible-product.gts @@ -0,0 +1,987 @@ +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 } 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'; +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'; +import { Money } from '@cardstack/catalog/cards/crm/money'; + +// CollectibleProduct — a catalogue entry for a manufactured collectible: the +// thing itself, not anyone's copy of it. +// +// NAMED `CollectibleProduct`, NOT `Product`, because `experiments-realm/product.gts` +// already exports a `Product` (an e-commerce demo card with seller/lead-time +// fields). Two `Product`s at one realm root would be a coin-flip for any importer. +// +// The split against CollectionItem is the whole point and is worth stating: this +// card is the CATALOGUE (one row per SKU, shared by everyone), CollectionItem is +// the OWNED COPY (one row per person per pair, with condition and acquisition). +// Retail price lives here because it is a fact about the product; what someone +// paid lives on their CollectionItem because it is a fact about their purchase. + +class CollectibleProductEdit extends Component { + @tracked pricingOpen = true; + @tracked mediaOpen = false; + @tracked descriptionOpen = false; + @tracked specificsOpen = false; + + togglePricing = () => (this.pricingOpen = !this.pricingOpen); + toggleMedia = () => (this.mediaOpen = !this.mediaOpen); + toggleDescription = () => (this.descriptionOpen = !this.descriptionOpen); + toggleSpecifics = () => (this.specificsOpen = !this.specificsOpen); + + +} + +export class CollectibleProduct extends CardDef { + static displayName = 'Collectible Product'; + static icon = PackageIcon; + // Image-led catalogue entry with a wide spec grid — a layout surface for a + // single record, same call as the rest of the family (isolated-card Rule 1b). + + // The definitive identifier — a UPC-like code. Kept as its own field rather + // than folded into the title because it is looked up, read aloud and typed + // into other systems. + // + @field sku = contains(StringField); + + @field colorway = contains(StringField); + + // Taxonomy, deliberately links rather than strings: 'Air Jordan 1 High' and + // 'Nike' are shared vocabulary, so Silhouette and Brand ride on Tag rather + // than being cards of their own. + @field silhouette = linksTo(Tag, { searchable: true }); + @field brand = linksTo(Tag, { searchable: true }); + + @field releaseDate = contains(DateField); + @field retailPrice = contains(AmountWithCurrency); + + @field images = contains(MultiImageSourceField); + @field productDescription = contains(MarkdownField); + + // The manufacturer's size run. containsMany of a plain string because the + // scale is the consumer's — see CollectionItem.variantScale for the same call. + @field availableVariants = containsMany(StringField); + + @field collabPartner = contains(StringField); + @field tags = containsMany(StringField); + + // NO `lowestPrice` AND NO `listingCount`. They are unbounded rollups over + // active listings: a live query belongs in a metrics component the consumer + // composes, never link-arrayed or cached onto the hot catalogue card. A + // consumer reads them with a reverse query on Listing. + + @field displayTitle = contains(StringField, { + computeVia: function (this: CollectibleProduct) { + let base = this.cardInfo?.name ?? ''; + return this.colorway ? `${base} "${this.colorway}"` : base; + }, + }); + + // ISOLATED — the catalogue record's landing page. + // + // DOMAIN QUESTION: "what exactly is this, and what did it retail for?" This + // card is the shared catalogue row (see the header note against + // CollectionItem), so the answer is the product's own facts — never a rollup + // over anyone's listings, which is deliberately absent (see the header note). + // + // Direction: Object (image-led — the photo carries the hero). Signature + // element: the family's gold plaque (same device the app shell uses for its + // collection-value figure), here filled with retail price instead of a + // collector's own valuation, so the catalogue reads as the same app. + static isolated = class Isolated extends Component< + typeof CollectibleProduct + > { + get retail() { + return formatMoney(this.args.model?.retailPrice); + } + + get hasSpecifics() { + let m = this.args.model; + return Boolean( + m?.collabPartner || (m?.availableVariants ?? []).filter(Boolean).length, + ); + } + + + }; + + // EDIT — compact identity row (Rule 0: the fields glanced at every time), + // everything else grouped into independently collapsible sections. Pricing + // opens by default (the field most often changed); media/description/tags + // start collapsed. + static edit = CollectibleProductEdit; + + static atom = class Atom extends Component { + + }; + + static embedded = class Embedded extends Component< + typeof CollectibleProduct + > { + get retail() { + return formatMoney(this.args.model?.retailPrice); + } + + + }; + + static fitted = class Fitted extends Component { + get retail() { + return formatMoney(this.args.model?.retailPrice); + } + + + }; +} + +export default CollectibleProduct; diff --git a/cards/collectibles/collection-item.gts b/cards/collectibles/collection-item.gts new file mode 100644 index 00000000..7842574a --- /dev/null +++ b/cards/collectibles/collection-item.gts @@ -0,0 +1,1627 @@ +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'; +import MultiImageSourceField from '@cardstack/catalog/fields/multi-image-source/multi-image-source'; +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'; +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 { tracked } from '@glimmer/tracking'; +import { FieldContainer, FittedCard } from '@cardstack/boxel-ui/components'; +import { eq } from '@cardstack/boxel-ui/helpers'; +import { formatMoney, formatMoneyDelta } from './money-format'; +import { Accordion } from '@cardstack/pretui/components/accordion'; +import { ProgressBar } from '@cardstack/pretui/components/progress-bar'; +import { StatePill } from '@cardstack/catalog/components/state-pill'; +import { EmptyState } from '@cardstack/pretui/components/empty-state'; +import { COMPACT_EMPTY_STYLE } from '@cardstack/catalog/components/pretui-helpers'; +import { Money } from '@cardstack/catalog/cards/crm/money'; +import { Stat } from '@cardstack/pretui/components/stat'; + +// 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(() => MarketplaceMember, { searchable: true }); + + @field notes = contains(MarkdownField); + + // DENORMALIZED FOR PRERENDERED FITTED — do not "simplify" this to + // `@model.item.title` in a template. + // + // A collection view draws its grid and list from prerendered fitted HTML, and + // prerendered fitted cannot resolve linksTo/linksToMany. A tile that reads the + // linked card directly renders blank — which is every tile in the grid, since + // the thing owned is the tile's headline. A computed field is resolved at INDEX + // time and stored in the search document, so it is a plain attribute by the time + // fitted renders and survives the trip. + // + // Defensive on purpose: `item` may be unlinked (the block is neutral, the link + // is optional) or not yet loaded, so this optional-chains and falls back to the + // card's own title rather than rendering 'undefined'. + @field itemTitle = contains(StringField, { + computeVia: function (this: CollectionItem) { + return this.item?.cardInfo?.name ?? this.cardInfo?.name ?? ''; + }, + }); + + @field variantLabel = contains(StringField, { + computeVia: function (this: CollectionItem) { + if (!this.variant) { + return ''; + } + return this.variantScale + ? `${this.variantScale} ${this.variant}` + : this.variant; + }, + }); + + // EDIT — grouped sections, not the schema wearing input boxes (edit-card + // Rule 0). The fields glanced at every time sit compact up top; everything + // else is an independently collapsible section. `verified`, `forSale`, + // `itemTitle`, `variantLabel` are computed and deliberately absent. + static edit = CollectionItemEdit; + + static atom = class Atom extends Component { + + }; + + // Row-shaped embedded, for lists a consumer builds. + // + // Every trailing data slot is ALWAYS rendered at a constant width, em-dash when + // empty, so a consumer's list column-aligns regardless of which rows happen to + // carry a value or a per-row action. + static embedded = class Embedded extends Component { + + }; + + // THE DOMAIN QUESTION this view answers, above the fold: + // "Is it genuine, and what is it worth against what I paid?" + // + // Not "show me the item". A collector opens an owned item to check its + // authentication state and its position versus cost: the collection IS the + // inventory and the storefront. Both halves of that + // answer are attributes already on the card (`verifiedOn`, `lastKnownValue`, + // `acquisition.price`), so the hero needs no reverse query. + // + // DIRECTION: Object (image-led: the thing itself first), as across the + // collectibles cards. + // 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 collectibles card 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); + } + + // Stat shows its placeholder for an empty value. + get paidAmount(): number | string { + return this.args.model?.acquisition?.price?.amount ?? ''; + } + + get worthAmount(): number | string { + return this.args.model?.lastKnownValue?.amount ?? ''; + } + + get delta() { + return formatMoneyDelta( + this.args.model?.lastKnownValue, + this.args.model?.acquisition?.price, + ); + } + + get retention() { + let v = this.args.model?.condition?.valueRetention; + return v == null ? null : Math.max(0, Math.min(100, v)); + } + + get hasProvenance() { + let m = this.args.model; + return Boolean( + m.acquisition?.acquiredOnDay || m.verifiedOn || m.listedAt, + ); + } + + + }; + + // STEP 0 FORK — FittedCard, not hand-rolled. This reverses the instinct the + // design brief creates ("dark identity-bearing card → hand-roll"), and it is + // reversed on evidence read out of the component, not on taste: + // + // 1. `FittedCard`'s root is `
`, so + // a wrapper class of ours lands on the real root — the dark ground and + // every `--fc-*` override apply without forking anything. + // 2. It declares NO `container-type`/`container-name` — it queries the host's + // `fitted-card` container, which is exactly the rule a hand-rolled + // template has to obey anyway. + // 3. It already carries ~20 debugged `@container fitted-card` rules covering + // the whole aspect-ratio × width × height matrix. Hand-rolling means + // reimplementing the 16-size quanta that already exist and work. + // + // The content here IS the standard composition (image + eyebrow + title + badge + // + footer), which is precisely the case the fork rule sends to FittedCard. The + // identity we need is palette plus the photo carrying real width, and both are + // `--fc-*` knobs. + // + // SLOT DISCIPLINE — six distinct facts, six slots, zero repeats. + // `<:subtitle>` and `<:meta>` are deliberately NOT rendered. They are the two + // slots that would have to be padded with a value already shown elsewhere + // (variant, or the condition code), which is the documented failure mode: one + // nine-card family had a value duplicated across slots in 6 of 9 templates, + // and it survives source review because the repeat only appears at the quanta + // where both slots happen to be visible. A slot with nothing distinct to say is + // deleted, not filled. + // + // ONE BADGE, NOT TWO. `verified` and `forSale` are both badge-worthy, but two + // badges at the 150×40 quantum is two loud things and therefore no anchor. The + // split: `[FOR SALE]` is the tile marker, while `DS ✓Auth` sits inline in the text row — so for-sale takes the + // badge and verification rides as a glyph beside the condition in the footer. + static fitted = class Fitted extends Component { + get worth() { + return formatMoney(this.args.model?.lastKnownValue); + } + + + }; +} + +export default CollectionItem; diff --git a/cards/collectibles/commands/add-to-collection-command.gts b/cards/collectibles/commands/add-to-collection-command.gts new file mode 100644 index 00000000..7b1c2ae7 --- /dev/null +++ b/cards/collectibles/commands/add-to-collection-command.gts @@ -0,0 +1,132 @@ +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'; +import { CollectibleProduct } from '../collectible-product'; +import { MarketplaceMember } from '../marketplace-member'; + +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}`); + } + if (!(product instanceof CollectibleProduct)) { + throw new Error(`${productId} is not a Collectible Product`); + } + let owner = await new GetCardCommand(this.toolContext).execute({ + cardId: ownerId, + }); + if (!owner) { + throw new Error(`Owner not found: ${ownerId}`); + } + if (!(owner instanceof MarketplaceMember)) { + throw new Error(`${ownerId} is not a Marketplace Member`); + } + + let item = new CollectionItem(); + item.item = product; + item.owner = owner; + 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. + 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..660c422c --- /dev/null +++ b/cards/collectibles/commands/authenticate-item-command.gts @@ -0,0 +1,134 @@ +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 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. + 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..67e5f05b --- /dev/null +++ b/cards/collectibles/commands/calculate-completion-command.gts @@ -0,0 +1,101 @@ +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 { linkedId, linkedIds } from '@cardstack/catalog/utils/linked-id'; +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); + @field ownerId = contains(StringField, { + description: + "Counts only this MarketplaceMember's items. Omitted, every CollectionItem counts.", + }); +} + +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 CollectionItems, or one owner’s when ownerId is given, 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 = linkedIds(set, 'products'); + + let itemRef = identifyCard(CollectionItem); + let ownerId = input.ownerId?.trim(); + let items = itemRef + ? await new SearchCardsByQueryCommand(this.toolContext).execute({ + query: { + filter: ownerId + ? { on: itemRef, eq: { 'owner.id': ownerId } } + : { type: itemRef }, + }, + }) + : undefined; + + let ownedProductIds = ( + (items?.instances ?? []) as CollectionItem[] + ).flatMap((i) => { + let id = i ? linkedId(i, 'item') : null; + return id ? [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..d6f9229f --- /dev/null +++ b/cards/collectibles/commands/create-listing-command.gts @@ -0,0 +1,131 @@ +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'; +import { linkedId } from '@cardstack/catalog/utils/linked-id'; + +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}`); + } + let ownerId = linkedId(collectionItem, 'owner'); + if (!ownerId) { + throw new Error( + `CollectionItem ${collectionItemId} has no owner to list as seller`, + ); + } + let owner = await new GetCardCommand(this.toolContext).execute({ + cardId: ownerId, + }); + if (!(owner instanceof SellerProfile)) { + throw new Error( + `CollectionItem ${collectionItemId}'s owner is not a Seller Profile, so it cannot be listed`, + ); + } + let productId = linkedId(collectionItem, 'item'); + let product = productId + ? await new GetCardCommand(this.toolContext).execute({ + cardId: productId, + }) + : undefined; + if (!(product 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 = product; + 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 = owner; + listing.listedAt = now; + + // Save into the SOURCE card's own realm. Without `realm`, SaveCard + // 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, + 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..ff6ae74d --- /dev/null +++ b/cards/collectibles/commands/create-set-command.gts @@ -0,0 +1,107 @@ +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 { COMPLETION_RULES, CompletionSet } from '../completion-set'; +import { CollectibleProduct } from '../collectible-product'; + +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 rule = input.completionRule?.trim() || 'own-any'; + if (!COMPLETION_RULES.some((r) => r.value === rule)) { + throw new Error( + `completionRule must be one of ${COMPLETION_RULES.map((r) => r.value).join(', ')}`, + ); + } + + let products = await Promise.all( + ids.map(async (id) => { + let product = await new GetCardCommand(this.toolContext).execute({ + cardId: id, + }); + if (!product) { + throw new Error(`Product not found: ${id}`); + } + if (!(product instanceof CollectibleProduct)) { + throw new Error(`${id} is not a Collectible Product`); + } + return product; + }), + ); + + let set = new CompletionSet(); + set.cardInfo.name = name; + set.goal = input.goal?.trim() || ''; + set.products = products; + set.completionRule = rule 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. + 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/completion-set.gts b/cards/collectibles/completion-set.gts new file mode 100644 index 00000000..d7a18e3c --- /dev/null +++ b/cards/collectibles/completion-set.gts @@ -0,0 +1,1091 @@ +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, FieldContainer } 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'; +import { Accordion } from '@cardstack/pretui/components/accordion'; +import { ProgressBar } from '@cardstack/pretui/components/progress-bar'; +import { Spinner } from '@cardstack/pretui/components/spinner'; +import { StatePill } from '@cardstack/catalog/components/state-pill'; +import { EmptyState } from '@cardstack/pretui/components/empty-state'; +import { COMPACT_EMPTY_STYLE } from '@cardstack/catalog/components/pretui-helpers'; + +// 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 CollectionItems intersected with this list: +// the isolated view counts every CollectionItem in the set's realm, and +// Calculate Completion counts the same or, given an `ownerId`, one owner's. +// 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: the `item` ids of the CollectionItems in its scope. + * + * Guards the two cases that silently produce a wrong number rather than an error: + * a set with no products (percent must be 0, not NaN from a divide-by-zero), and + * dead link slots (a deleted product leaves an `undefined` in the array while + * `.length` still counts it, so a raw length would inflate the denominator). + */ +export function computeCompletion( + setProductIds: (string | undefined | null)[], + ownedProductIds: Iterable, +): CompletionResult { + let target = setProductIds.filter(Boolean) as string[]; + let owned = new Set(ownedProductIds); + let missing = target.filter((id) => !owned.has(id)); + let ownedCount = target.length - missing.length; + return { + ownedCount, + targetCount: target.length, + percent: + target.length === 0 ? 0 : Math.round((ownedCount / target.length) * 100), + missingProductIds: missing, + }; +} + +class CompletionSetEdit extends Component { + @tracked goalOpen = true; + @tracked membersOpen = true; + + toggleGoal = () => (this.goalOpen = !this.goalOpen); + toggleMembers = () => (this.membersOpen = !this.membersOpen); + + +} + +export class CompletionSet extends CardDef { + static displayName = 'Completion Set'; + static icon = TargetArrowIcon; + + @field goal = contains(MarkdownField); + @field coverImage = contains(ImageSourceField); + + // Curated membership. See the header note — this is deliberately a link. + @field products = linksToMany(() => CollectibleProduct, { searchable: true }); + + @field completionRule = contains(CompletionRuleField); + @field isPublic = contains(BooleanField); + + // The DENOMINATOR is safe to store as a computed field: it depends only on this + // card's own link array, not on any owner's collection. The numerator is not, + // which is exactly why only one of the two lives here. + @field targetCount = contains(NumberField, { + computeVia: function (this: CompletionSet) { + return (this.products ?? []).filter(Boolean).length; + }, + }); + + // ISOLATED — the goal's landing page. + // + // DOMAIN QUESTION: "how close am I, and what is still missing?" Neither half + // is a field on this card, and that is deliberate — see the header note: + // progress is a rollup over the realm's CollectionItems, so storing it here + // would let it drift from the collection it describes. + // + // So the answer is a REVERSE QUERY, which is what an isolated view can do and + // the embedded view cannot. The embedded template's comment says a bar drawn + // without owner context "would be asserting a number this card cannot know" — + // that remains true there. Here the card asks, gets the collection, and + // intersects it through the block's OWN exported `computeCompletion`, so there + // is still exactly one definition of "80% complete" in the realm. + // + // Direction: Instrument, same as the rest of the family. Signature element: + // the vault plaque (gold hairline over the headline figure) plus the shared + // ProgressBar meter the app already uses for this same number. + // EDIT — only five editable fields total, so Rule 0's grouping stays light: + // one compact identity row (name lives on cardInfo, rendered by the default + // schema chrome) plus two sections, both open by default since there is + // nothing here dense enough to want collapsing away. + static edit = CompletionSetEdit; + + static isolated = class Isolated extends Component { + get realms() { + let realmUrl = this.args.model?.[realmURL]; + return realmUrl ? [realmUrl.href] : []; + } + + // The reverse query: every CollectionItem in the realm, intersected against + // this set's curated product links below. + // + // A plain `type` filter, so it needs no `on:` anchor — that requirement + // belongs to eq/contains/range filters, where an unanchored field path + // resolves against CardDef and 500s. Worth stating because the obvious + // "optimisation" here is to push the intersection into the query as + // `eq: { 'item.id': ... }`, and THAT form does need the anchor. + // + // `isLive` so acquiring or selling a pair moves the percentage without a + // reload — the number is only trustworthy if it cannot go stale. + private itemsQuery = this.args.context?.getCards( + this, + () => { + let ref = identifyCard(CollectionItem); + return ref ? { filter: { type: ref } } : undefined; + }, + () => this.realms, + { isLive: true }, + ); + + // Read `.instances` at the point of use and filter dead slots: a deleted + // target leaves its slot in place, so a raw iteration renders an empty row + // and a raw `.length` counts members that no longer exist. + get ownedProductIds(): Set { + let ids = new Set(); + for (let item of (this.itemsQuery?.instances ?? []) as CollectionItem[]) { + let id = item?.item?.id; + if (id) { + ids.add(id); + } + } + return ids; + } + + get products(): CollectibleProduct[] { + return ((this.args.model?.products ?? []) as CollectibleProduct[]).filter( + Boolean, + ); + } + + get result() { + return computeCompletion( + this.products.map((p) => p?.id), + this.ownedProductIds, + ); + } + + get owned(): CollectibleProduct[] { + let ids = this.ownedProductIds; + return this.products.filter((p) => p?.id && ids.has(p.id)); + } + + get missing(): CollectibleProduct[] { + let ids = this.ownedProductIds; + return this.products.filter((p) => !p?.id || !ids.has(p.id)); + } + + // The query is absent when there is no `context` (some render modes have + // none) and empty before it resolves. Those are different states and only + // one of them should paint a "0% complete" claim. + get isLoading() { + return Boolean(this.itemsQuery) && !this.itemsQuery?.instances; + } + + + }; + + // FITTED — FittedCard, same fork and same knobs as the rest of the family. + // + // NO PROGRESS BAR HERE, and this is the important decision rather than an + // omission. Progress needs the owner's collection, which means a query, and a + // prerendered fitted tile can neither run a query nor resolve a link — so any + // percentage drawn here would be an invented number. The isolated view runs + // that query; the tile shows only what this card actually owns. + // + // SLOT DISCIPLINE — three distinct facts, three slots. `<:subtitle>` and + // `<:meta>` are not rendered: the remaining values are the rule (already the + // eyebrow) and the count (already the footer). + static fitted = class Fitted extends Component { + get countLabel() { + let n = this.args.model?.targetCount ?? 0; + return n === 1 ? '1 product' : `${n} products`; + } + + + }; + + static atom = class Atom extends Component { + + }; + + static embedded = class Embedded extends Component { + + }; +} + +export default CompletionSet; diff --git a/cards/collectibles/listing.gts b/cards/collectibles/listing.gts new file mode 100644 index 00000000..a75ae264 --- /dev/null +++ b/cards/collectibles/listing.gts @@ -0,0 +1,1175 @@ +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 { FieldContainer, FittedCard } 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(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 { 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'; +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'; +import { Accordion } from '@cardstack/pretui/components/accordion'; +import { Spinner } from '@cardstack/pretui/components/spinner'; +import { StatePill } from '@cardstack/catalog/components/state-pill'; +import { EmptyState } from '@cardstack/pretui/components/empty-state'; +import { COMPACT_EMPTY_STYLE } from '@cardstack/catalog/components/pretui-helpers'; +import { Money } from '@cardstack/catalog/cards/crm/money'; + +// Listing — one seller offering one owned item for sale. +// +// Not the catalog app's Listing (a catalog entry) and not a Property Listing: +// this is a marketplace offer of one physical item. +// +// EVERY FIELD A TILE NEEDS IS AN ATTRIBUTE. A marketplace's whole UI is grids of +// listings, grids are prerendered fitted, and prerendered fitted cannot resolve +// linksTo — so `productTitle`, `variantLabel` and `conditionCode` are computed +// (resolved at index time, stored in the search doc) rather than read through +// `collectionItem.*` in a template. Without that every tile in the marketplace +// renders blank, which is the single most expensive version of this mistake. + +export type ListingStatus = 'active' | 'sold' | 'cancelled' | 'expired'; + +// A `statusField`, not a bare `enumField`: a dropdown would happily move a +// listing from `sold` back to `active`, or `cancelled` straight to `sold`. The +// catalog's Status block carries the part that matters: +// the transition graph, so the field can answer "may I?" rather than only +// "what are the choices?". It also carries `hue` as data and renders through +// StatePill, which is the self-diluting chip pattern — so status colour stops +// being a class list in each consumer's CSS. +// +// `nextStatuses(ListingStatusField, current)` is what an action menu should +// offer; `canTransition(...)` is what a command should check before writing. +export const ListingStatusField = statusField({ + displayName: 'Listing Status', + options: [ + { + value: 'active', + label: 'Active', + hue: 'green', + meaning: 'Visible to buyers and accepting offers.', + }, + { + value: 'sold', + label: 'Sold', + hue: 'teal', + terminal: true, + holds: true, + meaning: 'A buyer committed. The item is spoken for.', + }, + { + value: 'cancelled', + label: 'Cancelled', + hue: 'slate', + terminal: true, + holds: true, + meaning: 'The seller pulled it. Nothing is owed either way.', + }, + { + value: 'expired', + label: 'Expired', + hue: 'amber', + terminal: true, + holds: true, + meaning: 'Ran past its own expiry date without selling.', + }, + ], + // A sale is not reversible by editing a dropdown — unwinding one is a refund, + // which is a command with money in it, not a status change. So `sold` leads + // nowhere here. `cancelled` and `expired` may be relisted. + transitions: { + active: ['sold', 'cancelled', 'expired'], + cancelled: ['active'], + expired: ['active'], + sold: [], + }, +}); + +class ListingEdit extends Component { + @tracked datesOpen = true; + @tracked fulfilmentOpen = false; + @tracked photosOpen = false; + @tracked notesOpen = false; + + toggleDates = () => (this.datesOpen = !this.datesOpen); + toggleFulfilment = () => (this.fulfilmentOpen = !this.fulfilmentOpen); + togglePhotos = () => (this.photosOpen = !this.photosOpen); + toggleNotes = () => (this.notesOpen = !this.notesOpen); + + +} + +export class Listing extends CardDef { + static displayName = 'Listing'; + static icon = TagIcon; + + // The specific owned copy being sold. + @field collectionItem = linksTo(() => CollectionItem, { searchable: true }); + + // Denormalized link to the catalogue row, so "all listings for this SKU" is one + // query rather than a walk through collectionItem. + @field product = linksTo(() => CollectibleProduct, { searchable: true }); + + @field price = contains(AmountWithCurrency); + @field shippingPrice = contains(AmountWithCurrency); + + @field listingStatus = contains(ListingStatusField); + @field shipsFrom = contains(StringField); + @field photos = contains(MultiImageSourceField); + @field listingNotes = contains(MarkdownField); + + // SellerProfile (a 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 }); + + // 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(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(MarketplaceOffer); + let id = this.args.model?.id; + return ref && id + ? { filter: { on: ref, every: [{ eq: { 'listing.id': id } }] } } + : undefined; + }, + () => this.realms, + { isLive: true }, + ); + + get offers() { + return (this.offersQuery?.instances ?? []).filter(Boolean); + } + + get offersLoading() { + return Boolean(this.offersQuery) && !this.offersQuery?.instances; + } + + get offersError() { + return (this.offersQuery as any)?.error; + } + + get price() { + return formatMoney(this.args.model?.price); + } + + get shipping() { + return formatMoney(this.args.model?.shippingPrice); + } + + + }; + + static atom = class Atom extends Component { + get price() { + return formatMoney(this.args.model?.price); + } + + + }; + + // Row-shaped: the "listings for size 10.5, sorted by price" row of a product + // page. Trailing slots are constant-width with an em-dash + // when empty so a consumer's list column-aligns. + static embedded = class Embedded extends Component { + get price() { + return formatMoney(this.args.model?.price); + } + + get shipping() { + return formatMoney(this.args.model?.shippingPrice); + } + + + }; + + // EDIT — grouped, not the schema in declaration order (edit-card Rule 0). + // What's listed and for how much sits up top; dates, fulfilment, photos and + // notes are collapsible. `productTitle`, `variantLabel`, `conditionCode` and + // `verified` are computed and deliberately absent. + static edit = ListingEdit; + + static fitted = class Fitted extends Component { + get price() { + return formatMoney(this.args.model?.price); + } + + + }; +} + +export default Listing; diff --git a/cards/collectibles/marketplace-member.gts b/cards/collectibles/marketplace-member.gts new file mode 100644 index 00000000..f0f41981 --- /dev/null +++ b/cards/collectibles/marketplace-member.gts @@ -0,0 +1,764 @@ +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 } 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 (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 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. +import type { Listing as ListingType } from './listing'; +import { Spinner } from '@cardstack/pretui/components/spinner'; +import { EmptyState } from '@cardstack/pretui/components/empty-state'; +import { COMPACT_EMPTY_STYLE } from '@cardstack/catalog/components/pretui-helpers'; + +// The isolated landing page, exported so SellerProfile can extend it. +export class MarketplaceMemberIsolated< + T extends typeof MarketplaceMember = typeof MarketplaceMember, +> 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; + } + + +} + +// MarketplaceMember — the collectibles cards' 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 the formats (the isolated landing page, the edit and +// fitted views) live here. +export class MarketplaceMember extends PersonBase { + static displayName = 'Marketplace Member'; + 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 = 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 MarketplaceMember + > { + + }; + + // FITTED — FittedCard, same knobs as the rest of the collectibles cards. + // + // 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 MarketplaceMember + > { + + }; + + // Overrides PersonBase's plain embedded row so it matches the rest of the + // collectibles cards. + static embedded: BaseDefComponent = class Embedded extends Component< + typeof MarketplaceMember + > { + + }; +} + +export default MarketplaceMember; diff --git a/cards/collectibles/marketplace-offer.gts b/cards/collectibles/marketplace-offer.gts new file mode 100644 index 00000000..f4c39fab --- /dev/null +++ b/cards/collectibles/marketplace-offer.gts @@ -0,0 +1,1325 @@ +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 { 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'; +import MessageCircleIcon from '@cardstack/boxel-icons/message-circle'; +import AlertTriangleIcon from '@cardstack/boxel-icons/alert-triangle'; +import { tracked } from '@glimmer/tracking'; +import { FieldContainer, FittedCard } from '@cardstack/boxel-ui/components'; +import { identifyCard } from '@cardstack/runtime-common'; +import { StatePill } from '@cardstack/catalog/components/state-pill'; +import { formatMoney } from './money-format'; +import { Accordion } from '@cardstack/pretui/components/accordion'; +import { Spinner } from '@cardstack/pretui/components/spinner'; +import { EmptyState } from '@cardstack/pretui/components/empty-state'; +import { COMPACT_EMPTY_STYLE } from '@cardstack/catalog/components/pretui-helpers'; +import { Money } from '@cardstack/catalog/cards/crm/money'; + +// 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 MarketplaceOfferEdit extends Component { + @tracked threadOpen = true; + @tracked datesOpen = false; + + toggleThread = () => (this.threadOpen = !this.threadOpen); + toggleDates = () => (this.datesOpen = !this.datesOpen); + + +} + +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(() => MarketplaceMember, { searchable: true }); + + @field amount = contains(AmountWithCurrency); + @field offerStatus = contains(OfferStatusField); + + // The negotiation spine. Null = an opening offer. + @field counterTo = linksTo(() => MarketplaceOffer, { searchable: true }); + + @field message = contains(StringField); + + @field offeredAt = contains(DateField); + @field respondedAt = contains(DateField); + @field expiresAt = contains(DateField); + + @field isOpen = contains(BooleanField, { + computeVia: function (this: MarketplaceOffer) { + return this.offerStatus === 'open'; + }, + }); + + // A counter is identifiable without loading the chain — cheap, and a tile + // needs it. + @field isCounter = contains(BooleanField, { + computeVia: function (this: MarketplaceOffer) { + return this.counterTo != null; + }, + }); + + // --- denormalized for prerendered fitted (cannot resolve linksTo) --- + @field productTitle = contains(StringField, { + computeVia: function (this: MarketplaceOffer) { + return this.listing?.productTitle ?? this.cardInfo?.name ?? ''; + }, + }); + + @field offeredByName = contains(StringField, { + computeVia: function (this: MarketplaceOffer) { + return this.offeredBy?.title ?? ''; + }, + }); + + // The asking price at index time, so a tile can show offer-vs-asking without + // 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: MarketplaceOffer) { + return this.listing?.price; + }, + }); + + // ISOLATED — the offer's landing page. Instrument direction: no image field + // anywhere on an offer, so the plaque figure is the hero. + // + // Domain question: "how much, against what asking price, and is it still + // live?" — all three are the hero (amount plaque + gap annotation + status). + // + // The negotiation THREAD is the section that makes this view worth opening: + // `counterTo` looks backwards (a resolvable link, rendered directly) and the + // 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 { + get realms() { + let realmUrl = this.args.model?.[realmURL]; + return realmUrl ? [realmUrl.href] : []; + } + + private countersQuery = this.args.context?.getCards( + this, + () => { + let ref = identifyCard(MarketplaceOffer); + let id = this.args.model?.id; + return ref && id + ? { filter: { on: ref, every: [{ eq: { 'counterTo.id': id } }] } } + : undefined; + }, + () => this.realms, + { isLive: true }, + ); + + get counters() { + return (this.countersQuery?.instances ?? []).filter(Boolean); + } + + get countersLoading() { + return Boolean(this.countersQuery) && !this.countersQuery?.instances; + } + + get countersError() { + return (this.countersQuery as any)?.error; + } + + get amount() { + return formatMoney(this.args.model?.amount); + } + + get asking() { + return formatMoney(this.args.model?.listingPrice); + } + + // Derived at render time, never stored — a stored discount goes stale the + // moment the seller re-prices. Same math as the fitted and embedded. + get gap() { + return computeGap( + this.args.model?.amount?.amount, + this.args.model?.listingPrice?.amount, + ); + } + + get gapIsBelowAsking() { + return Boolean(this.gap && this.gap.pct > 0); + } + + + }; + + // 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 = MarketplaceOfferEdit; + + // 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`; + } + + + }; + + static atom = class Atom extends Component { + get amount() { + return formatMoney(this.args.model?.amount); + } + + + }; + + 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; + } + + + }; +} + +export default MarketplaceOffer; diff --git a/cards/collectibles/money-format.ts b/cards/collectibles/money-format.ts new file mode 100644 index 00000000..8f2b53b7 --- /dev/null +++ b/cards/collectibles/money-format.ts @@ -0,0 +1,84 @@ +// The single money formatter for the collectibles 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..72496dc7 --- /dev/null +++ b/cards/collectibles/seller-profile.gts @@ -0,0 +1,434 @@ +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 } from '@cardstack/boxel-ui/components'; +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 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 `MarketplaceMember`, not `PersonBase` directly, to inherit its +// isolated/edit/fitted views 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 `MarketplaceMember` itself overrides `PersonBase`'s plain +// `embedded`. +export class SellerProfile extends MarketplaceMember { + 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 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 `