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:
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user