feat: surface seller contact methods on public detail pages
Quality / test (push) Canceled after 0s

This commit is contained in:
2026-09-10 07:16:41 -04:00
parent 54a8d87293
commit e630a89e8d
11 changed files with 84 additions and 3 deletions
@@ -17,6 +17,8 @@ import {
marketValueOverrides, marketValueOverrides,
marketValuations, marketValuations,
products, products,
sellers,
sellerSettings,
} from "../../../infrastructure/database/schema.js"; } from "../../../infrastructure/database/schema.js";
export function createListingRepository(db) { export function createListingRepository(db) {
@@ -220,7 +222,12 @@ export function createListingRepository(db) {
async findPublicById(listingId) { async findPublicById(listingId) {
const [result] = await db const [result] = await db
.select({ listing: listings, condition: conditions }) .select({
listing: listings,
condition: conditions,
sellerDisplayName: sellers.displayName,
sellerSettings: sellerSettings.settings,
})
.from(listings) .from(listings)
.innerJoin( .innerJoin(
inventoryItems, inventoryItems,
@@ -228,6 +235,8 @@ export function createListingRepository(db) {
) )
.innerJoin(products, eq(products.id, inventoryItems.productId)) .innerJoin(products, eq(products.id, inventoryItems.productId))
.innerJoin(conditions, eq(conditions.id, inventoryItems.conditionId)) .innerJoin(conditions, eq(conditions.id, inventoryItems.conditionId))
.innerJoin(sellers, eq(sellers.id, products.sellerId))
.leftJoin(sellerSettings, eq(sellerSettings.sellerId, sellers.id))
.where( .where(
and( and(
eq(listings.id, listingId), eq(listings.id, listingId),
@@ -263,6 +272,9 @@ export function createListingRepository(db) {
marketValueMinor: override?.valueMinor ?? valuation?.valueMinor ?? null, marketValueMinor: override?.valueMinor ?? valuation?.valueMinor ?? null,
marketValueCurrency: override?.currency ?? valuation?.currency ?? null, marketValueCurrency: override?.currency ?? valuation?.currency ?? null,
marketValueUpdatedAt: override?.createdAt ?? valuation?.calculatedAt ?? null, marketValueUpdatedAt: override?.createdAt ?? valuation?.calculatedAt ?? null,
sellerDisplayName: result.sellerDisplayName,
sellerContactEmail: result.sellerSettings?.general?.publicContactEmail ?? null,
sellerContactPhone: result.sellerSettings?.general?.publicContactPhone ?? null,
}; };
}, },
@@ -4,6 +4,8 @@ import {
inventoryItems, inventoryItems,
listings, listings,
products, products,
sellers,
sellerSettings,
} from '../../../infrastructure/database/schema.js'; } from '../../../infrastructure/database/schema.js';
export function createProductRepository(db) { export function createProductRepository(db) {
@@ -24,10 +26,16 @@ export function createProductRepository(db) {
async findPublicBySlug(slug) { async findPublicBySlug(slug) {
const [result] = await db const [result] = await db
.select({ product: products }) .select({
product: products,
sellerDisplayName: sellers.displayName,
sellerSettings: sellerSettings.settings,
})
.from(products) .from(products)
.innerJoin(inventoryItems, eq(inventoryItems.productId, products.id)) .innerJoin(inventoryItems, eq(inventoryItems.productId, products.id))
.innerJoin(listings, eq(listings.inventoryItemId, inventoryItems.id)) .innerJoin(listings, eq(listings.inventoryItemId, inventoryItems.id))
.innerJoin(sellers, eq(sellers.id, products.sellerId))
.leftJoin(sellerSettings, eq(sellerSettings.sellerId, sellers.id))
.where(and( .where(and(
eq(products.slug, slug), eq(products.slug, slug),
eq(inventoryItems.status, 'ACTIVE'), eq(inventoryItems.status, 'ACTIVE'),
@@ -35,7 +43,13 @@ export function createProductRepository(db) {
)) ))
.limit(1); .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) { async findBySellerIdAndSlug(sellerId, slug) {
@@ -36,6 +36,9 @@ function createTestApp() {
conditionId: 'condition-1', conditionId: 'condition-1',
askingPriceMinor: 2499, askingPriceMinor: 2499,
status: 'PENDING', 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(detailResponse.statusCode, 200);
assert.equal('inventoryItemId' in detailResponse.json().data, false); 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.statusCode, 404);
assert.equal(missingResponse.json().error.code, 'LISTING_NOT_FOUND'); assert.equal(missingResponse.json().error.code, 'LISTING_NOT_FOUND');
}); });
@@ -21,6 +21,9 @@ function createTestApp() {
name: 'Pikachu Plush', name: 'Pikachu Plush',
slug, slug,
description: 'A friendly plush.', description: 'A friendly plush.',
sellerDisplayName: 'Pika Collectibles',
sellerContactEmail: 'hello@pika.example',
sellerContactPhone: null,
createdAt: new Date('2026-09-07T00:00:00.000Z'), createdAt: new Date('2026-09-07T00:00:00.000Z'),
updatedAt: 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, eventName: null,
releaseYear: null, releaseYear: null,
tags: [], tags: [],
sellerDisplayName: 'Pika Collectibles',
sellerContactEmail: 'hello@pika.example',
sellerContactPhone: null,
createdAt: '2026-09-07T00:00:00.000Z', createdAt: '2026-09-07T00:00:00.000Z',
updatedAt: '2026-09-07T00:00:00.000Z', updatedAt: '2026-09-07T00:00:00.000Z',
}); });
@@ -24,6 +24,9 @@ export const ListingDetail = {
{ id: "front", label: "FRONT", alt: "Front of Charizard ex" }, { id: "front", label: "FRONT", alt: "Front of Charizard ex" },
{ id: "back", label: "BACK", alt: "Back of Charizard ex" }, { id: "back", label: "BACK", alt: "Back of Charizard ex" },
], ],
sellerDisplayName: "Pika Collectibles",
sellerContactEmail: "hello@pika.example",
sellerContactPhone: "555-0100",
}, },
}; };
@@ -15,6 +15,9 @@ defineProps({
description: { type: String, default: "" }, description: { type: String, default: "" },
facts: { type: Array, default: () => [] }, facts: { type: Array, default: () => [] },
images: { type: Array, default: () => [] }, images: { type: Array, default: () => [] },
sellerDisplayName: { type: String, default: "" },
sellerContactEmail: { type: String, default: "" },
sellerContactPhone: { type: String, default: "" },
}); });
</script> </script>
@@ -47,6 +50,14 @@ defineProps({
<dd>{{ fact.value }}</dd> <dd>{{ fact.value }}</dd>
</div> </div>
</dl> </dl>
<section v-if="sellerDisplayName" class="collectible-detail__seller" aria-labelledby="collectible-detail-seller-title">
<p id="collectible-detail-seller-title" class="catalog-kicker">Sold by</p>
<p class="collectible-detail__seller-name">{{ sellerDisplayName }}</p>
<ul v-if="sellerContactEmail || sellerContactPhone" class="collectible-detail__seller-contacts">
<li v-if="sellerContactEmail"><a :href="`mailto:${sellerContactEmail}`">{{ sellerContactEmail }}</a></li>
<li v-if="sellerContactPhone"><a :href="`tel:${sellerContactPhone}`">{{ sellerContactPhone }}</a></li>
</ul>
</section>
</div> </div>
</article> </article>
</template> </template>
@@ -72,6 +83,23 @@ defineProps({
align-items: center; align-items: center;
gap: var(--space-4); 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) { @media (max-width: 768px) {
.collectible-detail { .collectible-detail {
grid-template-columns: 1fr; grid-template-columns: 1fr;
+3
View File
@@ -45,6 +45,9 @@ onMounted(async () => {
" "
:description="listing.notes" :description="listing.notes"
:images="listing.images" :images="listing.images"
:seller-display-name="listing.sellerDisplayName"
:seller-contact-email="listing.sellerContactEmail"
:seller-contact-phone="listing.sellerContactPhone"
/> />
</main> </main>
</template> </template>
+3
View File
@@ -37,6 +37,9 @@ onMounted(async () => {
...(product.cardNumber ? [{ label: 'Card number', value: product.cardNumber }] : []), ...(product.cardNumber ? [{ label: 'Card number', value: product.cardNumber }] : []),
...(product.releaseYear ? [{ label: 'Release year', value: product.releaseYear }] : []), ...(product.releaseYear ? [{ label: 'Release year', value: product.releaseYear }] : []),
]" ]"
:seller-display-name="product.sellerDisplayName"
:seller-contact-email="product.sellerContactEmail"
:seller-contact-phone="product.sellerContactPhone"
/> />
</main> </main>
</template> </template>
+1
View File
@@ -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 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 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 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. - 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 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. - 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.
+3
View File
@@ -17,6 +17,9 @@ export function toPublicListingDto(listing) {
status: listing.status, status: listing.status,
notes: listing.notes ?? null, notes: listing.notes ?? null,
images: listing.images ?? [], images: listing.images ?? [],
sellerDisplayName: listing.sellerDisplayName ?? null,
sellerContactEmail: listing.sellerContactEmail ?? null,
sellerContactPhone: listing.sellerContactPhone ?? null,
createdAt: toIsoString(listing.createdAt), createdAt: toIsoString(listing.createdAt),
updatedAt: toIsoString(listing.updatedAt), updatedAt: toIsoString(listing.updatedAt),
}; };
+3
View File
@@ -15,6 +15,9 @@ export function toPublicProductDto(product) {
eventName: product.eventName ?? null, eventName: product.eventName ?? null,
releaseYear: product.releaseYear ?? null, releaseYear: product.releaseYear ?? null,
tags: product.tags ?? [], tags: product.tags ?? [],
sellerDisplayName: product.sellerDisplayName ?? null,
sellerContactEmail: product.sellerContactEmail ?? null,
sellerContactPhone: product.sellerContactPhone ?? null,
createdAt: toIsoString(product.createdAt), createdAt: toIsoString(product.createdAt),
updatedAt: toIsoString(product.updatedAt), updatedAt: toIsoString(product.updatedAt),
}; };