feat: reconcile application vertical slices

Backfill the completed acquisitions, sales, dashboard, pricing, search, public detail, worker, migration, contracts, validation, tests, and implementation-status changes into one verified application slice.
This commit is contained in:
2026-09-09 22:12:53 -04:00
parent b9ff7445a6
commit b8f089a5d9
78 changed files with 4561 additions and 2057 deletions
@@ -1,11 +1,11 @@
import ImageGallery from './ImageGallery.vue';
import ImageGallery from "./ImageGallery.vue";
export default { title: 'Molecules/ImageGallery', component: ImageGallery };
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' },
{ id: "front", label: "FRONT", alt: "Front of Charizard ex" },
{ id: "back", label: "BACK", alt: "Back of Charizard ex" },
],
},
};
@@ -13,21 +13,37 @@ export const ContainedMuseumStage = {
export const WithPublicImageUrls = {
args: {
images: [
{ id: 'front', src: 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="640" height="480"%3E%3Crect width="100%" height="100%" fill="%23d9e2ef"/%3E%3Ctext x="50%" y="50%" text-anchor="middle" fill="%231761ff" font-size="42"%3EFRONT%3C/text%3E%3C/svg%3E', alt: 'Front of Charizard ex' },
{ id: 'back', label: 'BACK', alt: 'Back of Charizard ex' },
{
id: "front",
src: 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="640" height="480"%3E%3Crect width="100%" height="100%" fill="%23d9e2ef"/%3E%3Ctext x="50%" y="50%" text-anchor="middle" fill="%231761ff" font-size="42"%3EFRONT%3C/text%3E%3C/svg%3E',
alt: "Front of Charizard ex",
},
{ id: "back", label: "BACK", alt: "Back of Charizard ex" },
],
},
};
export const BrokenImageFallback = {
args: {
images: [{ id: 'broken', src: '/fixtures/missing-image.webp', alt: 'Unavailable collectible image' }],
images: [
{
id: "broken",
src: "/fixtures/missing-image.webp",
alt: "Unavailable collectible image",
},
],
},
};
export const LoadingImage = {
args: {
images: [{ id: 'loading', src: '/fixtures/slow-image.webp', alt: 'Collectible image loading' }],
images: [
{
id: "loading",
src: "/fixtures/slow-image.webp",
alt: "Collectible image loading",
},
],
},
parameters: { chromatic: { delay: 1000 } },
};
};
@@ -1,5 +1,5 @@
<script setup>
import { onMounted, ref } from 'vue';
import { onMounted, ref } from "vue";
const props = defineProps({
images: { type: Array, required: true },
@@ -9,7 +9,7 @@ const failedImages = ref(new Set());
const loadingImages = ref(new Set());
function imageKey(image) {
return image?.id ?? '';
return image?.id ?? "";
}
function markLoading(image) {
@@ -36,12 +36,67 @@ onMounted(() => markLoading(props.images[selectedIndex.value]));
<template>
<div class="image-gallery">
<div class="image-gallery__stage">
<img v-if="(props.images[selectedIndex]?.src ?? props.images[selectedIndex]?.url) && !failedImages.has(props.images[selectedIndex].id)" :src="props.images[selectedIndex].src ?? props.images[selectedIndex].url" :alt="props.images[selectedIndex].altText ?? props.images[selectedIndex].alt" @load="markLoaded(props.images[selectedIndex].id)" @error="markFailed(props.images[selectedIndex].id)" />
<span v-if="loadingImages.has(imageKey(props.images[selectedIndex]))" class="image-gallery__placeholder" role="status" aria-label="Loading collectible image">Loading image...</span>
<span v-else-if="!((props.images[selectedIndex]?.src ?? props.images[selectedIndex]?.url) && !failedImages.has(props.images[selectedIndex].id))" class="image-gallery__placeholder" role="img" :aria-label="props.images[selectedIndex]?.src ? 'Collectible image unavailable' : 'No collectible image available'">{{ props.images[selectedIndex]?.src ? 'IMAGE UNAVAILABLE' : (props.images[selectedIndex]?.label ?? 'IMAGE') }}</span>
<img
v-if="
(props.images[selectedIndex]?.src ??
props.images[selectedIndex]?.url) &&
!failedImages.has(props.images[selectedIndex].id)
"
:src="
props.images[selectedIndex].src ?? props.images[selectedIndex].url
"
:alt="
props.images[selectedIndex].altText ?? props.images[selectedIndex].alt
"
@load="markLoaded(props.images[selectedIndex].id)"
@error="markFailed(props.images[selectedIndex].id)"
/>
<span
v-if="loadingImages.has(imageKey(props.images[selectedIndex]))"
class="image-gallery__placeholder"
role="status"
aria-label="Loading collectible image"
>Loading image...</span
>
<span
v-else-if="
!(
(props.images[selectedIndex]?.src ??
props.images[selectedIndex]?.url) &&
!failedImages.has(props.images[selectedIndex].id)
)
"
class="image-gallery__placeholder"
role="img"
:aria-label="
props.images[selectedIndex]?.src
? 'Collectible image unavailable'
: 'No collectible image available'
"
>{{
props.images[selectedIndex]?.src
? "IMAGE UNAVAILABLE"
: (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}`" :aria-current="index === selectedIndex ? 'true' : undefined" @click="selectedIndex = index; markLoading(image)">
<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}`"
:aria-current="index === selectedIndex ? 'true' : undefined"
@click="
selectedIndex = index;
markLoading(image);
"
>
{{ index + 1 }}
</button>
</div>
@@ -49,5 +104,5 @@ onMounted(() => markLoading(props.images[selectedIndex.value]));
</template>
<style scoped lang="scss">
@use './ImageGallery.scss';
</style>
@use "./ImageGallery.scss";
</style>
@@ -1,32 +1,36 @@
import CollectibleDetail from './CollectibleDetail.vue';
import CollectibleDetail from "./CollectibleDetail.vue";
export default { title: 'Organisms/CollectibleDetail', component: CollectibleDetail };
export default {
title: "Organisms/CollectibleDetail",
component: CollectibleDetail,
};
export const ListingDetail = {
args: {
title: 'Charizard ex #199/165',
status: 'AVAILABLE',
title: "Charizard ex #199/165",
status: "AVAILABLE",
priceMinor: 11500,
marketValueMinor: 12000,
condition: 'Near Mint',
description: 'A clean collector copy with a clear front and back.',
condition: "Near Mint",
description: "A clean collector copy with a clear front and back.",
facts: [
{ label: 'Set', value: 'Scarlet & Violet 151' },
{ label: 'Card number', value: '199/165' },
{ label: 'Market context', value: '$120 reference' },
{ label: 'Updated', value: 'Today' },
{ label: "Set", value: "Scarlet & Violet 151" },
{ label: "Card number", value: "199/165" },
{ label: "Market context", value: "$120 reference" },
{ label: "Updated", value: "Today" },
],
images: [
{ id: 'front', label: 'FRONT', alt: 'Front of Charizard ex' },
{ id: 'back', label: 'BACK', alt: 'Back of Charizard ex' },
{ id: "front", label: "FRONT", alt: "Front of Charizard ex" },
{ id: "back", label: "BACK", alt: "Back of Charizard ex" },
],
},
};
export const ProductWithoutImages = {
args: {
title: 'Pikachu World Collection',
description: 'A representative product identity with images still to be added.',
facts: [{ label: 'Category', value: 'Collectible merchandise' }],
title: "Pikachu World Collection",
description:
"A representative product identity with images still to be added.",
facts: [{ label: "Category", value: "Collectible merchandise" }],
},
};
};
@@ -1,17 +1,17 @@
<script setup>
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';
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";
defineProps({
title: { type: String, required: true },
status: { type: String, default: '' },
status: { type: String, default: "" },
priceMinor: { type: Number, default: null },
marketValueMinor: { type: Number, default: null },
marketValueCurrency: { type: String, default: 'USD' },
condition: { type: String, default: '' },
description: { type: String, default: '' },
marketValueCurrency: { type: String, default: "USD" },
condition: { type: String, default: "" },
description: { type: String, default: "" },
facts: { type: Array, default: () => [] },
images: { type: Array, default: () => [] },
});
@@ -29,7 +29,11 @@ defineProps({
<p v-if="description" class="detail-notes">{{ description }}</p>
<div class="collectible-detail__summary">
<PriceDisplay v-if="priceMinor !== null" :amount-minor="priceMinor" />
<PriceDisplay v-if="marketValueMinor !== null" label="Market value" :amount-minor="marketValueMinor" />
<PriceDisplay
v-if="marketValueMinor !== null"
label="Market value"
:amount-minor="marketValueMinor"
/>
<ConditionBadge v-if="condition" :label="condition" />
</div>
<dl v-if="facts.length" class="product-facts">
@@ -43,9 +47,33 @@ defineProps({
</template>
<style scoped lang="scss">
.collectible-detail { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: var(--space-12); align-items: start; }
.collectible-detail__identity { display: grid; gap: var(--space-4); padding-top: var(--space-6); }
.collectible-detail__identity h1 { margin: 0; }
.collectible-detail__summary { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-4); }
@media (max-width: 768px) { .collectible-detail { grid-template-columns: 1fr; gap: var(--space-6); } .collectible-detail__identity { padding-top: 0; } }
</style>
.collectible-detail {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: var(--space-12);
align-items: start;
}
.collectible-detail__identity {
display: grid;
gap: var(--space-4);
padding-top: var(--space-6);
}
.collectible-detail__identity h1 {
margin: 0;
}
.collectible-detail__summary {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-4);
}
@media (max-width: 768px) {
.collectible-detail {
grid-template-columns: 1fr;
gap: var(--space-6);
}
.collectible-detail__identity {
padding-top: 0;
}
}
</style>
+46 -11
View File
@@ -1,8 +1,16 @@
import { ref, watch } from 'vue';
import { ref, watch } from "vue";
import { searchCatalog } from '@/services/catalog-service.js';
import { searchCatalog } from "@/services/catalog-service.js";
export function useCatalogSearch({ initialQuery = '', initialCategory = '', initialConditionId = '', initialMinPriceMinor, initialMaxPriceMinor, initialSort = 'recent', initialPage = 1 } = {}) {
export function useCatalogSearch({
initialQuery = "",
initialCategory = "",
initialConditionId = "",
initialMinPriceMinor,
initialMaxPriceMinor,
initialSort = "recent",
initialPage = 1,
} = {}) {
const query = ref(initialQuery);
const category = ref(initialCategory);
const conditionId = ref(initialConditionId);
@@ -10,7 +18,10 @@ export function useCatalogSearch({ initialQuery = '', initialCategory = '', init
const maxPriceMinor = ref(initialMaxPriceMinor);
const sort = ref(initialSort);
const page = ref(initialPage);
const result = ref({ items: [], pagination: { page: 1, limit: 20, total: 0, totalPages: 0 } });
const result = ref({
items: [],
pagination: { page: 1, limit: 20, total: 0, totalPages: 0 },
});
const isLoading = ref(false);
const error = ref(null);
let requestId = 0;
@@ -21,7 +32,15 @@ export function useCatalogSearch({ initialQuery = '', initialCategory = '', init
error.value = null;
try {
const nextResult = await searchCatalog({ query: query.value, category: category.value, conditionId: conditionId.value, minPriceMinor: minPriceMinor.value, maxPriceMinor: maxPriceMinor.value, sort: sort.value, page: page.value });
const nextResult = await searchCatalog({
query: query.value,
category: category.value,
conditionId: conditionId.value,
minPriceMinor: minPriceMinor.value,
maxPriceMinor: maxPriceMinor.value,
sort: sort.value,
page: page.value,
});
if (currentRequestId === requestId) result.value = nextResult;
} catch (requestError) {
if (currentRequestId === requestId) error.value = requestError;
@@ -30,10 +49,13 @@ export function useCatalogSearch({ initialQuery = '', initialCategory = '', init
}
}
watch([query, category, conditionId, minPriceMinor, maxPriceMinor, sort], () => {
if (page.value !== 1) page.value = 1;
else load();
});
watch(
[query, category, conditionId, minPriceMinor, maxPriceMinor, sort],
() => {
if (page.value !== 1) page.value = 1;
else load();
},
);
watch(page, load);
function goToPage(nextPage) {
@@ -42,5 +64,18 @@ export function useCatalogSearch({ initialQuery = '', initialCategory = '', init
}
}
return { query, category, conditionId, minPriceMinor, maxPriceMinor, sort, page, result, isLoading, error, load, goToPage };
}
return {
query,
category,
conditionId,
minPriceMinor,
maxPriceMinor,
sort,
page,
result,
isLoading,
error,
load,
goToPage,
};
}
+10 -9
View File
@@ -1,14 +1,15 @@
import AdminHomePage from './AdminHomePage.vue';
import AdminHomePage from "./AdminHomePage.vue";
export default { title: 'Pages/AdminHomePage', component: AdminHomePage };
export default { title: "Pages/AdminHomePage", component: AdminHomePage };
export const Overview = {};
export const ProviderMappingWorkflow = {
parameters: {
docs: {
description: {
story: 'The admin workspace includes Product-level provider mapping controls alongside inventory, acquisition, pricing, listing, and sales workflows.',
},
},
},
parameters: {
docs: {
description: {
story:
"The admin workspace includes Product-level provider mapping controls alongside inventory, acquisition, pricing, listing, and sales workflows.",
},
},
},
};
File diff suppressed because it is too large Load Diff
+125 -38
View File
@@ -1,46 +1,84 @@
<script setup>
import { onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useCatalogSearch } from '@/composables/useCatalogSearch.js';
import { listPublicCategories, listPublicConditions } from '@/services/catalog-service.js';
import ListingCard from '@/components/molecules/ListingCard.vue';
import { useCatalogSearch } from "@/composables/useCatalogSearch.js";
import {
listPublicCategories,
listPublicConditions,
} from "@/services/catalog-service.js";
import ListingCard from "@/components/molecules/ListingCard.vue";
const route = useRoute();
const router = useRouter();
const categories = ref([]);
const conditions = ref([]);
const { query, category, conditionId, minPriceMinor, maxPriceMinor, sort, page, result, isLoading, error, load, goToPage } = useCatalogSearch({
initialQuery: typeof route.query.q === 'string' ? route.query.q : '',
initialCategory: typeof route.query.category === 'string' ? route.query.category : '',
initialConditionId: typeof route.query.conditionId === 'string' ? route.query.conditionId : '',
const {
query,
category,
conditionId,
minPriceMinor,
maxPriceMinor,
sort,
page,
result,
isLoading,
error,
load,
goToPage,
} = useCatalogSearch({
initialQuery: typeof route.query.q === "string" ? route.query.q : "",
initialCategory:
typeof route.query.category === "string" ? route.query.category : "",
initialConditionId:
typeof route.query.conditionId === "string" ? route.query.conditionId : "",
initialMinPriceMinor: Number.parseInt(route.query.min, 10) || undefined,
initialMaxPriceMinor: Number.parseInt(route.query.max, 10) || undefined,
initialSort: typeof route.query.sort === 'string' ? route.query.sort : 'recent',
initialSort:
typeof route.query.sort === "string" ? route.query.sort : "recent",
initialPage: Number.parseInt(route.query.page, 10) || 1,
});
onMounted(async () => {
await Promise.all([
load(),
listPublicCategories().then((items) => { categories.value = items; }).catch(() => {}),
listPublicConditions().then((items) => { conditions.value = items; }).catch(() => {}),
listPublicCategories()
.then((items) => {
categories.value = items;
})
.catch(() => {}),
listPublicConditions()
.then((items) => {
conditions.value = items;
})
.catch(() => {}),
]);
});
watch([query, category, conditionId, minPriceMinor, maxPriceMinor, sort, page], ([nextQuery, nextCategory, nextCondition, nextMin, nextMax, nextSort, nextPage]) => {
router.replace({
query: {
...(nextQuery ? { q: nextQuery } : {}),
...(nextCategory ? { category: nextCategory } : {}),
...(nextCondition ? { conditionId: nextCondition } : {}),
...(typeof nextMin === 'number' ? { min: String(nextMin) } : {}),
...(typeof nextMax === 'number' ? { max: String(nextMax) } : {}),
...(nextSort !== 'recent' ? { sort: nextSort } : {}),
...(nextPage > 1 ? { page: String(nextPage) } : {}),
},
});
});
watch(
[query, category, conditionId, minPriceMinor, maxPriceMinor, sort, page],
([
nextQuery,
nextCategory,
nextCondition,
nextMin,
nextMax,
nextSort,
nextPage,
]) => {
router.replace({
query: {
...(nextQuery ? { q: nextQuery } : {}),
...(nextCategory ? { category: nextCategory } : {}),
...(nextCondition ? { conditionId: nextCondition } : {}),
...(typeof nextMin === "number" ? { min: String(nextMin) } : {}),
...(typeof nextMax === "number" ? { max: String(nextMax) } : {}),
...(nextSort !== "recent" ? { sort: nextSort } : {}),
...(nextPage > 1 ? { page: String(nextPage) } : {}),
},
});
},
);
</script>
<template>
@@ -48,17 +86,52 @@ watch([query, category, conditionId, minPriceMinor, maxPriceMinor, sort, page],
<header class="catalog-header">
<p class="catalog-kicker">Pokemon inventory</p>
<h1>Find the next favorite.</h1>
<p class="catalog-intro">Browse available cards, collectibles, and carefully cataloged pieces.</p>
<p class="catalog-intro">
Browse available cards, collectibles, and carefully cataloged pieces.
</p>
<label class="search-field">
<span>Search the catalog</span>
<input v-model="query" type="search" placeholder="Try Charizard, 151, plush..." />
<input
v-model="query"
type="search"
placeholder="Try Charizard, 151, plush..."
/>
</label>
<div class="catalog-filters">
<label>Category <select v-model="category"><option value="">All categories</option><option v-for="item in categories" :key="item.id" :value="item.id">{{ item.name }}</option></select></label>
<label>Condition <select v-model="conditionId"><option value="">All conditions</option><option v-for="item in conditions" :key="item.id" :value="item.id">{{ item.name }} ({{ item.code }})</option></select></label>
<label>Min price (cents) <input v-model.number="minPriceMinor" type="number" min="0" /></label>
<label>Max price (cents) <input v-model.number="maxPriceMinor" type="number" min="0" /></label>
<label>Sort <select v-model="sort"><option value="recent">Recently added</option><option value="price_low">Price: low to high</option><option value="price_high">Price: high to low</option></select></label>
<label
>Category
<select v-model="category">
<option value="">All categories</option>
<option v-for="item in categories" :key="item.id" :value="item.id">
{{ item.name }}
</option>
</select></label
>
<label
>Condition
<select v-model="conditionId">
<option value="">All conditions</option>
<option v-for="item in conditions" :key="item.id" :value="item.id">
{{ item.name }} ({{ item.code }})
</option>
</select></label
>
<label
>Min price (cents)
<input v-model.number="minPriceMinor" type="number" min="0"
/></label>
<label
>Max price (cents)
<input v-model.number="maxPriceMinor" type="number" min="0"
/></label>
<label
>Sort
<select v-model="sort">
<option value="recent">Recently added</option>
<option value="price_low">Price: low to high</option>
<option value="price_high">Price: high to low</option>
</select></label
>
</div>
</header>
@@ -69,13 +142,25 @@ watch([query, category, conditionId, minPriceMinor, maxPriceMinor, sort, page],
</div>
<p v-if="isLoading" class="catalog-state">Searching the catalog...</p>
<p v-else-if="error" class="catalog-state catalog-state--error">{{ error.message }}</p>
<p v-else-if="result.items.length === 0" class="catalog-state">No relevant matches found.</p>
<p v-else-if="error" class="catalog-state catalog-state--error">
{{ error.message }}
</p>
<p v-else-if="result.items.length === 0" class="catalog-state">
No relevant matches found.
</p>
<div v-else class="listing-grid">
<ListingCard v-for="item in result.items" :key="item.id" :listing="item" />
<ListingCard
v-for="item in result.items"
:key="item.id"
:listing="item"
/>
</div>
<nav v-if="result.pagination.totalPages > 1" class="pagination" aria-label="Catalog pages">
<nav
v-if="result.pagination.totalPages > 1"
class="pagination"
aria-label="Catalog pages"
>
<button
type="button"
:disabled="page <= 1 || isLoading"
@@ -83,7 +168,9 @@ watch([query, category, conditionId, minPriceMinor, maxPriceMinor, sort, page],
>
Previous
</button>
<span aria-live="polite">Page {{ page }} of {{ result.pagination.totalPages }}</span>
<span aria-live="polite"
>Page {{ page }} of {{ result.pagination.totalPages }}</span
>
<button
type="button"
:disabled="page >= result.pagination.totalPages || isLoading"
@@ -94,4 +181,4 @@ watch([query, category, conditionId, minPriceMinor, maxPriceMinor, sort, page],
</nav>
</section>
</main>
</template>
</template>
+16 -8
View File
@@ -1,9 +1,9 @@
<script setup>
import { onMounted, ref } from 'vue';
import { RouterLink, useRoute } from 'vue-router';
import { onMounted, ref } from "vue";
import { RouterLink, useRoute } from "vue-router";
import { getPublicListing } from '@/services/catalog-service.js';
import CollectibleDetail from '@/components/organisms/CollectibleDetail.vue';
import { getPublicListing } from "@/services/catalog-service.js";
import CollectibleDetail from "@/components/organisms/CollectibleDetail.vue";
const route = useRoute();
const listing = ref(null);
@@ -23,9 +23,13 @@ onMounted(async () => {
<template>
<main class="detail-page">
<RouterLink class="detail-back" :to="{ name: 'catalog' }">Back to catalog</RouterLink>
<RouterLink class="detail-back" :to="{ name: 'catalog' }"
>Back to catalog</RouterLink
>
<p v-if="isLoading" class="catalog-state">Loading listing...</p>
<p v-else-if="error" class="catalog-state catalog-state--error">{{ error.message }}</p>
<p v-else-if="error" class="catalog-state catalog-state--error">
{{ error.message }}
</p>
<CollectibleDetail
v-else-if="listing"
:title="listing.productName"
@@ -33,9 +37,13 @@ onMounted(async () => {
:price-minor="listing.askingPriceMinor"
:market-value-minor="listing.marketValueMinor"
:market-value-currency="listing.marketValueCurrency"
:condition="listing.conditionName ? `${listing.conditionName} (${listing.conditionCode})` : listing.conditionId"
:condition="
listing.conditionName
? `${listing.conditionName} (${listing.conditionCode})`
: listing.conditionId
"
:description="listing.notes"
:images="listing.images"
/>
</main>
</template>
</template>
@@ -1,24 +1,28 @@
const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? '/api/v1';
const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? "/api/v1";
async function request(path, options = {}) {
const response = await fetch(`${apiBaseUrl}${path}`, {
credentials: 'include',
credentials: "include",
...options,
headers: { ...(options.body ? { 'content-type': 'application/json' } : {}), ...options.headers },
headers: {
...(options.body ? { "content-type": "application/json" } : {}),
...options.headers,
},
});
const payload = await response.json().catch(() => null);
if (!response.ok) throw new Error(payload?.error?.message ?? 'Acquisition request failed.');
if (!response.ok)
throw new Error(payload?.error?.message ?? "Acquisition request failed.");
return payload?.data;
}
export function listAdminAcquisitions() {
return request('/admin/acquisitions');
return request("/admin/acquisitions");
}
export function createAdminAcquisition(input, csrfToken) {
return request('/admin/acquisitions', {
method: 'POST',
headers: { 'x-csrf-token': csrfToken },
return request("/admin/acquisitions", {
method: "POST",
headers: { "x-csrf-token": csrfToken },
body: JSON.stringify(input),
});
}
@@ -1,8 +1,11 @@
const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? '/api/v1';
const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? "/api/v1";
export async function getAdminDashboard() {
const response = await fetch(`${apiBaseUrl}/admin/dashboard`, { credentials: 'include' });
const response = await fetch(`${apiBaseUrl}/admin/dashboard`, {
credentials: "include",
});
const payload = await response.json().catch(() => null);
if (!response.ok) throw new Error(payload?.error?.message ?? 'Dashboard request failed.');
if (!response.ok)
throw new Error(payload?.error?.message ?? "Dashboard request failed.");
return payload?.data;
}
+11 -7
View File
@@ -1,23 +1,27 @@
const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? '/api/v1';
const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? "/api/v1";
async function request(path, options = {}) {
const response = await fetch(`${apiBaseUrl}${path}`, {
credentials: 'include',
credentials: "include",
...options,
headers: { ...(options.body ? { 'content-type': 'application/json' } : {}), ...options.headers },
headers: {
...(options.body ? { "content-type": "application/json" } : {}),
...options.headers,
},
});
const payload = await response.json().catch(() => null);
if (!response.ok) throw new Error(payload?.error?.message ?? 'Job request failed.');
if (!response.ok)
throw new Error(payload?.error?.message ?? "Job request failed.");
return payload?.data;
}
export function listAdminPricingJobs() {
return request('/admin/jobs');
return request("/admin/jobs");
}
export function retryAdminPricingJob(jobId, csrfToken) {
return request(`/admin/jobs/${encodeURIComponent(jobId)}/retry`, {
method: 'POST',
headers: { 'x-csrf-token': csrfToken },
method: "POST",
headers: { "x-csrf-token": csrfToken },
});
}
+34 -8
View File
@@ -1,14 +1,40 @@
const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? '/api/v1';
const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? "/api/v1";
async function request(path, options = {}) {
const headers = { ...(options.body ? { 'content-type': 'application/json' } : {}), ...options.headers };
const response = await fetch(`${apiBaseUrl}${path}`, { credentials: 'include', ...options, headers });
const headers = {
...(options.body ? { "content-type": "application/json" } : {}),
...options.headers,
};
const response = await fetch(`${apiBaseUrl}${path}`, {
credentials: "include",
...options,
headers,
});
const payload = await response.json().catch(() => null);
if (!response.ok) throw new Error(payload?.error?.message ?? 'Listing request failed.');
if (!response.ok)
throw new Error(payload?.error?.message ?? "Listing request failed.");
return payload?.data;
}
export function listAdminListings() { return request('/admin/listings'); }
export function createAdminListing(input, csrfToken) { return request('/admin/listings', { method: 'POST', headers: { 'x-csrf-token': csrfToken }, body: JSON.stringify(input) }); }
export function publishAdminListing(id, csrfToken) { return request(`/admin/listings/${encodeURIComponent(id)}/publish`, { method: 'POST', headers: { 'x-csrf-token': csrfToken } }); }
export function markListingPending(id, csrfToken) { return request(`/admin/listings/${encodeURIComponent(id)}/pending`, { method: 'POST', headers: { 'x-csrf-token': csrfToken } }); }
export function listAdminListings() {
return request("/admin/listings");
}
export function createAdminListing(input, csrfToken) {
return request("/admin/listings", {
method: "POST",
headers: { "x-csrf-token": csrfToken },
body: JSON.stringify(input),
});
}
export function publishAdminListing(id, csrfToken) {
return request(`/admin/listings/${encodeURIComponent(id)}/publish`, {
method: "POST",
headers: { "x-csrf-token": csrfToken },
});
}
export function markListingPending(id, csrfToken) {
return request(`/admin/listings/${encodeURIComponent(id)}/pending`, {
method: "POST",
headers: { "x-csrf-token": csrfToken },
});
}
+34 -21
View File
@@ -1,45 +1,58 @@
const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? '/api/v1';
const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? "/api/v1";
async function request(path, options = {}) {
const response = await fetch(`${apiBaseUrl}${path}`, {
credentials: 'include',
credentials: "include",
...options,
headers: { ...(options.body ? { 'content-type': 'application/json' } : {}), ...options.headers },
headers: {
...(options.body ? { "content-type": "application/json" } : {}),
...options.headers,
},
});
const payload = await response.json().catch(() => null);
if (!response.ok) throw new Error(payload?.error?.message ?? 'Pricing request failed.');
if (!response.ok)
throw new Error(payload?.error?.message ?? "Pricing request failed.");
return payload?.data;
}
export function addAdminPricingObservation(input, csrfToken) {
return request('/admin/pricing/observations', {
method: 'POST',
headers: { 'x-csrf-token': csrfToken },
return request("/admin/pricing/observations", {
method: "POST",
headers: { "x-csrf-token": csrfToken },
body: JSON.stringify(input),
});
}
export function calculateAdminValuation(inventoryItemId, csrfToken) {
return request(`/admin/inventory/${encodeURIComponent(inventoryItemId)}/valuation`, {
method: 'POST',
headers: { 'x-csrf-token': csrfToken },
});
return request(
`/admin/inventory/${encodeURIComponent(inventoryItemId)}/valuation`,
{
method: "POST",
headers: { "x-csrf-token": csrfToken },
},
);
}
export function overrideAdminValuation(inventoryItemId, input, csrfToken) {
return request(`/admin/inventory/${encodeURIComponent(inventoryItemId)}/valuation/override`, {
method: 'POST',
headers: { 'x-csrf-token': csrfToken },
body: JSON.stringify(input),
});
return request(
`/admin/inventory/${encodeURIComponent(inventoryItemId)}/valuation/override`,
{
method: "POST",
headers: { "x-csrf-token": csrfToken },
body: JSON.stringify(input),
},
);
}
export function enqueueAdminPricingRefresh(inventoryItemId, input, csrfToken) {
return request(`/admin/inventory/${encodeURIComponent(inventoryItemId)}/pricing/refresh`, {
method: 'POST',
headers: { 'x-csrf-token': csrfToken },
body: JSON.stringify(input),
});
return request(
`/admin/inventory/${encodeURIComponent(inventoryItemId)}/pricing/refresh`,
{
method: "POST",
headers: { "x-csrf-token": csrfToken },
body: JSON.stringify(input),
},
);
}
export function getAdminPricingJob(jobId) {
@@ -1,24 +1,35 @@
const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? '/api/v1';
const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? "/api/v1";
async function request(path, options = {}) {
const response = await fetch(`${apiBaseUrl}${path}`, {
credentials: 'include',
credentials: "include",
...options,
headers: { ...(options.body ? { 'content-type': 'application/json' } : {}), ...options.headers },
headers: {
...(options.body ? { "content-type": "application/json" } : {}),
...options.headers,
},
});
const payload = await response.json().catch(() => null);
if (!response.ok) throw new Error(payload?.error?.message ?? 'Provider mapping request failed.');
if (!response.ok)
throw new Error(
payload?.error?.message ?? "Provider mapping request failed.",
);
return payload?.data;
}
export function listAdminProviderMappings(productId) {
return request(`/admin/products/${encodeURIComponent(productId)}/provider-mappings`);
return request(
`/admin/products/${encodeURIComponent(productId)}/provider-mappings`,
);
}
export function createAdminProviderMapping(productId, input, csrfToken) {
return request(`/admin/products/${encodeURIComponent(productId)}/provider-mappings`, {
method: 'POST',
headers: { 'x-csrf-token': csrfToken },
body: JSON.stringify(input),
});
return request(
`/admin/products/${encodeURIComponent(productId)}/provider-mappings`,
{
method: "POST",
headers: { "x-csrf-token": csrfToken },
body: JSON.stringify(input),
},
);
}
+25 -17
View File
@@ -1,38 +1,46 @@
const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? '/api/v1';
const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? "/api/v1";
export async function createAdminSale(input, csrfToken) {
const response = await fetch(`${apiBaseUrl}/admin/sales`, {
method: 'POST',
credentials: 'include',
method: "POST",
credentials: "include",
headers: {
'content-type': 'application/json',
'x-csrf-token': csrfToken,
"content-type": "application/json",
"x-csrf-token": csrfToken,
},
body: JSON.stringify(input),
});
const payload = await response.json().catch(() => null);
if (!response.ok) throw new Error(payload?.error?.message ?? 'Sale request failed.');
if (!response.ok)
throw new Error(payload?.error?.message ?? "Sale request failed.");
return payload?.data;
}
export async function listAdminSales() {
const response = await fetch(`${apiBaseUrl}/admin/sales`, { credentials: 'include' });
const response = await fetch(`${apiBaseUrl}/admin/sales`, {
credentials: "include",
});
const payload = await response.json().catch(() => null);
if (!response.ok) throw new Error(payload?.error?.message ?? 'Sale history is unavailable.');
if (!response.ok)
throw new Error(payload?.error?.message ?? "Sale history is unavailable.");
return payload?.data ?? [];
}
export async function voidAdminSale(id, reason, csrfToken) {
const response = await fetch(`${apiBaseUrl}/admin/sales/${encodeURIComponent(id)}/void`, {
method: 'POST',
credentials: 'include',
headers: {
'content-type': 'application/json',
'x-csrf-token': csrfToken,
const response = await fetch(
`${apiBaseUrl}/admin/sales/${encodeURIComponent(id)}/void`,
{
method: "POST",
credentials: "include",
headers: {
"content-type": "application/json",
"x-csrf-token": csrfToken,
},
body: JSON.stringify({ reason }),
},
body: JSON.stringify({ reason }),
});
);
const payload = await response.json().catch(() => null);
if (!response.ok) throw new Error(payload?.error?.message ?? 'Sale correction failed.');
if (!response.ok)
throw new Error(payload?.error?.message ?? "Sale correction failed.");
return payload?.data;
}
+43 -16
View File
@@ -1,16 +1,31 @@
const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? '/api/v1';
const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? "/api/v1";
export async function searchCatalog({ query = '', category = '', conditionId = '', minPriceMinor, maxPriceMinor, sort = 'recent', page = 1, limit = 20 } = {}) {
const params = new URLSearchParams({ query, page: String(page), limit: String(limit) });
if (sort !== 'recent') params.set('sort', sort);
if (category) params.set('category', category);
if (conditionId) params.set('conditionId', conditionId);
if (typeof minPriceMinor === 'number') params.set('minPriceMinor', String(minPriceMinor));
if (typeof maxPriceMinor === 'number') params.set('maxPriceMinor', String(maxPriceMinor));
export async function searchCatalog({
query = "",
category = "",
conditionId = "",
minPriceMinor,
maxPriceMinor,
sort = "recent",
page = 1,
limit = 20,
} = {}) {
const params = new URLSearchParams({
query,
page: String(page),
limit: String(limit),
});
if (sort !== "recent") params.set("sort", sort);
if (category) params.set("category", category);
if (conditionId) params.set("conditionId", conditionId);
if (typeof minPriceMinor === "number")
params.set("minPriceMinor", String(minPriceMinor));
if (typeof maxPriceMinor === "number")
params.set("maxPriceMinor", String(maxPriceMinor));
const response = await fetch(`${apiBaseUrl}/public/search?${params}`);
if (!response.ok) {
throw new Error('Catalog search is unavailable.');
throw new Error("Catalog search is unavailable.");
}
const payload = await response.json();
@@ -19,21 +34,27 @@ export async function searchCatalog({ query = '', category = '', conditionId = '
export async function listPublicCategories() {
const response = await fetch(`${apiBaseUrl}/public/lookups/categories`);
if (!response.ok) throw new Error('Categories are unavailable.');
if (!response.ok) throw new Error("Categories are unavailable.");
return (await response.json()).data;
}
export async function listPublicConditions() {
const response = await fetch(`${apiBaseUrl}/public/lookups/conditions`);
if (!response.ok) throw new Error('Conditions are unavailable.');
if (!response.ok) throw new Error("Conditions are unavailable.");
return (await response.json()).data;
}
export async function getPublicListing(id) {
const response = await fetch(`${apiBaseUrl}/public/listings/${encodeURIComponent(id)}`);
const response = await fetch(
`${apiBaseUrl}/public/listings/${encodeURIComponent(id)}`,
);
if (!response.ok) {
throw new Error(response.status === 404 ? 'Listing not found.' : 'Listing is unavailable.');
throw new Error(
response.status === 404
? "Listing not found."
: "Listing is unavailable.",
);
}
const payload = await response.json();
@@ -41,12 +62,18 @@ export async function getPublicListing(id) {
}
export async function getPublicProduct(slug) {
const response = await fetch(`${apiBaseUrl}/public/products/${encodeURIComponent(slug)}`);
const response = await fetch(
`${apiBaseUrl}/public/products/${encodeURIComponent(slug)}`,
);
if (!response.ok) {
throw new Error(response.status === 404 ? 'Product not found.' : 'Product is unavailable.');
throw new Error(
response.status === 404
? "Product not found."
: "Product is unavailable.",
);
}
const payload = await response.json();
return payload.data;
}
}
+157 -79
View File
@@ -1,103 +1,181 @@
import { expect, test } from '@playwright/test';
import { expect, test } from "@playwright/test";
const tinyPng = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64');
const tinyPng = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
"base64",
);
test('signs in and publishes a listing after adding an inventory image', async ({ page }) => {
test("signs in and publishes a listing after adding an inventory image", async ({
page,
}) => {
const email = process.env.INITIAL_ADMIN_EMAIL;
const password = process.env.INITIAL_ADMIN_PASSWORD;
test.skip(!email || !password, 'Set INITIAL_ADMIN_EMAIL and INITIAL_ADMIN_PASSWORD for the local authenticated workflow.');
test.skip(
!email || !password,
"Set INITIAL_ADMIN_EMAIL and INITIAL_ADMIN_PASSWORD for the local authenticated workflow.",
);
await page.goto('/login');
await page.getByLabel('Email').fill(email);
await page.getByLabel('Password').fill(password);
await page.getByRole('button', { name: 'Sign in' }).click();
await page.goto("/login");
await page.getByLabel("Email").fill(email);
await page.getByLabel("Password").fill(password);
await page.getByRole("button", { name: "Sign in" }).click();
await expect(page).toHaveURL(/\/admin$/);
await expect(page.getByRole('heading', { name: 'Collector workspace' })).toBeVisible();
await expect(page.getByRole('status', { name: 'Loading inventory...' })).toHaveCount(0);
await expect(page.getByRole('heading', { name: 'At a glance' })).toBeVisible();
await expect(page.getByText('Active inventory')).toBeVisible();
await expect(page.locator('.dashboard-summary__grid div').filter({ hasText: 'Missing images' }).getByRole('link')).toHaveAttribute('href', '#inventory-title');
await expect(page.locator('.dashboard-summary__grid div').filter({ hasText: 'Provider failures' }).getByRole('link')).toHaveAttribute('href', '#pricing-title');
await expect(
page.getByRole("heading", { name: "Collector workspace" }),
).toBeVisible();
await expect(
page.getByRole("status", { name: "Loading inventory..." }),
).toHaveCount(0);
await expect(
page.getByRole("heading", { name: "At a glance" }),
).toBeVisible();
await expect(page.getByText("Active inventory")).toBeVisible();
await expect(
page
.locator(".dashboard-summary__grid div")
.filter({ hasText: "Missing images" })
.getByRole("link"),
).toHaveAttribute("href", "#inventory-title");
await expect(
page
.locator(".dashboard-summary__grid div")
.filter({ hasText: "Provider failures" })
.getByRole("link"),
).toHaveAttribute("href", "#pricing-title");
const category = page.locator('#product-category option').nth(1);
const category = page.locator("#product-category option").nth(1);
await expect(category).toBeAttached();
const productName = `Browser test collectible ${Date.now()}`;
await page.getByLabel('Name').fill(productName);
await page.getByLabel('Category').selectOption({ index: 1 });
await page.getByRole('button', { name: 'Create product' }).click();
await page.getByLabel("Name").fill(productName);
await page.getByLabel("Category").selectOption({ index: 1 });
await page.getByRole("button", { name: "Create product" }).click();
await expect(page.locator('#inventory-product option').nth(1)).toBeAttached();
const createdProductId = await page.locator('#inventory-product').locator('option', { hasText: productName }).getAttribute('value');
await page.locator('#mapping-product').selectOption(createdProductId);
await page.getByLabel('Provider ID').fill('fixture-provider');
await page.getByLabel('External identifier').fill('fixture-product-1');
await page.getByRole('button', { name: 'Save provider mapping' }).click();
await expect(page.getByRole('region', { name: 'Active provider mappings' })).toContainText('fixture-provider');
await page.locator('#inventory-product').selectOption(createdProductId);
await page.getByLabel('Condition', { exact: true }).selectOption({ index: 1 });
await page.getByRole('button', { name: 'Create copy' }).click();
await expect(page.locator("#inventory-product option").nth(1)).toBeAttached();
const createdProductId = await page
.locator("#inventory-product")
.locator("option", { hasText: productName })
.getAttribute("value");
await page.locator("#mapping-product").selectOption(createdProductId);
await page.getByLabel("Provider ID").fill("fixture-provider");
await page.getByLabel("External identifier").fill("fixture-product-1");
await page.getByRole("button", { name: "Save provider mapping" }).click();
await expect(
page.getByRole("region", { name: "Active provider mappings" }),
).toContainText("fixture-provider");
await page.locator("#inventory-product").selectOption(createdProductId);
await page
.getByLabel("Condition", { exact: true })
.selectOption({ index: 1 });
await page.getByRole("button", { name: "Create copy" }).click();
const createdInventoryRow = page.locator(`section[aria-labelledby="inventory-title"] li[data-product-id="${createdProductId}"]`).last();
const createdInventoryId = await createdInventoryRow.locator('a').getAttribute('href').then((href) => href.split('/').at(-2));
const inventoryLink = page.locator(`a[href="/admin/inventory/${createdInventoryId}/images"]`);
const createdInventoryRow = page
.locator(
`section[aria-labelledby="inventory-title"] li[data-product-id="${createdProductId}"]`,
)
.last();
const createdInventoryId = await createdInventoryRow
.locator("a")
.getAttribute("href")
.then((href) => href.split("/").at(-2));
const inventoryLink = page.locator(
`a[href="/admin/inventory/${createdInventoryId}/images"]`,
);
await expect(inventoryLink).toBeVisible();
await page.locator('#acquisition-inventory').selectOption(createdInventoryId);
await page.getByLabel('Method').fill('PURCHASE');
await page.getByLabel('Acquisition date').fill('2026-09-09');
await page.getByLabel('Cost (cents)').fill('1000');
await page.getByLabel('Shipping (cents)').fill('100');
await page.getByLabel('Tax (cents)').fill('50');
await page.getByRole('button', { name: 'Record acquisition' }).click();
await expect(page.locator('section[aria-labelledby="acquisitions-title"] li').first()).toContainText('Basis $11.50');
await page.locator('#pricing-inventory').selectOption(createdInventoryId);
await page.getByLabel('Source identifier').fill('browser-price-1');
await page.getByLabel('Observed price (cents)').fill('2500');
await page.locator("#acquisition-inventory").selectOption(createdInventoryId);
await page.getByLabel("Method").fill("PURCHASE");
await page.getByLabel("Acquisition date").fill("2026-09-09");
await page.getByLabel("Cost (cents)").fill("1000");
await page.getByLabel("Shipping (cents)").fill("100");
await page.getByLabel("Tax (cents)").fill("50");
await page.getByRole("button", { name: "Record acquisition" }).click();
await expect(
page.locator('section[aria-labelledby="acquisitions-title"] li').first(),
).toContainText("Basis $11.50");
await page.locator("#pricing-inventory").selectOption(createdInventoryId);
await page.getByLabel("Source identifier").fill("browser-price-1");
await page.getByLabel("Observed price (cents)").fill("2500");
const [observationResponse, valuationResponse] = await Promise.all([
page.waitForResponse((response) => response.url().includes('/admin/pricing/observations')),
page.waitForResponse((response) => response.url().includes('/valuation') && response.request().method() === 'POST'),
page.getByRole('button', { name: 'Add observation and calculate' }).click(),
page.waitForResponse((response) =>
response.url().includes("/admin/pricing/observations"),
),
page.waitForResponse(
(response) =>
response.url().includes("/valuation") &&
response.request().method() === "POST",
),
page.getByRole("button", { name: "Add observation and calculate" }).click(),
]);
expect(observationResponse.status()).toBe(201);
expect(valuationResponse.status()).toBe(200);
const valuationRow = page.getByRole('list', { name: 'Market valuations' }).getByText(createdInventoryId);
await expect(valuationRow).toContainText('Market value $25.00');
await page.getByLabel('Manual value (cents)').fill('2700');
await page.getByLabel('Override reason').fill('Verified recent sale');
await page.getByRole('button', { name: 'Save manual value' }).click();
await expect(page.getByRole('list', { name: 'Market valuations' })).toContainText('Market value $27.00');
await expect(page.getByRole('list', { name: 'Market valuations' })).toContainText('OVERRIDE confidence');
const valuationRow = page
.getByRole("list", { name: "Market valuations" })
.getByText(createdInventoryId);
await expect(valuationRow).toContainText("Market value $25.00");
await page.getByLabel("Manual value (cents)").fill("2700");
await page.getByLabel("Override reason").fill("Verified recent sale");
await page.getByRole("button", { name: "Save manual value" }).click();
await expect(
page.getByRole("list", { name: "Market valuations" }),
).toContainText("Market value $27.00");
await expect(
page.getByRole("list", { name: "Market valuations" }),
).toContainText("OVERRIDE confidence");
await inventoryLink.click();
await expect(page).toHaveURL(/\/admin\/inventory\/[^/]+\/images$/);
await expect(page.getByRole('heading', { name: 'Manage images' })).toBeVisible();
await page.getByLabel('Add image').setInputFiles({
name: 'front.png',
mimeType: 'image/png',
await expect(
page.getByRole("heading", { name: "Manage images" }),
).toBeVisible();
await page.getByLabel("Add image").setInputFiles({
name: "front.png",
mimeType: "image/png",
buffer: tinyPng,
});
await expect(page.getByLabel('Alt text')).toHaveValue('front.png');
await expect(page.getByLabel("Alt text")).toHaveValue("front.png");
await page.goto('/admin');
await expect(page.getByRole('heading', { name: 'Create listing draft' })).toBeVisible();
await page.getByLabel('Inventory copy', { exact: true }).first().selectOption(createdInventoryId);
await page.getByLabel('Asking price (cents)').fill('2499');
await page.getByRole('button', { name: 'Create listing' }).click();
const createdListing = page.locator('section[aria-labelledby="listings-title"] article').filter({ hasText: productName }).last();
await expect(createdListing.getByText('Add at least one image before publishing.')).toHaveCount(0);
await createdListing.getByRole('button', { name: 'Publish' }).click();
await expect(createdListing).toContainText('AVAILABLE');
await page.goto("/admin");
await expect(
page.getByRole("heading", { name: "Create listing draft" }),
).toBeVisible();
await page
.getByLabel("Inventory copy", { exact: true })
.first()
.selectOption(createdInventoryId);
await page.getByLabel("Asking price (cents)").fill("2499");
await page.getByRole("button", { name: "Create listing" }).click();
const createdListing = page
.locator('section[aria-labelledby="listings-title"] article')
.filter({ hasText: productName })
.last();
await expect(
createdListing.getByText("Add at least one image before publishing."),
).toHaveCount(0);
await createdListing.getByRole("button", { name: "Publish" }).click();
await expect(createdListing).toContainText("AVAILABLE");
await page.locator('#sale-inventory').selectOption(createdInventoryId);
await page.getByLabel('Sale date').fill('2026-09-09');
await page.getByLabel('Sold price (cents)').fill('2499');
await page.getByLabel('Cost basis (cents)').fill('1200');
await page.getByRole('button', { name: 'Record sale' }).click();
await expect(page.locator('section[aria-labelledby="inventory-title"]').getByText(`${createdInventoryId} · SOLD`)).toBeVisible();
await expect(page.getByRole('heading', { name: 'Sale history' })).toBeVisible();
const latestSale = page.locator('section[aria-labelledby="sales-title"] li').first();
await expect(latestSale).toContainText('Profit $12.99');
await latestSale.getByLabel('Void reason').fill('Browser test correction');
await latestSale.getByRole('button', { name: 'Void sale' }).click();
await expect(latestSale).toContainText('VOIDED');
await expect(page.locator('section[aria-labelledby="inventory-title"]').getByText(`${createdInventoryId} · ACTIVE`)).toBeVisible();
await page.locator("#sale-inventory").selectOption(createdInventoryId);
await page.getByLabel("Sale date").fill("2026-09-09");
await page.getByLabel("Sold price (cents)").fill("2499");
await page.getByLabel("Cost basis (cents)").fill("1200");
await page.getByRole("button", { name: "Record sale" }).click();
await expect(
page
.locator('section[aria-labelledby="inventory-title"]')
.getByText(`${createdInventoryId} · SOLD`),
).toBeVisible();
await expect(
page.getByRole("heading", { name: "Sale history" }),
).toBeVisible();
const latestSale = page
.locator('section[aria-labelledby="sales-title"] li')
.first();
await expect(latestSale).toContainText("Profit $12.99");
await latestSale.getByLabel("Void reason").fill("Browser test correction");
await latestSale.getByRole("button", { name: "Void sale" }).click();
await expect(latestSale).toContainText("VOIDED");
await expect(
page
.locator('section[aria-labelledby="inventory-title"]')
.getByText(`${createdInventoryId} · ACTIVE`),
).toBeVisible();
});