From 9bb4a16e9ef10326bd4f40be7e038c32369f7f0d Mon Sep 17 00:00:00 2001 From: FragginWagon Date: Tue, 8 Sep 2026 21:50:32 -0400 Subject: [PATCH] feat: complete visual catalog development workflow --- .github/workflows/quality.yml | 23 + README.md | 8 + apps/api/package.json | 1 + .../catalog/scripts/seed-demo-catalog.js | 101 + apps/web/.storybook/preview.js | 2 +- apps/web/package.json | 1 + apps/web/src/assets/styles/global-tokens.scss | 6 + apps/web/src/assets/styles/global.scss | 14 +- .../src/components/atoms/ConditionBadge.scss | 2 + .../atoms/ConditionBadge.stories.js | 5 + .../src/components/atoms/ConditionBadge.vue | 17 + .../src/components/atoms/PriceDisplay.scss | 3 + .../components/atoms/PriceDisplay.stories.js | 5 + .../web/src/components/atoms/PriceDisplay.vue | 17 + .../web/src/components/atoms/StatusBadge.scss | 5 + .../components/atoms/StatusBadge.stories.js | 6 + apps/web/src/components/atoms/StatusBadge.vue | 15 + .../components/molecules/ImageGallery.scss | 7 + .../molecules/ImageGallery.stories.js | 11 + .../src/components/molecules/ImageGallery.vue | 26 + .../src/components/molecules/ListingCard.scss | 2 +- .../molecules/ListingCard.stories.js | 15 +- .../src/components/molecules/ListingCard.vue | 11 +- .../organisms/DesignSystemShowcase.vue | 23 +- package-lock.json | 7007 ++++++++++++++++- package.json | 5 + 26 files changed, 7312 insertions(+), 26 deletions(-) create mode 100644 .github/workflows/quality.yml create mode 100644 apps/api/src/modules/catalog/scripts/seed-demo-catalog.js create mode 100644 apps/web/src/components/atoms/ConditionBadge.scss create mode 100644 apps/web/src/components/atoms/ConditionBadge.stories.js create mode 100644 apps/web/src/components/atoms/ConditionBadge.vue create mode 100644 apps/web/src/components/atoms/PriceDisplay.scss create mode 100644 apps/web/src/components/atoms/PriceDisplay.stories.js create mode 100644 apps/web/src/components/atoms/PriceDisplay.vue create mode 100644 apps/web/src/components/atoms/StatusBadge.scss create mode 100644 apps/web/src/components/atoms/StatusBadge.stories.js create mode 100644 apps/web/src/components/atoms/StatusBadge.vue create mode 100644 apps/web/src/components/molecules/ImageGallery.scss create mode 100644 apps/web/src/components/molecules/ImageGallery.stories.js create mode 100644 apps/web/src/components/molecules/ImageGallery.vue diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 0000000..5d764bd --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,23 @@ +name: Quality + +on: + push: + branches: [develop] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npx playwright install --with-deps chromium + - run: npm test + - run: npm run test --workspace=@pokemon-inventory/web + - run: npm run build --workspace=@pokemon-inventory/web + - run: npm run build-storybook --workspace=@pokemon-inventory/web + - run: npm run test-storybook \ No newline at end of file diff --git a/README.md b/README.md index 306f71c..a0a0743 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,14 @@ npm run storybook Open `http://localhost:6006/`. Use the Storybook theme toolbar to switch between light and dark themes. The accessibility addon is enabled for component review. The fixture showcase is intentionally mock-only and is not part of the production router. +Seed representative API-backed catalog data for browser testing with: + +```sh +npm run seed:demo +``` + +The seed is non-destructive and exits without changes when the demo catalog already exists. + ## Initial Administrator After the development database is migrated, set `INITIAL_ADMIN_EMAIL`, `INITIAL_ADMIN_PASSWORD`, and `INITIAL_SELLER_NAME` in your local `.env`, then run `npm run provision:admin` once. The command creates the only initial administrator and linked Seller; it refuses to run after any User exists. diff --git a/apps/api/package.json b/apps/api/package.json index 5b18517..ca058cc 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -3,6 +3,7 @@ "private": true, "type": "module", "scripts": { + "seed:demo": "node src/modules/catalog/scripts/seed-demo-catalog.js", "provision:admin": "node src/modules/auth/scripts/provision-initial-admin.js", "start": "node src/app/server.js", "test:application": "node --test tests/application/*.test.js", diff --git a/apps/api/src/modules/catalog/scripts/seed-demo-catalog.js b/apps/api/src/modules/catalog/scripts/seed-demo-catalog.js new file mode 100644 index 0000000..58c2c7b --- /dev/null +++ b/apps/api/src/modules/catalog/scripts/seed-demo-catalog.js @@ -0,0 +1,101 @@ +import dotenv from 'dotenv'; +import { fileURLToPath } from 'node:url'; +import { eq } from 'drizzle-orm'; + +import { createDatabase } from '../../../infrastructure/database/client.js'; +import { + categories, + conditionProfiles, + conditions, + inventoryItems, + listings, + products, + sellers, + users, +} from '../../../infrastructure/database/schema.js'; + +dotenv.config({ path: fileURLToPath(new URL('../../../../../../.env', import.meta.url)) }); + +const demoEmail = 'demo-catalog@example.test'; + +async function seedDemoCatalog() { + const database = createDatabase(); + const [existingUser] = await database.db.select().from(users).where(eq(users.email, demoEmail)); + + if (existingUser) { + console.log(`Demo catalog already exists for ${demoEmail}.`); + await database.close(); + return; + } + + try { + await database.db.transaction(async (tx) => { + const [user] = await tx.insert(users).values({ + email: demoEmail, + passwordHash: 'demo-catalog-password-not-for-login', + }).returning(); + const [seller] = await tx.insert(sellers).values({ + userId: user.id, + displayName: 'Demo Catalog', + }).returning(); + const [category] = await tx.insert(categories).values({ + name: 'Demo Collectibles', + slug: 'demo-collectibles', + }).returning(); + const [profile] = await tx.insert(conditionProfiles).values({ + categoryId: category.id, + name: 'Collectible Condition', + }).returning(); + const [condition] = await tx.insert(conditions).values({ + conditionProfileId: profile.id, + name: 'Near Mint', + code: 'NM', + qualityRank: 1, + }).returning(); + + const fixtures = [ + { name: 'Charizard ex #199/165', slug: 'charizard-ex-199-165', price: 11500, status: 'AVAILABLE' }, + { name: 'Pikachu World Collection', slug: 'pikachu-world-collection', price: 8200, status: 'PENDING' }, + { name: 'Gengar holo promo', slug: 'gengar-holo-promo', price: 4500, status: 'CLOSED', inventoryStatus: 'SOLD' }, + ]; + + for (const fixture of fixtures) { + const [product] = await tx.insert(products).values({ + sellerId: seller.id, + categoryId: category.id, + name: fixture.name, + slug: fixture.slug, + description: 'A representative demo collectible for local browser testing.', + brand: 'Demo Archive', + setName: 'Demo Collection', + cardNumber: fixture.slug === 'charizard-ex-199-165' ? '199/165' : null, + releaseYear: 2026, + }).returning(); + const [inventoryItem] = await tx.insert(inventoryItems).values({ + productId: product.id, + conditionId: condition.id, + status: fixture.inventoryStatus ?? 'ACTIVE', + notes: 'Private demo inventory note.', + }).returning(); + await tx.insert(listings).values({ + inventoryItemId: inventoryItem.id, + productName: product.name, + categoryId: category.id, + conditionId: condition.id, + askingPriceMinor: fixture.price, + status: fixture.status, + notes: fixture.status === 'CLOSED' ? 'Sold history retained.' : 'Available for local preview.', + }); + } + }); + + console.log('Seeded three demo collectibles for browser testing.'); + } finally { + await database.close(); + } +} + +seedDemoCatalog().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/apps/web/.storybook/preview.js b/apps/web/.storybook/preview.js index a63f80b..8247ecf 100644 --- a/apps/web/.storybook/preview.js +++ b/apps/web/.storybook/preview.js @@ -21,6 +21,6 @@ export const decorators = [ export const parameters = { controls: { expanded: true }, - a11y: { test: 'todo' }, + a11y: { test: 'error' }, layout: 'fullscreen', }; diff --git a/apps/web/package.json b/apps/web/package.json index f97e514..dd9a73b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -7,6 +7,7 @@ "build": "vite build", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", + "test-storybook": "test-storybook --url http://127.0.0.1:6006 --ci", "test": "node --test tests/*.test.js" }, "dependencies": { diff --git a/apps/web/src/assets/styles/global-tokens.scss b/apps/web/src/assets/styles/global-tokens.scss index f0df669..57bf09f 100644 --- a/apps/web/src/assets/styles/global-tokens.scss +++ b/apps/web/src/assets/styles/global-tokens.scss @@ -17,6 +17,10 @@ --color-warning-500: #d88a00; --color-danger-500: #e5484d; --color-info-500: #1683c4; + --color-success-text: #08783a; + --color-warning-text: #8a5700; + --color-danger-text: #b4232b; + --color-info-text: #0b5f91; --font-ui: 'Manrope', ui-sans-serif, system-ui, sans-serif; --font-display: 'Space Grotesk', 'Manrope', ui-sans-serif, sans-serif; @@ -77,6 +81,7 @@ --color-border: var(--color-gray-200); --color-border-strong: var(--color-gray-300); --color-interactive: var(--color-cobalt-500); + --color-interactive-text: var(--color-cobalt-700); --color-interactive-hover: var(--color-cobalt-700); --color-on-interactive: var(--color-white); --color-focus: var(--color-cobalt-500); @@ -97,6 +102,7 @@ --color-border: #2b3745; --color-border-strong: #425064; --color-interactive: var(--color-cobalt-400); + --color-interactive-text: var(--color-cobalt-400); --color-interactive-hover: #83adff; --color-on-interactive: #07142f; --color-focus: var(--color-cobalt-400); diff --git a/apps/web/src/assets/styles/global.scss b/apps/web/src/assets/styles/global.scss index 909e7ae..a76f928 100644 --- a/apps/web/src/assets/styles/global.scss +++ b/apps/web/src/assets/styles/global.scss @@ -54,7 +54,7 @@ h1 { max-width: 760px; margin: var(--space-3) 0 var(--space-4); font-size: var(- .button { min-height: 44px; border: var(--border-thin) solid transparent; border-radius: var(--radius-md); padding: var(--space-3) var(--space-4); font-weight: var(--weight-bold); } .button--primary { background: var(--color-interactive); color: var(--color-on-interactive); } .button--secondary { border-color: var(--color-border-strong); background: var(--color-surface); color: var(--color-text); } -.button--danger { background: var(--color-danger-500); color: var(--color-white); } +.button--danger { background: var(--color-danger-text); color: var(--color-white); } .button:disabled { cursor: not-allowed; opacity: 0.45; } .swatch { display: grid; gap: var(--space-2); color: var(--color-text-muted); font-size: var(--text-xs); } .swatch__chip { width: 72px; height: 44px; border-radius: var(--radius-md); } @@ -69,11 +69,11 @@ h1 { max-width: 760px; margin: var(--space-3) 0 var(--space-4); font-size: var(- .type-sample__numeric { font-size: var(--text-xl); font-variant-numeric: tabular-nums; } .status { display: inline-flex; align-items: center; gap: var(--space-2); border-radius: var(--radius-full); padding: var(--space-2) var(--space-3); font-size: var(--text-xs); font-weight: var(--weight-bold); } .status::before { width: 8px; height: 8px; border-radius: 50%; background: currentColor; content: ''; } -.status--available { background: color-mix(in srgb, var(--color-success-500) 14%, transparent); color: var(--color-success-500); } -.status--pending { background: color-mix(in srgb, var(--color-warning-500) 16%, transparent); color: var(--color-warning-500); } -.status--sold { background: color-mix(in srgb, var(--color-danger-500) 14%, transparent); color: var(--color-danger-500); } +.status--available { background: color-mix(in srgb, var(--color-success-500) 14%, transparent); color: var(--color-success-text); } +.status--pending { background: color-mix(in srgb, var(--color-warning-500) 16%, transparent); color: var(--color-warning-text); } +.status--sold { background: color-mix(in srgb, var(--color-danger-500) 14%, transparent); color: var(--color-danger-text); } .condition { border: var(--border-thin) solid var(--color-border-strong); border-radius: var(--radius-md); padding: var(--space-2) var(--space-3); color: var(--color-text-subtle); font-size: var(--text-sm); } -.condition--selected { border-color: var(--color-interactive); box-shadow: var(--glow-selection); } +.condition--selected { border-color: var(--color-interactive); box-shadow: var(--glow-selection); color: var(--color-interactive-text); } .fixture-stage { display: grid; place-items: center; min-height: 180px; border-radius: var(--radius-lg); background: var(--color-stage); color: var(--color-text-muted); text-align: center; } .fixture-stage--image { min-height: 240px; background: linear-gradient(135deg, var(--color-stage), var(--color-surface-muted)); } .fixture-stage__glyph { font-family: var(--font-display); font-size: 3rem; font-weight: var(--weight-bold); } @@ -85,8 +85,8 @@ h1 { max-width: 760px; margin: var(--space-3) 0 var(--space-4); font-size: var(- .state-stack { display: grid; gap: var(--space-3); } .state { border-radius: var(--radius-lg); padding: var(--space-4); } .state--empty { border: var(--border-thin) dashed var(--color-border-strong); color: var(--color-text-muted); } -.state--loading { background: var(--color-surface-muted); color: var(--color-text-muted); } -.state--error { background: color-mix(in srgb, var(--color-danger-500) 10%, transparent); color: var(--color-danger-500); } +.state--loading { background: var(--color-surface-muted); color: var(--color-text-subtle); } +.state--error { background: color-mix(in srgb, var(--color-danger-500) 10%, transparent); color: var(--color-danger-text); } @media (max-width: 768px) { .catalog-header { padding-top: var(--space-16); } diff --git a/apps/web/src/components/atoms/ConditionBadge.scss b/apps/web/src/components/atoms/ConditionBadge.scss new file mode 100644 index 0000000..0628e8b --- /dev/null +++ b/apps/web/src/components/atoms/ConditionBadge.scss @@ -0,0 +1,2 @@ +.condition { min-height: 44px; border: var(--border-thin) solid var(--color-border-strong); border-radius: var(--radius-md); padding: var(--space-2) var(--space-3); background: var(--color-surface); color: var(--color-text-subtle); font-size: var(--text-sm); } +.condition--selected { border-color: var(--color-interactive); box-shadow: var(--glow-selection); color: var(--color-interactive-text); } \ No newline at end of file diff --git a/apps/web/src/components/atoms/ConditionBadge.stories.js b/apps/web/src/components/atoms/ConditionBadge.stories.js new file mode 100644 index 0000000..2754c72 --- /dev/null +++ b/apps/web/src/components/atoms/ConditionBadge.stories.js @@ -0,0 +1,5 @@ +import ConditionBadge from './ConditionBadge.vue'; + +export default { title: 'Atoms/ConditionBadge', component: ConditionBadge }; +export const Unselected = { args: { label: 'Near Mint', selected: false } }; +export const Selected = { args: { label: 'Near Mint', selected: true } }; \ No newline at end of file diff --git a/apps/web/src/components/atoms/ConditionBadge.vue b/apps/web/src/components/atoms/ConditionBadge.vue new file mode 100644 index 0000000..94ec55c --- /dev/null +++ b/apps/web/src/components/atoms/ConditionBadge.vue @@ -0,0 +1,17 @@ + + + + + \ No newline at end of file diff --git a/apps/web/src/components/atoms/PriceDisplay.scss b/apps/web/src/components/atoms/PriceDisplay.scss new file mode 100644 index 0000000..d1ce792 --- /dev/null +++ b/apps/web/src/components/atoms/PriceDisplay.scss @@ -0,0 +1,3 @@ +.price-display { display: inline-grid; gap: var(--space-1); } +.price-display__label { color: var(--color-text-muted); font-size: var(--text-xs); } +.price-display__value { color: var(--color-text); font-size: var(--text-lg); font-weight: var(--weight-bold); font-variant-numeric: tabular-nums; } \ No newline at end of file diff --git a/apps/web/src/components/atoms/PriceDisplay.stories.js b/apps/web/src/components/atoms/PriceDisplay.stories.js new file mode 100644 index 0000000..16d6336 --- /dev/null +++ b/apps/web/src/components/atoms/PriceDisplay.stories.js @@ -0,0 +1,5 @@ +import PriceDisplay from './PriceDisplay.vue'; + +export default { title: 'Atoms/PriceDisplay', component: PriceDisplay }; +export const AskingPrice = { args: { amountMinor: 11500 } }; +export const MarketReference = { args: { amountMinor: 12000, label: 'Market reference' } }; \ No newline at end of file diff --git a/apps/web/src/components/atoms/PriceDisplay.vue b/apps/web/src/components/atoms/PriceDisplay.vue new file mode 100644 index 0000000..3670bc6 --- /dev/null +++ b/apps/web/src/components/atoms/PriceDisplay.vue @@ -0,0 +1,17 @@ + + + + + \ No newline at end of file diff --git a/apps/web/src/components/atoms/StatusBadge.scss b/apps/web/src/components/atoms/StatusBadge.scss new file mode 100644 index 0000000..76b846d --- /dev/null +++ b/apps/web/src/components/atoms/StatusBadge.scss @@ -0,0 +1,5 @@ +.status { display: inline-flex; align-items: center; gap: var(--space-2); border-radius: var(--radius-full); padding: var(--space-2) var(--space-3); font-size: var(--text-xs); font-weight: var(--weight-bold); } +.status::before { width: 8px; height: 8px; flex: 0 0 auto; border-radius: var(--radius-full); background: currentColor; content: ''; } +.status--available { background: color-mix(in srgb, var(--color-success-500) 14%, transparent); color: var(--color-success-text); } +.status--pending { background: color-mix(in srgb, var(--color-warning-500) 16%, transparent); color: var(--color-warning-text); } +.status--sold { background: color-mix(in srgb, var(--color-danger-500) 14%, transparent); color: var(--color-danger-text); } \ No newline at end of file diff --git a/apps/web/src/components/atoms/StatusBadge.stories.js b/apps/web/src/components/atoms/StatusBadge.stories.js new file mode 100644 index 0000000..c69ee80 --- /dev/null +++ b/apps/web/src/components/atoms/StatusBadge.stories.js @@ -0,0 +1,6 @@ +import StatusBadge from './StatusBadge.vue'; + +export default { title: 'Atoms/StatusBadge', component: StatusBadge, argTypes: { status: { control: 'select', options: ['AVAILABLE', 'PENDING', 'SOLD'] } } }; +export const Available = { args: { status: 'AVAILABLE' } }; +export const Pending = { args: { status: 'PENDING' } }; +export const Sold = { args: { status: 'SOLD' } }; \ No newline at end of file diff --git a/apps/web/src/components/atoms/StatusBadge.vue b/apps/web/src/components/atoms/StatusBadge.vue new file mode 100644 index 0000000..88b0e86 --- /dev/null +++ b/apps/web/src/components/atoms/StatusBadge.vue @@ -0,0 +1,15 @@ + + + + + \ No newline at end of file diff --git a/apps/web/src/components/molecules/ImageGallery.scss b/apps/web/src/components/molecules/ImageGallery.scss new file mode 100644 index 0000000..e44b591 --- /dev/null +++ b/apps/web/src/components/molecules/ImageGallery.scss @@ -0,0 +1,7 @@ +.image-gallery { display: grid; gap: var(--space-3); } +.image-gallery__stage { display: grid; place-items: center; min-height: 280px; border-radius: var(--radius-xl); background: var(--color-stage); overflow: hidden; } +.image-gallery__stage img { width: 100%; height: 100%; min-height: 280px; object-fit: contain; } +.image-gallery__placeholder { color: var(--color-text-muted); font-family: var(--font-display); font-size: var(--text-2xl); font-weight: var(--weight-bold); } +.image-gallery__controls { display: flex; gap: var(--space-2); } +.image-gallery__controls button { min-width: 44px; min-height: 44px; border: var(--border-thin) solid var(--color-border-strong); border-radius: var(--radius-md); background: var(--color-surface); color: var(--color-text); } +.image-gallery__controls button.image-gallery__thumb--selected { border-color: var(--color-interactive); box-shadow: var(--glow-selection); color: var(--color-interactive); } \ No newline at end of file diff --git a/apps/web/src/components/molecules/ImageGallery.stories.js b/apps/web/src/components/molecules/ImageGallery.stories.js new file mode 100644 index 0000000..0289f10 --- /dev/null +++ b/apps/web/src/components/molecules/ImageGallery.stories.js @@ -0,0 +1,11 @@ +import ImageGallery from './ImageGallery.vue'; + +export default { title: 'Molecules/ImageGallery', component: ImageGallery }; +export const ContainedMuseumStage = { + args: { + images: [ + { id: 'front', label: 'FRONT', alt: 'Front of Charizard ex' }, + { id: 'back', label: 'BACK', alt: 'Back of Charizard ex' }, + ], + }, +}; \ No newline at end of file diff --git a/apps/web/src/components/molecules/ImageGallery.vue b/apps/web/src/components/molecules/ImageGallery.vue new file mode 100644 index 0000000..319577e --- /dev/null +++ b/apps/web/src/components/molecules/ImageGallery.vue @@ -0,0 +1,26 @@ + + + + + \ No newline at end of file diff --git a/apps/web/src/components/molecules/ListingCard.scss b/apps/web/src/components/molecules/ListingCard.scss index 18e4475..9fae504 100644 --- a/apps/web/src/components/molecules/ListingCard.scss +++ b/apps/web/src/components/molecules/ListingCard.scss @@ -14,7 +14,7 @@ } .listing-card:hover { border-color: var(--color-interactive); transform: translateY(-2px); } -.listing-card__mark { display: grid; place-items: center; aspect-ratio: 1; align-self: start; border-radius: var(--radius-md); background: var(--color-stage); color: var(--color-interactive); font: var(--weight-bold) var(--text-sm) var(--font-display); } +.listing-card__mark { display: grid; place-items: center; aspect-ratio: 1; align-self: start; border-radius: var(--radius-md); background: var(--color-stage); color: var(--color-interactive-text); font: var(--weight-bold) var(--text-sm) var(--font-display); } .listing-card__status { display: block; margin-top: 2px; } .listing-card__name { display: block; margin: var(--space-2) 0 var(--space-4); font-family: var(--font-display); font-size: var(--text-lg); } .listing-card__price { display: block; font-size: var(--text-md); font-weight: var(--weight-bold); font-variant-numeric: tabular-nums; } \ No newline at end of file diff --git a/apps/web/src/components/molecules/ListingCard.stories.js b/apps/web/src/components/molecules/ListingCard.stories.js index 0cf48f3..f43c395 100644 --- a/apps/web/src/components/molecules/ListingCard.stories.js +++ b/apps/web/src/components/molecules/ListingCard.stories.js @@ -1,4 +1,8 @@ -export default { title: 'Molecules/ListingCard' }; +import { expect, userEvent, within } from '@storybook/test'; + +import ListingCard from './ListingCard.vue'; + +export default { title: 'Molecules/ListingCard', component: ListingCard }; export const available = { args: { @@ -31,4 +35,13 @@ export const sold = { status: 'SOLD', }, }, +}; + +export const KeyboardReachable = { + args: available.args, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.tab(); + await expect(canvas.getByRole('link')).toHaveFocus(); + }, }; \ No newline at end of file diff --git a/apps/web/src/components/molecules/ListingCard.vue b/apps/web/src/components/molecules/ListingCard.vue index 891f36f..e29f36f 100644 --- a/apps/web/src/components/molecules/ListingCard.vue +++ b/apps/web/src/components/molecules/ListingCard.vue @@ -1,5 +1,6 @@