feat: add admin listing workflow
Quality / test (push) Canceled after 0s

This commit is contained in:
2026-09-08 23:15:50 -04:00
parent 3c8a1d1154
commit 4e0e4ebd48
2 changed files with 55 additions and 1 deletions
+42 -1
View File
@@ -4,10 +4,12 @@ import { RouterLink } from 'vue-router';
import { useAuth } from '@/composables/useAuth.js';
import { createAdminInventory, createAdminProduct, listAdminCategories, listAdminConditions, listAdminInventory, listAdminProducts } from '@/services/admin-catalog-service.js';
import { createAdminListing, listAdminListings, markListingPending, publishAdminListing } from '@/services/admin-listing-service.js';
const { principal } = useAuth();
const products = ref([]);
const inventory = ref([]);
const listings = ref([]);
const categories = ref([]);
const conditions = ref([]);
const isLoading = ref(true);
@@ -17,6 +19,8 @@ const productName = ref('');
const categoryId = ref('');
const inventoryProductId = ref('');
const conditionId = ref('');
const listingInventoryId = ref('');
const askingPrice = ref('');
function csrfToken() {
return document.cookie.split('; ').find((value) => value.startsWith('pokemon_inventory_csrf='))?.split('=')[1] ?? '';
@@ -25,7 +29,7 @@ function csrfToken() {
async function load() {
isLoading.value = true;
try {
[products.value, inventory.value, categories.value, conditions.value] = await Promise.all([listAdminProducts(), listAdminInventory(), listAdminCategories(), listAdminConditions()]);
[products.value, inventory.value, categories.value, conditions.value, listings.value] = await Promise.all([listAdminProducts(), listAdminInventory(), listAdminCategories(), listAdminConditions(), listAdminListings()]);
} catch (requestError) { error.value = requestError; }
finally { isLoading.value = false; }
}
@@ -49,6 +53,27 @@ async function createInventory() {
finally { isSaving.value = false; }
}
async function createListing() {
const item = inventory.value.find((candidate) => candidate.id === listingInventoryId.value);
const product = products.value.find((candidate) => candidate.id === item?.productId);
isSaving.value = true;
try {
const listing = await createAdminListing({ inventoryItemId: item.id, productName: product.name, categoryId: product.categoryId, conditionId: item.conditionId, askingPriceMinor: Number(askingPrice.value) }, csrfToken());
listings.value.push(listing);
askingPrice.value = '';
} catch (requestError) { error.value = requestError; }
finally { isSaving.value = false; }
}
async function transitionListing(action, id) {
isSaving.value = true;
try {
const listing = await action(id, csrfToken());
listings.value = listings.value.map((candidate) => candidate.id === listing.id ? listing : candidate);
} catch (requestError) { error.value = requestError; }
finally { isSaving.value = false; }
}
onMounted(load);
</script>
@@ -86,6 +111,17 @@ onMounted(load);
</select>
<button class="button button--primary" type="submit" :disabled="isSaving">Create copy</button>
</form>
<form class="showcase-panel" @submit.prevent="createListing">
<h2>Create listing draft</h2>
<label class="fixture-label" for="listing-inventory">Inventory copy</label>
<select id="listing-inventory" v-model="listingInventoryId" class="fixture-input" required>
<option value="" disabled>Select a physical copy</option>
<option v-for="item in inventory" :key="item.id" :value="item.id">{{ item.id }} · {{ item.status }}</option>
</select>
<label class="fixture-label" for="listing-price">Asking price (cents)</label>
<input id="listing-price" v-model="askingPrice" class="fixture-input" type="number" min="1" required />
<button class="button button--primary" type="submit" :disabled="isSaving">Create listing</button>
</form>
</div>
<section class="admin-list" aria-labelledby="products-title">
@@ -98,5 +134,10 @@ onMounted(load);
<p v-if="!inventory.length && !isLoading" class="state state--empty">No physical copies yet.</p>
<ul v-else><li v-for="item in inventory" :key="item.id"><span>{{ item.id }}</span> · {{ item.status }} <RouterLink :to="{ name: 'admin-image-manager', params: { id: item.id } }">Manage images</RouterLink></li></ul>
</section>
<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>
</section>
</section>
</template>
@@ -0,0 +1,13 @@
const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? '/api/v1';
async function request(path, options = {}) {
const response = await fetch(`${apiBaseUrl}${path}`, { credentials: 'include', ...options, headers: { 'content-type': 'application/json', ...options.headers } });
const payload = await response.json().catch(() => null);
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 } }); }