This commit is contained in:
@@ -273,6 +273,7 @@ export function createListingRepository(db) {
|
|||||||
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,
|
sellerDisplayName: result.sellerDisplayName,
|
||||||
|
sellerDescription: result.sellerSettings?.general?.publicDescription || null,
|
||||||
sellerContactEmail: result.sellerSettings?.general?.publicContactEmail ?? null,
|
sellerContactEmail: result.sellerSettings?.general?.publicContactEmail ?? null,
|
||||||
sellerContactPhone: result.sellerSettings?.general?.publicContactPhone ?? null,
|
sellerContactPhone: result.sellerSettings?.general?.publicContactPhone ?? null,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export function createProductRepository(db) {
|
|||||||
return {
|
return {
|
||||||
...result.product,
|
...result.product,
|
||||||
sellerDisplayName: result.sellerDisplayName,
|
sellerDisplayName: result.sellerDisplayName,
|
||||||
|
sellerDescription: result.sellerSettings?.general?.publicDescription || null,
|
||||||
sellerContactEmail: result.sellerSettings?.general?.publicContactEmail ?? null,
|
sellerContactEmail: result.sellerSettings?.general?.publicContactEmail ?? null,
|
||||||
sellerContactPhone: result.sellerSettings?.general?.publicContactPhone ?? null,
|
sellerContactPhone: result.sellerSettings?.general?.publicContactPhone ?? null,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -40,3 +40,19 @@ test('rejects unknown or invalid settings', async () => {
|
|||||||
() => service.update({ sellerId: 'seller-1', settings: { secrets: { token: 'nope' } } }),
|
() => 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, '');
|
||||||
|
});
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ function createTestApp() {
|
|||||||
askingPriceMinor: 2499,
|
askingPriceMinor: 2499,
|
||||||
status: 'PENDING',
|
status: 'PENDING',
|
||||||
sellerDisplayName: 'Pika Collectibles',
|
sellerDisplayName: 'Pika Collectibles',
|
||||||
|
sellerDescription: 'Independent Pokémon collector.',
|
||||||
sellerContactEmail: 'hello@pika.example',
|
sellerContactEmail: 'hello@pika.example',
|
||||||
sellerContactPhone: null,
|
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(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.sellerDisplayName, 'Pika Collectibles');
|
||||||
|
assert.equal(detailResponse.json().data.sellerDescription, 'Independent Pokémon collector.');
|
||||||
assert.equal(detailResponse.json().data.sellerContactEmail, 'hello@pika.example');
|
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');
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ function createTestApp() {
|
|||||||
slug,
|
slug,
|
||||||
description: 'A friendly plush.',
|
description: 'A friendly plush.',
|
||||||
sellerDisplayName: 'Pika Collectibles',
|
sellerDisplayName: 'Pika Collectibles',
|
||||||
|
sellerDescription: 'Independent Pokémon collector.',
|
||||||
sellerContactEmail: 'hello@pika.example',
|
sellerContactEmail: 'hello@pika.example',
|
||||||
sellerContactPhone: null,
|
sellerContactPhone: null,
|
||||||
createdAt: new Date('2026-09-07T00:00:00.000Z'),
|
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,
|
releaseYear: null,
|
||||||
tags: [],
|
tags: [],
|
||||||
sellerDisplayName: 'Pika Collectibles',
|
sellerDisplayName: 'Pika Collectibles',
|
||||||
|
sellerDescription: 'Independent Pokémon collector.',
|
||||||
sellerContactEmail: 'hello@pika.example',
|
sellerContactEmail: 'hello@pika.example',
|
||||||
sellerContactPhone: null,
|
sellerContactPhone: null,
|
||||||
createdAt: '2026-09-07T00:00:00.000Z',
|
createdAt: '2026-09-07T00:00:00.000Z',
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
products,
|
products,
|
||||||
securityAuditEvents,
|
securityAuditEvents,
|
||||||
sellers,
|
sellers,
|
||||||
|
sellerSettings,
|
||||||
sessions,
|
sessions,
|
||||||
users,
|
users,
|
||||||
} from '../../src/infrastructure/database/schema.js';
|
} from '../../src/infrastructure/database/schema.js';
|
||||||
@@ -190,6 +191,12 @@ test('finds only products with active inventory and a public listing', async ()
|
|||||||
product.id,
|
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
|
await database.db
|
||||||
.update(listings)
|
.update(listings)
|
||||||
.set({ status: 'CLOSED' })
|
.set({ status: 'CLOSED' })
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
listings,
|
listings,
|
||||||
products,
|
products,
|
||||||
sellers,
|
sellers,
|
||||||
|
sellerSettings,
|
||||||
users,
|
users,
|
||||||
} from '../../src/infrastructure/database/schema.js';
|
} from '../../src/infrastructure/database/schema.js';
|
||||||
import { createInventoryImageRepository } from '../../src/modules/inventory/repositories/inventory-image-repository.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,
|
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
|
await database.db
|
||||||
.update(listings)
|
.update(listings)
|
||||||
.set({ status: 'CLOSED' })
|
.set({ status: 'CLOSED' })
|
||||||
|
|||||||
@@ -91,6 +91,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
[data-theme='dark'] {
|
[data-theme='dark'] {
|
||||||
|
--color-success-text: #63d991;
|
||||||
|
--color-warning-text: #ffc766;
|
||||||
|
--color-danger-text: #ff9297;
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
--color-background: #0b0d10;
|
--color-background: #0b0d10;
|
||||||
--color-surface: #11161d;
|
--color-surface: #11161d;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { expect, within } from '@storybook/test';
|
||||||
import CollectibleDetail from "./CollectibleDetail.vue";
|
import CollectibleDetail from "./CollectibleDetail.vue";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@@ -24,6 +25,7 @@ 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" },
|
||||||
],
|
],
|
||||||
|
sellerDescription: "Independent Pokémon collector.\nCarefully packed for fellow collectors.",
|
||||||
sellerDisplayName: "Pika Collectibles",
|
sellerDisplayName: "Pika Collectibles",
|
||||||
sellerContactEmail: "hello@pika.example",
|
sellerContactEmail: "hello@pika.example",
|
||||||
sellerContactPhone: "555-0100",
|
sellerContactPhone: "555-0100",
|
||||||
@@ -49,3 +51,20 @@ export const SoldListing = {
|
|||||||
condition: "Near Mint",
|
condition: "Near Mint",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const SellerDescriptionPlainText = {
|
||||||
|
args: { ...ListingDetail.args, sellerDescription: '<strong>Collector</strong>', sellerContactEmail: '', sellerContactPhone: '' },
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
await expect(within(canvasElement).getByText('<strong>Collector</strong>')).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: '<div style="background: var(--color-background); color: var(--color-text); padding: 24px"><story /></div>' })],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PendingSellerDescriptionDark = { ...SellerDescriptionDark, args: { ...ListingDetail.args, status: 'PENDING' } };
|
||||||
|
export const SoldSellerDescriptionDark = { ...SellerDescriptionDark, args: { ...ListingDetail.args, status: 'SOLD' } };
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ 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: () => [] },
|
||||||
|
sellerDescription: { type: String, default: "" },
|
||||||
sellerDisplayName: { type: String, default: "" },
|
sellerDisplayName: { type: String, default: "" },
|
||||||
sellerContactEmail: { type: String, default: "" },
|
sellerContactEmail: { type: String, default: "" },
|
||||||
sellerContactPhone: { type: String, default: "" },
|
sellerContactPhone: { type: String, default: "" },
|
||||||
@@ -50,9 +51,10 @@ 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">
|
<section v-if="sellerDisplayName || sellerDescription || sellerContactEmail || sellerContactPhone" class="collectible-detail__seller" aria-labelledby="collectible-detail-seller-title">
|
||||||
<p id="collectible-detail-seller-title" class="catalog-kicker">Sold by</p>
|
<p id="collectible-detail-seller-title" class="catalog-kicker">Sold by</p>
|
||||||
<p class="collectible-detail__seller-name">{{ sellerDisplayName }}</p>
|
<p class="collectible-detail__seller-name">{{ sellerDisplayName }}</p>
|
||||||
|
<p v-if="sellerDescription" class="collectible-detail__seller-description">{{ sellerDescription }}</p>
|
||||||
<ul v-if="sellerContactEmail || sellerContactPhone" class="collectible-detail__seller-contacts">
|
<ul v-if="sellerContactEmail || sellerContactPhone" class="collectible-detail__seller-contacts">
|
||||||
<li v-if="sellerContactEmail"><a :href="`mailto:${sellerContactEmail}`">{{ sellerContactEmail }}</a></li>
|
<li v-if="sellerContactEmail"><a :href="`mailto:${sellerContactEmail}`">{{ sellerContactEmail }}</a></li>
|
||||||
<li v-if="sellerContactPhone"><a :href="`tel:${sellerContactPhone}`">{{ sellerContactPhone }}</a></li>
|
<li v-if="sellerContactPhone"><a :href="`tel:${sellerContactPhone}`">{{ sellerContactPhone }}</a></li>
|
||||||
@@ -90,6 +92,10 @@ defineProps({
|
|||||||
.collectible-detail__seller-name {
|
.collectible-detail__seller-name {
|
||||||
font-weight: var(--weight-bold);
|
font-weight: var(--weight-bold);
|
||||||
}
|
}
|
||||||
|
.collectible-detail__seller-description {
|
||||||
|
white-space: pre-line;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
.collectible-detail__seller-contacts {
|
.collectible-detail__seller-contacts {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--space-1);
|
gap: var(--space-1);
|
||||||
|
|||||||
@@ -1,5 +1,34 @@
|
|||||||
|
import { expect, userEvent, within, waitFor } from '@storybook/test';
|
||||||
import AdminSettingsPage from './AdminSettingsPage.vue';
|
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 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('');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { onMounted, reactive, ref } from 'vue';
|
|||||||
import { getAdminSettings, updateAdminSettings } from '@/services/admin-settings-service.js';
|
import { getAdminSettings, updateAdminSettings } from '@/services/admin-settings-service.js';
|
||||||
|
|
||||||
const settings = reactive({
|
const settings = reactive({
|
||||||
general: { publicContactEmail: '', publicContactPhone: '' },
|
general: { publicDescription: '', publicContactEmail: '', publicContactPhone: '' },
|
||||||
inventory: { maxImagesPerItem: 10 },
|
inventory: { maxImagesPerItem: 10 },
|
||||||
pricing: { defaultStrategy: 'median' },
|
pricing: { defaultStrategy: 'median' },
|
||||||
security: { sessionIdleMinutes: 60 },
|
security: { sessionIdleMinutes: 60 },
|
||||||
@@ -63,6 +63,9 @@ onMounted(load);
|
|||||||
<form v-if="!isLoading" class="admin-workspace-grid" @submit.prevent="save">
|
<form v-if="!isLoading" class="admin-workspace-grid" @submit.prevent="save">
|
||||||
<fieldset class="showcase-panel">
|
<fieldset class="showcase-panel">
|
||||||
<legend>General</legend>
|
<legend>General</legend>
|
||||||
|
<label class="fixture-label" for="settings-description">Public seller description (optional)</label>
|
||||||
|
<p id="settings-description-help">Shown on public listing and product pages. Up to 2,000 characters. Clear to remove.</p>
|
||||||
|
<textarea id="settings-description" v-model="settings.general.publicDescription" class="fixture-input" rows="5" maxlength="2000" aria-describedby="settings-description-help" />
|
||||||
<label class="fixture-label" for="settings-email">Public contact email</label>
|
<label class="fixture-label" for="settings-email">Public contact email</label>
|
||||||
<input id="settings-email" v-model="settings.general.publicContactEmail" class="fixture-input" type="email" />
|
<input id="settings-email" v-model="settings.general.publicContactEmail" class="fixture-input" type="email" />
|
||||||
<label class="fixture-label" for="settings-phone">Public contact phone</label>
|
<label class="fixture-label" for="settings-phone">Public contact phone</label>
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
|
import { expect, within } from '@storybook/test';
|
||||||
import ListingDetailPage from './ListingDetailPage.vue';
|
import ListingDetailPage from './ListingDetailPage.vue';
|
||||||
|
|
||||||
export default { title: 'Pages/ListingDetailPage', component: ListingDetailPage };
|
export default { title: 'Pages/ListingDetailPage', component: ListingDetailPage };
|
||||||
|
|
||||||
export const Overview = {};
|
export const Overview = {};
|
||||||
|
|
||||||
|
export const PublicSellerDescription = {
|
||||||
|
beforeEach: () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = async () => new Response(JSON.stringify({ data: {
|
||||||
|
id: 'listing-1', name: 'Pikachu', productName: 'Pikachu', status: 'AVAILABLE',
|
||||||
|
askingPriceMinor: 2500, sellerDisplayName: 'Pika Collectibles',
|
||||||
|
sellerDescription: 'Independent Pokémon collector.', images: [], tags: [],
|
||||||
|
} }), { headers: { 'content-type': 'application/json' } });
|
||||||
|
return () => { globalThis.fetch = originalFetch; };
|
||||||
|
},
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
await expect(await within(canvasElement).findByText('Independent Pokémon collector.')).toBeVisible();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ onMounted(async () => {
|
|||||||
"
|
"
|
||||||
:description="listing.notes"
|
:description="listing.notes"
|
||||||
:images="listing.images"
|
:images="listing.images"
|
||||||
|
:seller-description="listing.sellerDescription"
|
||||||
:seller-display-name="listing.sellerDisplayName"
|
:seller-display-name="listing.sellerDisplayName"
|
||||||
:seller-contact-email="listing.sellerContactEmail"
|
:seller-contact-email="listing.sellerContactEmail"
|
||||||
:seller-contact-phone="listing.sellerContactPhone"
|
:seller-contact-phone="listing.sellerContactPhone"
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
|
import { expect, within } from '@storybook/test';
|
||||||
import ProductDetailPage from './ProductDetailPage.vue';
|
import ProductDetailPage from './ProductDetailPage.vue';
|
||||||
|
|
||||||
export default { title: 'Pages/ProductDetailPage', component: ProductDetailPage };
|
export default { title: 'Pages/ProductDetailPage', component: ProductDetailPage };
|
||||||
|
|
||||||
export const Overview = {};
|
export const Overview = {};
|
||||||
|
|
||||||
|
export const PublicSellerDescription = {
|
||||||
|
beforeEach: () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = async () => new Response(JSON.stringify({ data: {
|
||||||
|
id: 'listing-1', name: 'Pikachu', productName: 'Pikachu', status: 'AVAILABLE',
|
||||||
|
askingPriceMinor: 2500, sellerDisplayName: 'Pika Collectibles',
|
||||||
|
sellerDescription: 'Independent Pokémon collector.', images: [], tags: [],
|
||||||
|
} }), { headers: { 'content-type': 'application/json' } });
|
||||||
|
return () => { globalThis.fetch = originalFetch; };
|
||||||
|
},
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
await expect(await within(canvasElement).findByText('Independent Pokémon collector.')).toBeVisible();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ 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-description="product.sellerDescription"
|
||||||
:seller-display-name="product.sellerDisplayName"
|
:seller-display-name="product.sellerDisplayName"
|
||||||
:seller-contact-email="product.sellerContactEmail"
|
:seller-contact-email="product.sellerContactEmail"
|
||||||
:seller-contact-phone="product.sellerContactPhone"
|
:seller-contact-phone="product.sellerContactPhone"
|
||||||
|
|||||||
@@ -184,3 +184,9 @@ Public DTOs must never expose private acquisition, sale, buyer, internal, provid
|
|||||||
## Pagination and query state
|
## Pagination and query state
|
||||||
|
|
||||||
Large collections use pagination. Public/admin filters and searches should be represented in URL query state where useful for shareability, refresh, and back/forward navigation.
|
Large collections use pagination. Public/admin filters and searches should be represented in URL query state where useful for shareability, refresh, and back/forward navigation.
|
||||||
|
|
||||||
|
Public listing and product details include nullable `sellerDescription` from
|
||||||
|
`general.publicDescription` in seller settings. Authenticated, seller-scoped
|
||||||
|
`PATCH /api/v1/admin/settings` accepts this optional plain-text field (trimmed,
|
||||||
|
maximum 2,000 characters); send an empty string to remove it. Omitted fields
|
||||||
|
retain their current values. Private settings are not exposed in public DTOs.
|
||||||
|
|||||||
@@ -206,3 +206,5 @@ Security events are stored separately.
|
|||||||
## Money
|
## Money
|
||||||
|
|
||||||
Store money as integer minor units. Negative values are prohibited; zero is valid. USD is canonical for stored/calculated market values unless a documented external representation requires another currency.
|
Store money as integer minor units. Negative values are prohibited; zero is valid. USD is canonical for stored/calculated market values unless a documented external representation requires another currency.
|
||||||
|
|
||||||
|
Public seller descriptions use optional `general.publicDescription` in seller settings: trimmed plain text, at most 2,000 characters. An empty string clears the description. Public listing and product detail DTOs expose only this field as `sellerDescription`, alongside the existing public seller name and contact fields; other settings remain private. No database migration is required.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Implementation Status
|
# Implementation Status
|
||||||
|
|
||||||
Last updated: 2026-09-09
|
Last updated: 2026-09-10
|
||||||
|
|
||||||
## Resume Here
|
## Resume Here
|
||||||
|
|
||||||
@@ -96,7 +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 listing and product detail responses and pages now surface the seller's display name and configured public contact methods (email/phone) from seller settings, covering the contact-method portion of 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.
|
||||||
@@ -120,7 +120,18 @@ docker context use orbstack
|
|||||||
| HTTP | Product and inventory creation, validation, authentication/recovery, CSRF, rate limiting, and session management |
|
| HTTP | Product and inventory creation, validation, authentication/recovery, CSRF, rate limiting, and session management |
|
||||||
| Integration | PostgreSQL catalog/session/audit/password recovery persistence and Redis-shared login throttling |
|
| Integration | PostgreSQL catalog/session/audit/password recovery persistence and Redis-shared login throttling |
|
||||||
|
|
||||||
Verified locally: `npm test` passes 164 tests with no failures (22 domain, 78 application, and 64 HTTP); worker tests, web tests, web build, Storybook build, Storybook interaction/accessibility checks, integration tests, and authenticated E2E workflows also pass when their required local services and credentials are available.
|
Verified locally: `npm test` passes 166 tests with no failures (23 domain, 79 application, and 64 HTTP); worker tests, web tests, web build, Storybook build, Storybook interaction/accessibility checks, integration tests, and authenticated E2E workflows also pass when their required local services and credentials are available.
|
||||||
|
|
||||||
|
### Latest Vertical Slice: Public Seller Description
|
||||||
|
|
||||||
|
- Sellers can save, edit, and clear an optional public description in Settings.
|
||||||
|
- Descriptions are trimmed plain text, limited to 2,000 characters server-side, and stored in existing seller settings JSON without a migration.
|
||||||
|
- Public listing and product detail pages render the description as escaped text with line breaks; public DTOs expose only the allowlisted field.
|
||||||
|
- Dark-theme coverage also exposed and fixed insufficient status badge text contrast for Available, Pending, and Sold.
|
||||||
|
- Coverage includes validation/partial-update/clearing tests, public HTTP contracts, PostgreSQL repository reads, settings edit/clear interactions, both public page integrations, and absent/plain-text/dark-theme component stories.
|
||||||
|
- Validation: 166 API tests, 23 isolated PostgreSQL/Redis integration tests, 10 web tests, production web build, Storybook build, and Storybook interaction/accessibility runner (32 suites / 84 tests).
|
||||||
|
|
||||||
|
Next bounded slice: review the public catalog → listing/product detail → seller contact journey with keyboard navigation, mobile layout, and 200% zoom; fix discovered accessibility issues and add regression stories/tests. Record manual results in `docs/ACCESSIBILITY.md`. This advances the documented accessibility milestone without requiring external provider credentials.
|
||||||
|
|
||||||
## Next Milestone
|
## Next Milestone
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export function toPublicListingDto(listing) {
|
|||||||
status: listing.status,
|
status: listing.status,
|
||||||
notes: listing.notes ?? null,
|
notes: listing.notes ?? null,
|
||||||
images: listing.images ?? [],
|
images: listing.images ?? [],
|
||||||
|
sellerDescription: listing.sellerDescription ?? null,
|
||||||
sellerDisplayName: listing.sellerDisplayName ?? null,
|
sellerDisplayName: listing.sellerDisplayName ?? null,
|
||||||
sellerContactEmail: listing.sellerContactEmail ?? null,
|
sellerContactEmail: listing.sellerContactEmail ?? null,
|
||||||
sellerContactPhone: listing.sellerContactPhone ?? null,
|
sellerContactPhone: listing.sellerContactPhone ?? null,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ 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 ?? [],
|
||||||
|
sellerDescription: product.sellerDescription ?? null,
|
||||||
sellerDisplayName: product.sellerDisplayName ?? null,
|
sellerDisplayName: product.sellerDisplayName ?? null,
|
||||||
sellerContactEmail: product.sellerContactEmail ?? null,
|
sellerContactEmail: product.sellerContactEmail ?? null,
|
||||||
sellerContactPhone: product.sellerContactPhone ?? null,
|
sellerContactPhone: product.sellerContactPhone ?? null,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
export const sellerSettingsSchema = z.object({
|
export const sellerSettingsSchema = z.object({
|
||||||
general: z.object({
|
general: z.object({
|
||||||
|
publicDescription: z.string().trim().max(2000).optional(),
|
||||||
publicContactEmail: z.string().trim().email().optional(),
|
publicContactEmail: z.string().trim().email().optional(),
|
||||||
publicContactPhone: z.string().trim().max(50).optional(),
|
publicContactPhone: z.string().trim().max(50).optional(),
|
||||||
}).strict().optional(),
|
}).strict().optional(),
|
||||||
|
|||||||
Reference in New Issue
Block a user