From 94c37250d163b6c056059e205b4ce9b70451f37e Mon Sep 17 00:00:00 2001 From: FragginWagon Date: Thu, 10 Sep 2026 07:40:18 -0400 Subject: [PATCH] feat: configure and display public seller descriptions --- .../repositories/listing-repository.js | 1 + .../repositories/product-repository.js | 1 + .../seller-settings-service.test.js | 16 ++++++++++ .../tests/http/public-listing-routes.test.js | 2 ++ .../tests/http/public-product-routes.test.js | 2 ++ .../integration/catalog-repositories.test.js | 7 +++++ .../integration/listing-repository.test.js | 7 +++++ apps/web/src/assets/styles/global-tokens.scss | 3 ++ .../organisms/CollectibleDetail.stories.js | 19 ++++++++++++ .../organisms/CollectibleDetail.vue | 8 ++++- .../src/pages/AdminSettingsPage.stories.js | 31 ++++++++++++++++++- apps/web/src/pages/AdminSettingsPage.vue | 5 ++- .../src/pages/ListingDetailPage.stories.js | 16 ++++++++++ apps/web/src/pages/ListingDetailPage.vue | 1 + .../src/pages/ProductDetailPage.stories.js | 16 ++++++++++ apps/web/src/pages/ProductDetailPage.vue | 1 + docs/API.md | 6 ++++ docs/DATA-MODEL.md | 2 ++ docs/IMPLEMENTATION-STATUS.md | 17 ++++++++-- packages/contracts/public-listing.js | 1 + packages/contracts/public-product.js | 1 + packages/validation/settings.js | 1 + 22 files changed, 158 insertions(+), 6 deletions(-) diff --git a/apps/api/src/modules/listings/repositories/listing-repository.js b/apps/api/src/modules/listings/repositories/listing-repository.js index 5356ee6..9f939e2 100644 --- a/apps/api/src/modules/listings/repositories/listing-repository.js +++ b/apps/api/src/modules/listings/repositories/listing-repository.js @@ -273,6 +273,7 @@ export function createListingRepository(db) { marketValueCurrency: override?.currency ?? valuation?.currency ?? null, marketValueUpdatedAt: override?.createdAt ?? valuation?.calculatedAt ?? null, sellerDisplayName: result.sellerDisplayName, + sellerDescription: result.sellerSettings?.general?.publicDescription || null, 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 9bd3729..1dbcf8e 100644 --- a/apps/api/src/modules/products/repositories/product-repository.js +++ b/apps/api/src/modules/products/repositories/product-repository.js @@ -47,6 +47,7 @@ export function createProductRepository(db) { return { ...result.product, sellerDisplayName: result.sellerDisplayName, + sellerDescription: result.sellerSettings?.general?.publicDescription || null, sellerContactEmail: result.sellerSettings?.general?.publicContactEmail ?? null, sellerContactPhone: result.sellerSettings?.general?.publicContactPhone ?? null, }; diff --git a/apps/api/tests/application/seller-settings-service.test.js b/apps/api/tests/application/seller-settings-service.test.js index 892e326..43590c5 100644 --- a/apps/api/tests/application/seller-settings-service.test.js +++ b/apps/api/tests/application/seller-settings-service.test.js @@ -40,3 +40,19 @@ test('rejects unknown or invalid settings', async () => { () => service.update({ sellerId: 'seller-1', settings: { secrets: { token: 'nope' } } }), ); }); + + +test('seller descriptions are trimmed, bounded, preserved on partial updates and clearable', async () => { + let stored; + const service = createSellerSettingsService({ sellerSettingsRepository: { + async findBySellerId() { return stored; }, + async upsert(sellerId, settings) { stored = { sellerId, settings }; return stored; }, + } }); + const update = (general) => service.update({ sellerId: 'seller-1', settings: { general } }); + assert.equal((await update({ publicDescription: ' Collector. ' })).general.publicDescription, 'Collector.'); + assert.equal((await update({ publicContactPhone: '555-0100' })).general.publicDescription, 'Collector.'); + await assert.rejects(() => update({ publicDescription: 'x'.repeat(2001) })); + await assert.rejects(() => update({ publicDescription: 123 })); + assert.equal((await update({ publicDescription: 'x'.repeat(2000) })).general.publicDescription.length, 2000); + assert.equal((await update({ publicDescription: ' ' })).general.publicDescription, ''); +}); diff --git a/apps/api/tests/http/public-listing-routes.test.js b/apps/api/tests/http/public-listing-routes.test.js index d5492b6..2a8feaf 100644 --- a/apps/api/tests/http/public-listing-routes.test.js +++ b/apps/api/tests/http/public-listing-routes.test.js @@ -37,6 +37,7 @@ function createTestApp() { askingPriceMinor: 2499, status: 'PENDING', sellerDisplayName: 'Pika Collectibles', + sellerDescription: 'Independent Pokémon collector.', sellerContactEmail: 'hello@pika.example', sellerContactPhone: null, }; @@ -76,6 +77,7 @@ 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.sellerDescription, 'Independent Pokémon collector.'); assert.equal(detailResponse.json().data.sellerContactEmail, 'hello@pika.example'); assert.equal(missingResponse.statusCode, 404); assert.equal(missingResponse.json().error.code, 'LISTING_NOT_FOUND'); diff --git a/apps/api/tests/http/public-product-routes.test.js b/apps/api/tests/http/public-product-routes.test.js index 5fd8a0e..d19b0fd 100644 --- a/apps/api/tests/http/public-product-routes.test.js +++ b/apps/api/tests/http/public-product-routes.test.js @@ -22,6 +22,7 @@ function createTestApp() { slug, description: 'A friendly plush.', sellerDisplayName: 'Pika Collectibles', + sellerDescription: 'Independent Pokémon collector.', sellerContactEmail: 'hello@pika.example', sellerContactPhone: null, createdAt: new Date('2026-09-07T00:00:00.000Z'), @@ -55,6 +56,7 @@ test('returns a public product without private seller identity', async (testCont releaseYear: null, tags: [], sellerDisplayName: 'Pika Collectibles', + sellerDescription: 'Independent Pokémon collector.', sellerContactEmail: 'hello@pika.example', sellerContactPhone: null, createdAt: '2026-09-07T00:00:00.000Z', diff --git a/apps/api/tests/integration/catalog-repositories.test.js b/apps/api/tests/integration/catalog-repositories.test.js index bc445ed..1f019e1 100644 --- a/apps/api/tests/integration/catalog-repositories.test.js +++ b/apps/api/tests/integration/catalog-repositories.test.js @@ -17,6 +17,7 @@ import { products, securityAuditEvents, sellers, + sellerSettings, sessions, users, } from '../../src/infrastructure/database/schema.js'; @@ -190,6 +191,12 @@ test('finds only products with active inventory and a public listing', async () product.id, ); + assert.equal((await productRepository.findPublicBySlug(product.slug)).sellerDescription, null); + await database.db.insert(sellerSettings).values({ sellerId: seller.id, settings: { general: { publicDescription: 'Collector.' }, security: { sessionIdleMinutes: 60 } } }); + assert.equal((await productRepository.findPublicBySlug(product.slug)).sellerDescription, 'Collector.'); + await database.db.update(sellerSettings).set({ settings: { general: { publicDescription: '' } } }); + assert.equal((await productRepository.findPublicBySlug(product.slug)).sellerDescription, null); + await database.db .update(listings) .set({ status: 'CLOSED' }) diff --git a/apps/api/tests/integration/listing-repository.test.js b/apps/api/tests/integration/listing-repository.test.js index 8266d12..dd19e0e 100644 --- a/apps/api/tests/integration/listing-repository.test.js +++ b/apps/api/tests/integration/listing-repository.test.js @@ -12,6 +12,7 @@ import { listings, products, sellers, + sellerSettings, users, } from '../../src/infrastructure/database/schema.js'; import { createInventoryImageRepository } from '../../src/modules/inventory/repositories/inventory-image-repository.js'; @@ -148,6 +149,12 @@ test('searches public listings with pagination and hides sold items by default', visible.items[0].id, ); + assert.equal((await listingRepository.findPublicById(visible.items[0].id)).sellerDescription, null); + await database.db.insert(sellerSettings).values({ sellerId: product.sellerId, settings: { general: { publicDescription: 'Collector.' }, security: { sessionIdleMinutes: 60 } } }); + assert.equal((await listingRepository.findPublicById(visible.items[0].id)).sellerDescription, 'Collector.'); + await database.db.update(sellerSettings).set({ settings: { general: { publicDescription: '' } } }); + assert.equal((await listingRepository.findPublicById(visible.items[0].id)).sellerDescription, null); + await database.db .update(listings) .set({ status: 'CLOSED' }) diff --git a/apps/web/src/assets/styles/global-tokens.scss b/apps/web/src/assets/styles/global-tokens.scss index 57bf09f..ad37e98 100644 --- a/apps/web/src/assets/styles/global-tokens.scss +++ b/apps/web/src/assets/styles/global-tokens.scss @@ -91,6 +91,9 @@ } [data-theme='dark'] { + --color-success-text: #63d991; + --color-warning-text: #ffc766; + --color-danger-text: #ff9297; color-scheme: dark; --color-background: #0b0d10; --color-surface: #11161d; diff --git a/apps/web/src/components/organisms/CollectibleDetail.stories.js b/apps/web/src/components/organisms/CollectibleDetail.stories.js index 7948a3d..8fda1ee 100644 --- a/apps/web/src/components/organisms/CollectibleDetail.stories.js +++ b/apps/web/src/components/organisms/CollectibleDetail.stories.js @@ -1,3 +1,4 @@ +import { expect, within } from '@storybook/test'; import CollectibleDetail from "./CollectibleDetail.vue"; export default { @@ -24,6 +25,7 @@ export const ListingDetail = { { id: "front", label: "FRONT", alt: "Front of Charizard ex" }, { id: "back", label: "BACK", alt: "Back of Charizard ex" }, ], + sellerDescription: "Independent Pokémon collector.\nCarefully packed for fellow collectors.", sellerDisplayName: "Pika Collectibles", sellerContactEmail: "hello@pika.example", sellerContactPhone: "555-0100", @@ -49,3 +51,20 @@ export const SoldListing = { condition: "Near Mint", }, }; + +export const SellerDescriptionPlainText = { + args: { ...ListingDetail.args, sellerDescription: 'Collector', sellerContactEmail: '', sellerContactPhone: '' }, + play: async ({ canvasElement }) => { + await expect(within(canvasElement).getByText('Collector')).toBeVisible(); + await expect(canvasElement.querySelector('.collectible-detail__seller-description strong')).toBeNull(); + }, +}; +export const SellerWithoutDescription = { args: { ...ListingDetail.args, sellerDescription: '' } }; +export const SellerDescriptionDark = { + args: ListingDetail.args, + globals: { theme: 'dark' }, + decorators: [() => ({ template: '
' })], +}; + +export const PendingSellerDescriptionDark = { ...SellerDescriptionDark, args: { ...ListingDetail.args, status: 'PENDING' } }; +export const SoldSellerDescriptionDark = { ...SellerDescriptionDark, args: { ...ListingDetail.args, status: 'SOLD' } }; diff --git a/apps/web/src/components/organisms/CollectibleDetail.vue b/apps/web/src/components/organisms/CollectibleDetail.vue index c3c5448..18c8a24 100644 --- a/apps/web/src/components/organisms/CollectibleDetail.vue +++ b/apps/web/src/components/organisms/CollectibleDetail.vue @@ -15,6 +15,7 @@ defineProps({ description: { type: String, default: "" }, facts: { type: Array, default: () => [] }, images: { type: Array, default: () => [] }, + sellerDescription: { type: String, default: "" }, sellerDisplayName: { type: String, default: "" }, sellerContactEmail: { type: String, default: "" }, sellerContactPhone: { type: String, default: "" }, @@ -50,9 +51,10 @@ defineProps({
{{ fact.value }}
-
+

Sold by

{{ sellerDisplayName }}

+

{{ sellerDescription }}

  • {{ sellerContactEmail }}
  • {{ sellerContactPhone }}
  • @@ -90,6 +92,10 @@ defineProps({ .collectible-detail__seller-name { font-weight: var(--weight-bold); } +.collectible-detail__seller-description { + white-space: pre-line; + overflow-wrap: anywhere; +} .collectible-detail__seller-contacts { display: grid; gap: var(--space-1); diff --git a/apps/web/src/pages/AdminSettingsPage.stories.js b/apps/web/src/pages/AdminSettingsPage.stories.js index c1d9ea6..1a35d11 100644 --- a/apps/web/src/pages/AdminSettingsPage.stories.js +++ b/apps/web/src/pages/AdminSettingsPage.stories.js @@ -1,5 +1,34 @@ +import { expect, userEvent, within, waitFor } from '@storybook/test'; import AdminSettingsPage from './AdminSettingsPage.vue'; -export default { title: 'Pages/AdminSettingsPage', component: AdminSettingsPage }; +export default { + title: 'Pages/AdminSettingsPage', component: AdminSettingsPage, + beforeEach: () => { + const originalFetch = globalThis.fetch; + let stored = { general: { publicDescription: 'Independent collector.', publicContactEmail: 'collector@example.test' } }; + globalThis.fetch = async (url, options) => { + if (!String(url).endsWith('/admin/settings')) return originalFetch(url, options); + if (options?.method === 'PATCH') stored = JSON.parse(options.body); + return new Response(JSON.stringify({ data: stored }), { headers: { 'content-type': 'application/json' } }); + }; + return () => { globalThis.fetch = originalFetch; }; + }, +}; export const SettingsForm = {}; +export const EditAndClearDescription = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const field = await canvas.findByLabelText('Public seller description (optional)'); + await expect(field).toHaveValue('Independent collector.'); + await userEvent.clear(field); + await userEvent.type(field, 'Cards and collectibles.'); + await userEvent.click(canvas.getByRole('button', { name: 'Save settings' })); + await waitFor(() => expect(canvas.getByRole('status')).toHaveTextContent('Settings saved.')); + await expect(field).toHaveValue('Cards and collectibles.'); + await userEvent.clear(field); + await userEvent.click(canvas.getByRole('button', { name: 'Save settings' })); + await waitFor(() => expect(canvas.getByRole('status')).toHaveTextContent('Settings saved.')); + await expect(field).toHaveValue(''); + }, +}; diff --git a/apps/web/src/pages/AdminSettingsPage.vue b/apps/web/src/pages/AdminSettingsPage.vue index a994940..1e99945 100644 --- a/apps/web/src/pages/AdminSettingsPage.vue +++ b/apps/web/src/pages/AdminSettingsPage.vue @@ -4,7 +4,7 @@ import { onMounted, reactive, ref } from 'vue'; import { getAdminSettings, updateAdminSettings } from '@/services/admin-settings-service.js'; const settings = reactive({ - general: { publicContactEmail: '', publicContactPhone: '' }, + general: { publicDescription: '', publicContactEmail: '', publicContactPhone: '' }, inventory: { maxImagesPerItem: 10 }, pricing: { defaultStrategy: 'median' }, security: { sessionIdleMinutes: 60 }, @@ -63,6 +63,9 @@ onMounted(load);
    General + +

    Shown on public listing and product pages. Up to 2,000 characters. Clear to remove.

    +