feat: add seller settings
Quality / test (push) Canceled after 0s

This commit is contained in:
2026-09-10 01:14:57 -04:00
parent 24f51212e5
commit cf61ec3c2c
20 changed files with 401 additions and 2 deletions
@@ -0,0 +1,5 @@
import AdminSettingsPage from './AdminSettingsPage.vue';
export default { title: 'Pages/AdminSettingsPage', component: AdminSettingsPage };
export const SettingsForm = {};
+85
View File
@@ -0,0 +1,85 @@
<script setup>
import { onMounted, reactive, ref } from 'vue';
import { getAdminSettings, updateAdminSettings } from '@/services/admin-settings-service.js';
const settings = reactive({
general: { publicContactEmail: '', publicContactPhone: '' },
inventory: { maxImagesPerItem: 10 },
pricing: { defaultStrategy: 'median' },
security: { sessionIdleMinutes: 60 },
});
const isLoading = ref(true);
const isSaving = ref(false);
const message = ref('');
const error = ref('');
function csrfToken() {
return document.cookie.split('; ').find((value) => value.startsWith('pokemon_inventory_csrf='))?.split('=')[1] ?? '';
}
async function load() {
try {
Object.assign(settings, await getAdminSettings());
} catch (requestError) {
error.value = requestError.message;
} finally {
isLoading.value = false;
}
}
async function save() {
isSaving.value = true;
message.value = '';
error.value = '';
try {
Object.assign(settings, await updateAdminSettings(settings, csrfToken()));
message.value = 'Settings saved.';
} catch (requestError) {
error.value = requestError.message;
} finally {
isSaving.value = false;
}
}
onMounted(load);
</script>
<template>
<section class="admin-page">
<p class="catalog-kicker">Configuration</p>
<h1>Settings</h1>
<p v-if="isLoading" class="state state--loading" role="status">Loading settings...</p>
<p v-if="message" class="state" role="status">{{ message }}</p>
<p v-if="error" class="state state--error" role="alert">{{ error }}</p>
<form v-if="!isLoading" class="admin-workspace-grid" @submit.prevent="save">
<fieldset class="showcase-panel">
<legend>General</legend>
<label class="fixture-label" for="settings-email">Public contact email</label>
<input id="settings-email" v-model="settings.general.publicContactEmail" class="fixture-input" type="email" />
<label class="fixture-label" for="settings-phone">Public contact phone</label>
<input id="settings-phone" v-model="settings.general.publicContactPhone" class="fixture-input" />
</fieldset>
<fieldset class="showcase-panel">
<legend>Inventory</legend>
<label class="fixture-label" for="settings-images">Maximum images per item</label>
<input id="settings-images" v-model.number="settings.inventory.maxImagesPerItem" class="fixture-input" type="number" min="1" max="100" />
</fieldset>
<fieldset class="showcase-panel">
<legend>Pricing</legend>
<label class="fixture-label" for="settings-strategy">Default valuation strategy</label>
<select id="settings-strategy" v-model="settings.pricing.defaultStrategy" class="fixture-input">
<option value="median">Median</option>
<option value="average">Average</option>
<option value="weighted_average">Weighted average</option>
</select>
</fieldset>
<fieldset class="showcase-panel">
<legend>Security</legend>
<label class="fixture-label" for="settings-idle">Session idle minutes</label>
<input id="settings-idle" v-model.number="settings.security.sessionIdleMinutes" class="fixture-input" type="number" min="5" max="43200" />
</fieldset>
<button class="button button--primary" type="submit" :disabled="isSaving">Save settings</button>
</form>
</section>
</template>
+1
View File
@@ -19,6 +19,7 @@ async function logout() {
<h1>Inventory</h1>
<nav aria-label="Admin navigation">
<a href="/admin">Overview</a>
<RouterLink :to="{ name: 'admin-settings' }">Settings</RouterLink>
</nav>
<div class="admin-sidebar__footer">
<span>{{ principal?.email }}</span>
+2
View File
@@ -8,6 +8,7 @@ import AdminShell from '@/pages/AdminShell.vue';
import LoginPage from '@/pages/LoginPage.vue';
import { useAuth } from '@/composables/useAuth.js';
import AdminImagePage from '@/pages/AdminImagePage.vue';
import AdminSettingsPage from '@/pages/AdminSettingsPage.vue';
const router = createRouter({
history: createWebHistory(),
@@ -22,6 +23,7 @@ const router = createRouter({
children: [
{ path: '', name: 'admin-home', component: AdminHomePage },
{ path: 'inventory/:id/images', name: 'admin-image-manager', component: AdminImagePage },
{ path: 'settings', name: 'admin-settings', component: AdminSettingsPage },
],
},
],
@@ -0,0 +1,24 @@
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 ?? 'Settings request failed.');
return payload?.data;
}
export function getAdminSettings() {
return request('/admin/settings');
}
export function updateAdminSettings(settings, csrfToken) {
return request('/admin/settings', {
method: 'PATCH',
headers: { 'x-csrf-token': csrfToken },
body: JSON.stringify(settings),
});
}
+20
View File
@@ -116,4 +116,24 @@ test('updates selected admin inventory tags through the bulk API', async () => {
} finally {
globalThis.fetch = originalFetch;
}
});
test('updates admin settings through the CSRF-protected API', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async (url, options) => {
assert.equal(url, '/api/v1/admin/settings');
assert.equal(options.method, 'PATCH');
assert.equal(options.headers['x-csrf-token'], 'csrf-token');
assert.deepEqual(JSON.parse(options.body), { pricing: { defaultStrategy: 'median' } });
return { ok: true, async json() { return { data: { pricing: { defaultStrategy: 'median' } } }; } };
};
try {
const { updateAdminSettings } = await import('../src/services/admin-settings-service.js');
assert.deepEqual(await updateAdminSettings({ pricing: { defaultStrategy: 'median' } }, 'csrf-token'), {
pricing: { defaultStrategy: 'median' },
});
} finally {
globalThis.fetch = originalFetch;
}
});