This commit is contained in:
@@ -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
|
||||||
@@ -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.
|
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
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"seed:demo": "node src/modules/catalog/scripts/seed-demo-catalog.js",
|
||||||
"provision:admin": "node src/modules/auth/scripts/provision-initial-admin.js",
|
"provision:admin": "node src/modules/auth/scripts/provision-initial-admin.js",
|
||||||
"start": "node src/app/server.js",
|
"start": "node src/app/server.js",
|
||||||
"test:application": "node --test tests/application/*.test.js",
|
"test:application": "node --test tests/application/*.test.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;
|
||||||
|
});
|
||||||
@@ -21,6 +21,6 @@ export const decorators = [
|
|||||||
|
|
||||||
export const parameters = {
|
export const parameters = {
|
||||||
controls: { expanded: true },
|
controls: { expanded: true },
|
||||||
a11y: { test: 'todo' },
|
a11y: { test: 'error' },
|
||||||
layout: 'fullscreen',
|
layout: 'fullscreen',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"storybook": "storybook dev -p 6006",
|
"storybook": "storybook dev -p 6006",
|
||||||
"build-storybook": "storybook build",
|
"build-storybook": "storybook build",
|
||||||
|
"test-storybook": "test-storybook --url http://127.0.0.1:6006 --ci",
|
||||||
"test": "node --test tests/*.test.js"
|
"test": "node --test tests/*.test.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -17,6 +17,10 @@
|
|||||||
--color-warning-500: #d88a00;
|
--color-warning-500: #d88a00;
|
||||||
--color-danger-500: #e5484d;
|
--color-danger-500: #e5484d;
|
||||||
--color-info-500: #1683c4;
|
--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-ui: 'Manrope', ui-sans-serif, system-ui, sans-serif;
|
||||||
--font-display: 'Space Grotesk', 'Manrope', ui-sans-serif, sans-serif;
|
--font-display: 'Space Grotesk', 'Manrope', ui-sans-serif, sans-serif;
|
||||||
@@ -77,6 +81,7 @@
|
|||||||
--color-border: var(--color-gray-200);
|
--color-border: var(--color-gray-200);
|
||||||
--color-border-strong: var(--color-gray-300);
|
--color-border-strong: var(--color-gray-300);
|
||||||
--color-interactive: var(--color-cobalt-500);
|
--color-interactive: var(--color-cobalt-500);
|
||||||
|
--color-interactive-text: var(--color-cobalt-700);
|
||||||
--color-interactive-hover: var(--color-cobalt-700);
|
--color-interactive-hover: var(--color-cobalt-700);
|
||||||
--color-on-interactive: var(--color-white);
|
--color-on-interactive: var(--color-white);
|
||||||
--color-focus: var(--color-cobalt-500);
|
--color-focus: var(--color-cobalt-500);
|
||||||
@@ -97,6 +102,7 @@
|
|||||||
--color-border: #2b3745;
|
--color-border: #2b3745;
|
||||||
--color-border-strong: #425064;
|
--color-border-strong: #425064;
|
||||||
--color-interactive: var(--color-cobalt-400);
|
--color-interactive: var(--color-cobalt-400);
|
||||||
|
--color-interactive-text: var(--color-cobalt-400);
|
||||||
--color-interactive-hover: #83adff;
|
--color-interactive-hover: #83adff;
|
||||||
--color-on-interactive: #07142f;
|
--color-on-interactive: #07142f;
|
||||||
--color-focus: var(--color-cobalt-400);
|
--color-focus: var(--color-cobalt-400);
|
||||||
|
|||||||
@@ -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 { 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--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--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; }
|
.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 { 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); }
|
.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; }
|
.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 { 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::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--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-500); }
|
.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-500); }
|
.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 { 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 { 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--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); }
|
.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-stack { display: grid; gap: var(--space-3); }
|
||||||
.state { border-radius: var(--radius-lg); padding: var(--space-4); }
|
.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--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--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-500); }
|
.state--error { background: color-mix(in srgb, var(--color-danger-500) 10%, transparent); color: var(--color-danger-text); }
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.catalog-header { padding-top: var(--space-16); }
|
.catalog-header { padding-top: var(--space-16); }
|
||||||
|
|||||||
@@ -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); }
|
||||||
@@ -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 } };
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
label: { type: String, required: true },
|
||||||
|
selected: { type: Boolean, default: false },
|
||||||
|
});
|
||||||
|
defineEmits(['select']);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button :class="['condition', { 'condition--selected': selected }]" type="button" @click="$emit('select')">
|
||||||
|
{{ label }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
@use './ConditionBadge.scss';
|
||||||
|
</style>
|
||||||
@@ -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; }
|
||||||
@@ -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' } };
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
amountMinor: { type: Number, required: true },
|
||||||
|
label: { type: String, default: 'Asking price' },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span class="price-display">
|
||||||
|
<span class="price-display__label">{{ label }}</span>
|
||||||
|
<span class="price-display__value">${{ (amountMinor / 100).toFixed(2) }}</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
@use './PriceDisplay.scss';
|
||||||
|
</style>
|
||||||
@@ -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); }
|
||||||
@@ -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' } };
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
status: { type: String, required: true },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span :class="['status', `status--${status.toLowerCase()}`]">
|
||||||
|
{{ status }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
@use './StatusBadge.scss';
|
||||||
|
</style>
|
||||||
@@ -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); }
|
||||||
@@ -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' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
images: { type: Array, required: true },
|
||||||
|
});
|
||||||
|
const selectedIndex = ref(0);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="image-gallery">
|
||||||
|
<div class="image-gallery__stage">
|
||||||
|
<img v-if="props.images[selectedIndex]?.src" :src="props.images[selectedIndex].src" :alt="props.images[selectedIndex].alt" />
|
||||||
|
<span v-else class="image-gallery__placeholder" aria-label="No collectible image available">{{ props.images[selectedIndex]?.label ?? 'IMAGE' }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="props.images.length > 1" class="image-gallery__controls" aria-label="Gallery images">
|
||||||
|
<button v-for="(image, index) in props.images" :key="image.id" :class="{ 'image-gallery__thumb--selected': index === selectedIndex }" type="button" :aria-label="`Show image ${index + 1}: ${image.alt}`" @click="selectedIndex = index">
|
||||||
|
{{ index + 1 }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
@use './ImageGallery.scss';
|
||||||
|
</style>
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.listing-card:hover { border-color: var(--color-interactive); transform: translateY(-2px); }
|
.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__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__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; }
|
.listing-card__price { display: block; font-size: var(--text-md); font-weight: var(--weight-bold); font-variant-numeric: tabular-nums; }
|
||||||
@@ -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 = {
|
export const available = {
|
||||||
args: {
|
args: {
|
||||||
@@ -31,4 +35,13 @@ export const sold = {
|
|||||||
status: '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();
|
||||||
|
},
|
||||||
};
|
};
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { RouterLink } from 'vue-router';
|
import PriceDisplay from '@/components/atoms/PriceDisplay.vue';
|
||||||
|
import StatusBadge from '@/components/atoms/StatusBadge.vue';
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
listing: {
|
listing: {
|
||||||
@@ -10,14 +11,14 @@ defineProps({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<RouterLink class="listing-card" :to="{ name: 'listing-detail', params: { id: listing.id } }">
|
<a class="listing-card" :href="`/listings/${encodeURIComponent(listing.id)}`">
|
||||||
<span class="listing-card__mark" aria-hidden="true">PK</span>
|
<span class="listing-card__mark" aria-hidden="true">PK</span>
|
||||||
<span>
|
<span>
|
||||||
<span class="listing-card__status">{{ listing.status }}</span>
|
<StatusBadge :status="listing.status" />
|
||||||
<span class="listing-card__name">{{ listing.productName }}</span>
|
<span class="listing-card__name">{{ listing.productName }}</span>
|
||||||
<span class="listing-card__price">${{ (listing.askingPriceMinor / 100).toFixed(2) }}</span>
|
<PriceDisplay :amount-minor="listing.askingPriceMinor" />
|
||||||
</span>
|
</span>
|
||||||
</RouterLink>
|
</a>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
import ConditionBadge from '@/components/atoms/ConditionBadge.vue';
|
||||||
|
import PriceDisplay from '@/components/atoms/PriceDisplay.vue';
|
||||||
|
import StatusBadge from '@/components/atoms/StatusBadge.vue';
|
||||||
|
import ImageGallery from '@/components/molecules/ImageGallery.vue';
|
||||||
|
|
||||||
const selectedCondition = ref('Near Mint');
|
const selectedCondition = ref('Near Mint');
|
||||||
const listings = [
|
const listings = [
|
||||||
{ id: 'fixture-charizard', productName: 'Charizard ex #199/165', askingPriceMinor: 11500, status: 'AVAILABLE' },
|
{ id: 'fixture-charizard', productName: 'Charizard ex #199/165', askingPriceMinor: 11500, status: 'AVAILABLE' },
|
||||||
@@ -59,17 +64,15 @@ const listings = [
|
|||||||
<article class="showcase-panel">
|
<article class="showcase-panel">
|
||||||
<h3>Status treatments</h3>
|
<h3>Status treatments</h3>
|
||||||
<div class="status-row">
|
<div class="status-row">
|
||||||
<span class="status status--available">Available</span>
|
<StatusBadge status="AVAILABLE" />
|
||||||
<span class="status status--pending">Pending</span>
|
<StatusBadge status="PENDING" />
|
||||||
<span class="status status--sold">Sold</span>
|
<StatusBadge status="SOLD" />
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
<article class="showcase-panel">
|
<article class="showcase-panel">
|
||||||
<h3>Condition selection</h3>
|
<h3>Condition selection</h3>
|
||||||
<div class="control-row">
|
<div class="control-row">
|
||||||
<button v-for="condition in ['Near Mint', 'Lightly Played', 'Damaged']" :key="condition" class="condition" :class="{ 'condition--selected': selectedCondition === condition }" type="button" @click="selectedCondition = condition">
|
<ConditionBadge v-for="condition in ['Near Mint', 'Lightly Played', 'Damaged']" :key="condition" :label="condition" :selected="selectedCondition === condition" @select="selectedCondition = condition" />
|
||||||
{{ condition }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="fixture-label">Selected: {{ selectedCondition }}</p>
|
<p class="fixture-label">Selected: {{ selectedCondition }}</p>
|
||||||
</article>
|
</article>
|
||||||
@@ -92,8 +95,8 @@ const listings = [
|
|||||||
<h3>{{ listing.productName }}</h3>
|
<h3>{{ listing.productName }}</h3>
|
||||||
<p class="fixture-card__meta">Condition: {{ listing.status === 'SOLD' ? 'Lightly Played' : 'Near Mint' }}</p>
|
<p class="fixture-card__meta">Condition: {{ listing.status === 'SOLD' ? 'Lightly Played' : 'Near Mint' }}</p>
|
||||||
</div>
|
</div>
|
||||||
<span :class="['status', `status--${listing.status.toLowerCase()}`]">{{ listing.status }}</span>
|
<StatusBadge :status="listing.status" />
|
||||||
<p v-if="listing.status !== 'SOLD'" class="fixture-card__price">${{ (listing.askingPriceMinor / 100).toFixed(2) }}</p>
|
<PriceDisplay v-if="listing.status !== 'SOLD'" :amount-minor="listing.askingPriceMinor" />
|
||||||
<p v-else class="fixture-card__meta">Sold history retained</p>
|
<p v-else class="fixture-card__meta">Sold history retained</p>
|
||||||
<div class="fixture-card__actions">
|
<div class="fixture-card__actions">
|
||||||
<button class="button button--primary" type="button">View collectible</button>
|
<button class="button button--primary" type="button">View collectible</button>
|
||||||
@@ -117,7 +120,7 @@ const listings = [
|
|||||||
<h3>Listing detail</h3>
|
<h3>Listing detail</h3>
|
||||||
<p class="catalog-kicker">Available</p>
|
<p class="catalog-kicker">Available</p>
|
||||||
<h3>Asking price</h3>
|
<h3>Asking price</h3>
|
||||||
<p class="fixture-card__price">$115.00</p>
|
<PriceDisplay :amount-minor="11500" />
|
||||||
<dl class="product-facts">
|
<dl class="product-facts">
|
||||||
<div><dt>Condition</dt><dd>Near Mint</dd></div>
|
<div><dt>Condition</dt><dd>Near Mint</dd></div>
|
||||||
<div><dt>Market context</dt><dd>$120 reference</dd></div>
|
<div><dt>Market context</dt><dd>$120 reference</dd></div>
|
||||||
@@ -127,7 +130,7 @@ const listings = [
|
|||||||
</article>
|
</article>
|
||||||
<article class="showcase-panel">
|
<article class="showcase-panel">
|
||||||
<h3>Image treatment</h3>
|
<h3>Image treatment</h3>
|
||||||
<div class="fixture-stage fixture-stage--image" aria-label="Museum-style contained collectible image stage"><span class="fixture-stage__glyph">GALLERY</span></div>
|
<ImageGallery :images="[{ id: 'front', label: 'FRONT', alt: 'Front of Charizard ex' }, { id: 'back', label: 'BACK', alt: 'Back of Charizard ex' }]" />
|
||||||
<p class="fixture-card__meta">Contained, neutral, aspect-ratio preserving, and free of cobalt image backgrounds.</p>
|
<p class="fixture-card__meta">Contained, neutral, aspect-ratio preserving, and free of cobalt image backgrounds.</p>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Generated
+7006
-1
File diff suppressed because it is too large
Load Diff
@@ -17,7 +17,9 @@
|
|||||||
"dev:web": "npm run dev --workspace=@pokemon-inventory/web",
|
"dev:web": "npm run dev --workspace=@pokemon-inventory/web",
|
||||||
"storybook": "npm run storybook --workspace=@pokemon-inventory/web",
|
"storybook": "npm run storybook --workspace=@pokemon-inventory/web",
|
||||||
"build-storybook": "npm run build-storybook --workspace=@pokemon-inventory/web",
|
"build-storybook": "npm run build-storybook --workspace=@pokemon-inventory/web",
|
||||||
|
"test-storybook": "concurrently --kill-others --success first \"npm run storybook\" \"wait-on http://127.0.0.1:6006 && npm run test-storybook --workspace=@pokemon-inventory/web\"",
|
||||||
"provision:admin": "npm run provision:admin --workspace=@pokemon-inventory/api",
|
"provision:admin": "npm run provision:admin --workspace=@pokemon-inventory/api",
|
||||||
|
"seed:demo": "npm run seed:demo --workspace=@pokemon-inventory/api",
|
||||||
"test:application": "npm run test:application --workspace=@pokemon-inventory/api",
|
"test:application": "npm run test:application --workspace=@pokemon-inventory/api",
|
||||||
"test:domain": "npm run test:domain --workspace=@pokemon-inventory/api",
|
"test:domain": "npm run test:domain --workspace=@pokemon-inventory/api",
|
||||||
"test:http": "npm run test:http --workspace=@pokemon-inventory/api",
|
"test:http": "npm run test:http --workspace=@pokemon-inventory/api",
|
||||||
@@ -27,10 +29,13 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@storybook/addon-a11y": "^8.6.14",
|
"@storybook/addon-a11y": "^8.6.14",
|
||||||
"@storybook/addon-essentials": "^8.6.14",
|
"@storybook/addon-essentials": "^8.6.14",
|
||||||
|
"@storybook/test": "^8.6.14",
|
||||||
|
"@storybook/test-runner": "^0.23.0",
|
||||||
"@storybook/vue3-vite": "^8.6.14",
|
"@storybook/vue3-vite": "^8.6.14",
|
||||||
"concurrently": "^10.0.5",
|
"concurrently": "^10.0.5",
|
||||||
"dotenv": "^16.6.1",
|
"dotenv": "^16.6.1",
|
||||||
"drizzle-kit": "^0.31.8",
|
"drizzle-kit": "^0.31.8",
|
||||||
"storybook": "^8.6.14"
|
"storybook": "^8.6.14"
|
||||||
|
,"wait-on": "^8.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user