feat: configure and display public seller descriptions
Quality / test (push) Canceled after 0s

This commit is contained in:
2026-09-10 07:40:18 -04:00
parent e630a89e8d
commit 94c37250d1
22 changed files with 158 additions and 6 deletions
@@ -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;
@@ -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: '<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: "" },
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({
<dd>{{ fact.value }}</dd>
</div>
</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 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">
<li v-if="sellerContactEmail"><a :href="`mailto:${sellerContactEmail}`">{{ sellerContactEmail }}</a></li>
<li v-if="sellerContactPhone"><a :href="`tel:${sellerContactPhone}`">{{ sellerContactPhone }}</a></li>
@@ -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);
@@ -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('');
},
};
+4 -1
View File
@@ -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);
<form v-if="!isLoading" class="admin-workspace-grid" @submit.prevent="save">
<fieldset class="showcase-panel">
<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>
<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>
@@ -1,5 +1,21 @@
import { expect, within } from '@storybook/test';
import ListingDetailPage from './ListingDetailPage.vue';
export default { title: 'Pages/ListingDetailPage', component: ListingDetailPage };
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();
},
};
+1
View File
@@ -45,6 +45,7 @@ onMounted(async () => {
"
:description="listing.notes"
:images="listing.images"
:seller-description="listing.sellerDescription"
:seller-display-name="listing.sellerDisplayName"
:seller-contact-email="listing.sellerContactEmail"
:seller-contact-phone="listing.sellerContactPhone"
@@ -1,5 +1,21 @@
import { expect, within } from '@storybook/test';
import ProductDetailPage from './ProductDetailPage.vue';
export default { title: 'Pages/ProductDetailPage', component: ProductDetailPage };
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();
},
};
+1
View File
@@ -37,6 +37,7 @@ onMounted(async () => {
...(product.cardNumber ? [{ label: 'Card number', value: product.cardNumber }] : []),
...(product.releaseYear ? [{ label: 'Release year', value: product.releaseYear }] : []),
]"
:seller-description="product.sellerDescription"
:seller-display-name="product.sellerDisplayName"
:seller-contact-email="product.sellerContactEmail"
:seller-contact-phone="product.sellerContactPhone"