diff --git a/apps/api/src/modules/listings/repositories/listing-repository.js b/apps/api/src/modules/listings/repositories/listing-repository.js index 5299018..5356ee6 100644 --- a/apps/api/src/modules/listings/repositories/listing-repository.js +++ b/apps/api/src/modules/listings/repositories/listing-repository.js @@ -17,6 +17,8 @@ import { marketValueOverrides, marketValuations, products, + sellers, + sellerSettings, } from "../../../infrastructure/database/schema.js"; export function createListingRepository(db) { @@ -220,7 +222,12 @@ export function createListingRepository(db) { async findPublicById(listingId) { const [result] = await db - .select({ listing: listings, condition: conditions }) + .select({ + listing: listings, + condition: conditions, + sellerDisplayName: sellers.displayName, + sellerSettings: sellerSettings.settings, + }) .from(listings) .innerJoin( inventoryItems, @@ -228,6 +235,8 @@ export function createListingRepository(db) { ) .innerJoin(products, eq(products.id, inventoryItems.productId)) .innerJoin(conditions, eq(conditions.id, inventoryItems.conditionId)) + .innerJoin(sellers, eq(sellers.id, products.sellerId)) + .leftJoin(sellerSettings, eq(sellerSettings.sellerId, sellers.id)) .where( and( eq(listings.id, listingId), @@ -263,6 +272,9 @@ export function createListingRepository(db) { marketValueMinor: override?.valueMinor ?? valuation?.valueMinor ?? null, marketValueCurrency: override?.currency ?? valuation?.currency ?? null, marketValueUpdatedAt: override?.createdAt ?? valuation?.calculatedAt ?? null, + sellerDisplayName: result.sellerDisplayName, + sellerContactEmail: result.sellerSettings?.general?.publicContactEmail ?? null, + sellerContactPhone: result.sellerSettings?.general?.publicContactPhone ?? null, }; }, diff --git a/apps/api/src/modules/products/repositories/product-repository.js b/apps/api/src/modules/products/repositories/product-repository.js index e0d2cb7..9bd3729 100644 --- a/apps/api/src/modules/products/repositories/product-repository.js +++ b/apps/api/src/modules/products/repositories/product-repository.js @@ -4,6 +4,8 @@ import { inventoryItems, listings, products, + sellers, + sellerSettings, } from '../../../infrastructure/database/schema.js'; export function createProductRepository(db) { @@ -24,10 +26,16 @@ export function createProductRepository(db) { async findPublicBySlug(slug) { const [result] = await db - .select({ product: products }) + .select({ + product: products, + sellerDisplayName: sellers.displayName, + sellerSettings: sellerSettings.settings, + }) .from(products) .innerJoin(inventoryItems, eq(inventoryItems.productId, products.id)) .innerJoin(listings, eq(listings.inventoryItemId, inventoryItems.id)) + .innerJoin(sellers, eq(sellers.id, products.sellerId)) + .leftJoin(sellerSettings, eq(sellerSettings.sellerId, sellers.id)) .where(and( eq(products.slug, slug), eq(inventoryItems.status, 'ACTIVE'), @@ -35,7 +43,13 @@ export function createProductRepository(db) { )) .limit(1); - return result?.product ?? null; + if (!result) return null; + return { + ...result.product, + sellerDisplayName: result.sellerDisplayName, + sellerContactEmail: result.sellerSettings?.general?.publicContactEmail ?? null, + sellerContactPhone: result.sellerSettings?.general?.publicContactPhone ?? null, + }; }, async findBySellerIdAndSlug(sellerId, slug) { diff --git a/apps/api/tests/http/public-listing-routes.test.js b/apps/api/tests/http/public-listing-routes.test.js index c4f9153..d5492b6 100644 --- a/apps/api/tests/http/public-listing-routes.test.js +++ b/apps/api/tests/http/public-listing-routes.test.js @@ -36,6 +36,9 @@ function createTestApp() { conditionId: 'condition-1', askingPriceMinor: 2499, status: 'PENDING', + sellerDisplayName: 'Pika Collectibles', + sellerContactEmail: 'hello@pika.example', + sellerContactPhone: null, }; }, }, @@ -72,6 +75,8 @@ test('returns a public listing detail or a not-found response', async (testConte assert.equal(detailResponse.statusCode, 200); assert.equal('inventoryItemId' in detailResponse.json().data, false); + assert.equal(detailResponse.json().data.sellerDisplayName, 'Pika Collectibles'); + assert.equal(detailResponse.json().data.sellerContactEmail, 'hello@pika.example'); assert.equal(missingResponse.statusCode, 404); assert.equal(missingResponse.json().error.code, 'LISTING_NOT_FOUND'); }); \ No newline at end of file diff --git a/apps/api/tests/http/public-product-routes.test.js b/apps/api/tests/http/public-product-routes.test.js index ad681a8..5fd8a0e 100644 --- a/apps/api/tests/http/public-product-routes.test.js +++ b/apps/api/tests/http/public-product-routes.test.js @@ -21,6 +21,9 @@ function createTestApp() { name: 'Pikachu Plush', slug, description: 'A friendly plush.', + sellerDisplayName: 'Pika Collectibles', + sellerContactEmail: 'hello@pika.example', + sellerContactPhone: null, createdAt: new Date('2026-09-07T00:00:00.000Z'), updatedAt: new Date('2026-09-07T00:00:00.000Z'), }; @@ -51,6 +54,9 @@ test('returns a public product without private seller identity', async (testCont eventName: null, releaseYear: null, tags: [], + sellerDisplayName: 'Pika Collectibles', + sellerContactEmail: 'hello@pika.example', + sellerContactPhone: null, createdAt: '2026-09-07T00:00:00.000Z', updatedAt: '2026-09-07T00:00:00.000Z', }); diff --git a/apps/web/src/components/organisms/CollectibleDetail.stories.js b/apps/web/src/components/organisms/CollectibleDetail.stories.js index a8586bd..7948a3d 100644 --- a/apps/web/src/components/organisms/CollectibleDetail.stories.js +++ b/apps/web/src/components/organisms/CollectibleDetail.stories.js @@ -24,6 +24,9 @@ export const ListingDetail = { { id: "front", label: "FRONT", alt: "Front of Charizard ex" }, { id: "back", label: "BACK", alt: "Back of Charizard ex" }, ], + sellerDisplayName: "Pika Collectibles", + sellerContactEmail: "hello@pika.example", + sellerContactPhone: "555-0100", }, }; diff --git a/apps/web/src/components/organisms/CollectibleDetail.vue b/apps/web/src/components/organisms/CollectibleDetail.vue index 250b30d..c3c5448 100644 --- a/apps/web/src/components/organisms/CollectibleDetail.vue +++ b/apps/web/src/components/organisms/CollectibleDetail.vue @@ -15,6 +15,9 @@ defineProps({ description: { type: String, default: "" }, facts: { type: Array, default: () => [] }, images: { type: Array, default: () => [] }, + sellerDisplayName: { type: String, default: "" }, + sellerContactEmail: { type: String, default: "" }, + sellerContactPhone: { type: String, default: "" }, }); @@ -47,6 +50,14 @@ defineProps({
{{ fact.value }}
+
+

Sold by

+

{{ sellerDisplayName }}

+ +
@@ -72,6 +83,23 @@ defineProps({ align-items: center; gap: var(--space-4); } +.collectible-detail__seller { + border-top: var(--border-thin) solid var(--color-border); + padding-top: var(--space-4); +} +.collectible-detail__seller-name { + font-weight: var(--weight-bold); +} +.collectible-detail__seller-contacts { + display: grid; + gap: var(--space-1); + margin: var(--space-2) 0 0; + padding: 0; + list-style: none; +} +.collectible-detail__seller-contacts a { + color: var(--color-interactive-text); +} @media (max-width: 768px) { .collectible-detail { grid-template-columns: 1fr; diff --git a/apps/web/src/pages/ListingDetailPage.vue b/apps/web/src/pages/ListingDetailPage.vue index 5e01fd0..c55cd58 100644 --- a/apps/web/src/pages/ListingDetailPage.vue +++ b/apps/web/src/pages/ListingDetailPage.vue @@ -45,6 +45,9 @@ onMounted(async () => { " :description="listing.notes" :images="listing.images" + :seller-display-name="listing.sellerDisplayName" + :seller-contact-email="listing.sellerContactEmail" + :seller-contact-phone="listing.sellerContactPhone" /> diff --git a/apps/web/src/pages/ProductDetailPage.vue b/apps/web/src/pages/ProductDetailPage.vue index 4a2df74..f6c217f 100644 --- a/apps/web/src/pages/ProductDetailPage.vue +++ b/apps/web/src/pages/ProductDetailPage.vue @@ -37,6 +37,9 @@ onMounted(async () => { ...(product.cardNumber ? [{ label: 'Card number', value: product.cardNumber }] : []), ...(product.releaseYear ? [{ label: 'Release year', value: product.releaseYear }] : []), ]" + :seller-display-name="product.sellerDisplayName" + :seller-contact-email="product.sellerContactEmail" + :seller-contact-phone="product.sellerContactPhone" /> \ No newline at end of file diff --git a/docs/IMPLEMENTATION-STATUS.md b/docs/IMPLEMENTATION-STATUS.md index 06e0fb6..19d2317 100644 --- a/docs/IMPLEMENTATION-STATUS.md +++ b/docs/IMPLEMENTATION-STATUS.md @@ -96,6 +96,7 @@ docker context use orbstack - Public product pages are available at `GET /api/v1/public/products/:slug`; responses use a public DTO and require an active physical item with an available or pending listing. - Public listing collection and detail reads are available at `GET /api/v1/public/listings` and `GET /api/v1/public/listings/:id`; collection responses include pagination metadata and detail reads hide internal inventory identity. - Public listing detail responses include safe condition labels, latest market value, and public image URLs; unavailable image states are rendered accessibly in the gallery. +- Public listing and product detail responses and pages now surface the seller's display name and configured public contact methods (email/phone) from seller settings, satisfying the documented public detail requirement. - Public inventory collection and detail reads are available at `GET /api/v1/public/inventory` and `GET /api/v1/public/inventory/:id`; responses join only active physical items to available or pending listings and exclude private item notes. - The public seller directory is available at `GET /api/v1/public/sellers`; it returns only sellers with visible inventory and exposes display identity without user or contact fields. - The web workspace now has a Vue/Vite public catalog shell with Vue Router, a catalog API service, search composable, loading/error/empty states, and responsive search-first styling. diff --git a/packages/contracts/public-listing.js b/packages/contracts/public-listing.js index d894868..d024ae8 100644 --- a/packages/contracts/public-listing.js +++ b/packages/contracts/public-listing.js @@ -17,6 +17,9 @@ export function toPublicListingDto(listing) { status: listing.status, notes: listing.notes ?? null, images: listing.images ?? [], + sellerDisplayName: listing.sellerDisplayName ?? null, + sellerContactEmail: listing.sellerContactEmail ?? null, + sellerContactPhone: listing.sellerContactPhone ?? null, createdAt: toIsoString(listing.createdAt), updatedAt: toIsoString(listing.updatedAt), }; diff --git a/packages/contracts/public-product.js b/packages/contracts/public-product.js index 0724b03..b749c19 100644 --- a/packages/contracts/public-product.js +++ b/packages/contracts/public-product.js @@ -15,6 +15,9 @@ export function toPublicProductDto(product) { eventName: product.eventName ?? null, releaseYear: product.releaseYear ?? null, tags: product.tags ?? [], + sellerDisplayName: product.sellerDisplayName ?? null, + sellerContactEmail: product.sellerContactEmail ?? null, + sellerContactPhone: product.sellerContactPhone ?? null, createdAt: toIsoString(product.createdAt), updatedAt: toIsoString(product.updatedAt), };