feat: add listing publication readiness
Quality / test (push) Canceled after 0s

This commit is contained in:
2026-09-08 23:18:11 -04:00
parent 4e0e4ebd48
commit 370e4721ed
4 changed files with 58 additions and 1 deletions
+1
View File
@@ -56,6 +56,7 @@ h1 { max-width: 760px; margin: var(--space-3) 0 var(--space-4); font-size: var(-
.admin-list { margin-top: var(--space-8); }
.admin-list h2 { margin-bottom: var(--space-3); font-size: var(--text-xl); }
.admin-list ul { display: grid; gap: var(--space-2); margin: 0; padding: 0; list-style: none; }
.admin-listing-stack { display: grid; gap: var(--space-3); }
.admin-list li { display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: center; border-bottom: var(--border-thin) solid var(--color-border); padding: var(--space-3) 0; }
.admin-list li span { color: var(--color-text-muted); font-size: var(--text-sm); }
.admin-list a { color: var(--color-interactive-text); font-weight: var(--weight-bold); }
@@ -0,0 +1,19 @@
import { expect, userEvent, within } from '@storybook/test';
import AdminListingStatus from './AdminListingStatus.vue';
const listing = { id: 'listing-1', productName: 'Charizard ex #199/165', askingPriceMinor: 11500, status: 'DRAFT' };
export default { title: 'Molecules/AdminListingStatus', component: AdminListingStatus };
export const DraftReady = { args: { listing, blockers: [] } };
export const DraftBlockedByImages = { args: { listing, blockers: ['Add at least one completed image before publishing.'] } };
export const Publishing = { args: { listing, blockers: [], isSaving: true } };
export const PublishError = { args: { listing, blockers: [], errorMessage: 'Listing requirements are not met.' } };
export const KeyboardPublish = {
args: { listing, blockers: [] },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole('button', { name: 'Publish' }));
await expect(canvas.getByRole('button', { name: 'Publish' })).toBeInTheDocument();
},
};
@@ -0,0 +1,36 @@
<script setup>
defineProps({
listing: { type: Object, required: true },
blockers: { type: Array, default: () => [] },
isSaving: { type: Boolean, default: false },
errorMessage: { type: String, default: '' },
});
defineEmits(['publish', 'pending']);
</script>
<template>
<article class="admin-listing-status">
<div class="admin-listing-status__identity">
<p class="catalog-kicker">Listing</p>
<h3>{{ listing.productName }}</h3>
<span class="fixture-label">${{ (listing.askingPriceMinor / 100).toFixed(2) }}</span>
</div>
<span class="status" :class="`status--${listing.status.toLowerCase()}`">{{ listing.status }}</span>
<ul v-if="blockers.length" class="admin-listing-status__blockers" aria-label="Publication blockers">
<li v-for="blocker in blockers" :key="blocker">{{ blocker }}</li>
</ul>
<p v-if="errorMessage" class="state state--error" role="alert">{{ errorMessage }}</p>
<div class="admin-listing-status__actions">
<button v-if="listing.status === 'DRAFT'" class="button button--primary" type="button" :disabled="isSaving || blockers.length > 0" @click="$emit('publish')">{{ isSaving ? 'Publishing...' : 'Publish' }}</button>
<button v-if="listing.status === 'DRAFT'" class="button button--secondary" type="button" :disabled="isSaving" @click="$emit('pending')">Mark pending</button>
</div>
</article>
</template>
<style scoped lang="scss">
.admin-listing-status { display: grid; grid-template-columns: 1fr auto; gap: var(--space-3); border: var(--border-thin) solid var(--color-border); border-radius: var(--radius-lg); padding: var(--space-4); background: var(--color-surface); }
.admin-listing-status h3 { font-size: var(--text-lg); }
.admin-listing-status__identity { display: grid; gap: var(--space-2); }
.admin-listing-status__blockers { grid-column: 1 / -1; margin: 0; padding-left: var(--space-6); color: var(--color-warning-text); }
.admin-listing-status__actions { display: flex; grid-column: 1 / -1; flex-wrap: wrap; gap: var(--space-2); }
</style>
+2 -1
View File
@@ -3,6 +3,7 @@ import { onMounted, ref } from 'vue';
import { RouterLink } from 'vue-router';
import { useAuth } from '@/composables/useAuth.js';
import AdminListingStatus from '@/components/molecules/AdminListingStatus.vue';
import { createAdminInventory, createAdminProduct, listAdminCategories, listAdminConditions, listAdminInventory, listAdminProducts } from '@/services/admin-catalog-service.js';
import { createAdminListing, listAdminListings, markListingPending, publishAdminListing } from '@/services/admin-listing-service.js';
@@ -137,7 +138,7 @@ onMounted(load);
<section class="admin-list" aria-labelledby="listings-title">
<h2 id="listings-title">Listings</h2>
<p v-if="!listings.length && !isLoading" class="state state--empty">No listings yet.</p>
<ul v-else><li v-for="listing in listings" :key="listing.id"><span>{{ listing.productName }}</span> · {{ listing.status }} · ${{ (listing.askingPriceMinor / 100).toFixed(2) }} <button v-if="listing.status === 'DRAFT'" class="button button--secondary" type="button" @click="transitionListing(publishAdminListing, listing.id)">Publish</button><button v-if="listing.status === 'DRAFT'" class="button button--secondary" type="button" @click="transitionListing(markListingPending, listing.id)">Mark pending</button></li></ul>
<div v-else class="admin-listing-stack"><AdminListingStatus v-for="listing in listings" :key="listing.id" :listing="listing" :is-saving="isSaving" @publish="transitionListing(publishAdminListing, listing.id)" @pending="transitionListing(markListingPending, listing.id)" /></div>
</section>
</section>
</template>