Files
corrosion-admin-panel/frontend/src/views/admin/StoreConfigView.vue
Vantz Stockwell 6f783bfac8
All checks were successful
CI / backend-types (push) Successful in 10s
CI / frontend-build (push) Successful in 15s
CI / agent-tests (push) Successful in 45s
CI / integration (push) Successful in 22s
feat(panel): Beta sweep — multi-game coherence, honesty, UX fixes
Multi-game rebrand (no more Rust-only leftovers): game-neutral setup wizard +
deploy/store defaults; player-id labels driven by game profile (Steam ID only
for Rust); blueprint wipe type + verify-plugins gated to uMod games; oxide
command examples + Rust-only plugin pages (AutoDoors/FurnaceSplitter/BetterChat)
guarded behind mods==='umod' with empty-states for other games.

Honesty: webstore checkout shows coming-soon (backend now 503s); 'integrated
webstore' marketed as coming-soon; Discord references neutralized to
community/webhook; migration FAQ marked in-development; analytics dev phase
labels removed; Network pricing tier set to Custom/Contact (was a confusing
duplicate of Operator); docs/PRICING.md rewritten to match live subscriptions.

UX/bugs: fixed ServerView oxide-status operator-precedence bug; dead 'Deploy
server' button wired; non-functional topbar search removed; alert()/confirm()
replaced with toasts across schedules/alerts/migration/public store+server;
analytics chart arrays null-guarded; production console.logs gated to DEV.

Frontend build (vue-tsc + vite) green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 22:06:10 -04:00

288 lines
9.4 KiB
Vue

<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useApi } from '@/composables/useApi'
import { useToastStore } from '@/stores/toast'
import type { StoreConfig } from '@/types'
import Panel from '@/components/ds/data/Panel.vue'
import Button from '@/components/ds/core/Button.vue'
import Alert from '@/components/ds/feedback/Alert.vue'
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
import Input from '@/components/ds/forms/Input.vue'
import Select from '@/components/ds/forms/Select.vue'
import Switch from '@/components/ds/forms/Switch.vue'
const api = useApi()
const toast = useToastStore()
const config = ref<StoreConfig>({
store_name: '',
description: null,
currency: 'USD',
paypal_client_id: null,
sandbox_mode: true,
enabled: false,
})
const paypalSecret = ref<string>('')
const isLoading = ref(false)
const saving = ref(false)
const isConfigured = ref(false)
// DS Input uses defineModel<string>() which emits string | undefined, but
// paypal_client_id is string | null in the DB type. Bridge null ↔ undefined
// so we never change what gets saved to the API.
const paypalClientId = computed({
get: () => config.value.paypal_client_id ?? undefined,
set: (v: string | undefined) => { config.value.paypal_client_id = v ?? null },
})
const currencyOptions = [
{ value: 'USD', label: 'USD — US Dollar' },
{ value: 'EUR', label: 'EUR — Euro' },
{ value: 'GBP', label: 'GBP — British Pound' },
]
const showPayPalWarning = computed(() => {
return config.value.enabled && (!config.value.paypal_client_id || !paypalSecret.value)
})
async function fetchConfig() {
isLoading.value = true
try {
const data = await api.get<{ config: StoreConfig }>('/webstore/config')
config.value = data.config
isConfigured.value = !!data.config.store_name
} catch (err: any) {
if (err.response?.status === 404) {
isConfigured.value = false
} else {
toast.error('Failed to load store configuration')
}
} finally {
isLoading.value = false
}
}
async function saveConfig() {
if (!config.value.store_name.trim()) {
toast.error('Store name is required')
return
}
if (config.value.enabled && !config.value.paypal_client_id) {
toast.error('PayPal Client ID is required to enable the store')
return
}
saving.value = true
try {
const payload: any = {
store_name: config.value.store_name,
description: config.value.description,
currency: config.value.currency,
paypal_client_id: config.value.paypal_client_id,
sandbox_mode: config.value.sandbox_mode,
enabled: config.value.enabled,
}
if (paypalSecret.value.trim()) {
payload.paypal_client_secret = paypalSecret.value
}
await api.put('/webstore/config', payload)
toast.success('Store configuration saved successfully')
isConfigured.value = true
paypalSecret.value = ''
} catch (err: any) {
const message = err.response?.data?.error || 'Failed to save configuration'
toast.error(message)
} finally {
saving.value = false
}
}
onMounted(() => {
fetchConfig()
})
</script>
<template>
<div class="sc-page">
<!-- Page head -->
<div class="page__head">
<div>
<div class="t-eyebrow">Management</div>
<h1 class="page__title">Store configuration</h1>
<p class="page__sub">Configure your integrated webstore and PayPal settings.</p>
</div>
<Button :loading="saving" :disabled="isLoading" icon="save" @click="saveConfig">
Save configuration
</Button>
</div>
<!-- Loading skeleton -->
<div v-if="isLoading" class="sc-loading">
<EmptyState icon="loader" title="Loading configuration" description="Fetching your store settings…" />
</div>
<!-- Empty state no config yet -->
<Panel v-else-if="!isConfigured && !config.store_name">
<EmptyState
icon="shopping-cart"
title="No store configured"
description="Set up your integrated webstore to start selling in-game items, ranks, and currency to your players. Fill out the form below to get started."
>
<template #action>
<Button size="sm" variant="outline" icon="arrow-down">Complete the form below</Button>
</template>
</EmptyState>
</Panel>
<!-- Configuration form -->
<div v-else class="sc-form">
<!-- Enable toggle -->
<Panel title="Webstore status" subtitle="Allow players to purchase items from your store">
<div class="sc-toggle-row">
<div class="sc-toggle-row__info">
<span class="sc-toggle-row__label">Enable webstore</span>
<span class="sc-toggle-row__hint">Players will be able to browse and purchase items when enabled.</span>
</div>
<Switch v-model="config.enabled" />
</div>
<Alert
v-if="showPayPalWarning"
tone="warn"
title="PayPal configuration required"
class="sc-alert"
>
Configure PayPal credentials in the section below before the store can process transactions.
</Alert>
</Panel>
<!-- Store information -->
<Panel title="Store information" eyebrow="General">
<div class="sc-fields">
<Input
v-model="config.store_name"
label="Store name"
placeholder="My server store"
:required="true"
hint="Displayed to players on the store page"
/>
<label class="cc-field">
<span class="cc-field__label">Description <span class="sc-opt">(optional)</span></span>
<textarea
v-model="config.description"
class="cc-textarea"
rows="3"
placeholder="Welcome to our server store! Support us and get awesome in-game items…"
/>
<span class="cc-field__hint">Brief description shown on the store page.</span>
</label>
<Select
v-model="config.currency"
label="Currency"
:options="currencyOptions"
hint="Currency used for all transactions"
/>
</div>
</Panel>
<!-- PayPal configuration -->
<Panel title="PayPal configuration" eyebrow="Payments" subtitle="Get your API credentials from the PayPal Developer Dashboard.">
<div class="sc-fields">
<Input
v-model="paypalClientId"
label="PayPal Client ID"
placeholder="AXxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
:required="true"
:mono="true"
/>
<Input
v-model="paypalSecret"
label="PayPal Client Secret"
type="password"
placeholder="Enter to update (stored encrypted)"
:mono="true"
hint="Stored encrypted. Leave blank to keep existing secret."
:required="true"
/>
<!-- Sandbox mode toggle -->
<div class="sc-sandbox">
<div class="sc-toggle-row">
<div class="sc-toggle-row__info">
<span class="sc-toggle-row__label">Sandbox mode</span>
<span class="sc-toggle-row__hint">Use PayPal sandbox for testing no real transactions.</span>
</div>
<Switch v-model="config.sandbox_mode" />
</div>
</div>
<Alert
v-if="!config.sandbox_mode && config.enabled"
tone="danger"
title="Production mode active"
>
Real transactions will be processed. Ensure your PayPal credentials are correct.
</Alert>
</div>
</Panel>
</div>
</div>
</template>
<style scoped>
.sc-page { max-width: 860px; margin: 0 auto; display: flex; flex-direction: column; gap: 20px; }
.page__head {
display: flex; align-items: flex-start; justify-content: space-between;
gap: 16px; flex-wrap: wrap; row-gap: 12px;
}
.page__title {
font-size: var(--text-3xl); font-weight: 700; letter-spacing: -0.02em;
color: var(--text-primary); margin-top: 5px;
}
.page__sub { font-size: var(--text-sm); color: var(--text-tertiary); margin-top: 3px; }
.sc-loading { padding: 20px 0; }
.sc-form { display: flex; flex-direction: column; gap: 16px; }
.sc-fields { display: flex; flex-direction: column; gap: 16px; }
/* Shared toggle row */
.sc-toggle-row {
display: flex; align-items: center; justify-content: space-between;
gap: 16px;
}
.sc-toggle-row__info { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.sc-toggle-row__label { font-size: var(--text-sm); font-weight: 500; color: var(--text-primary); }
.sc-toggle-row__hint { font-size: var(--text-xs); color: var(--text-tertiary); }
.sc-sandbox {
background: var(--surface-raised); border-radius: var(--radius-md);
box-shadow: var(--ring-default); padding: 14px 16px;
}
.sc-alert { margin-top: 14px; }
.sc-opt { font-weight: 400; color: var(--text-muted); }
/* Textarea token style */
.cc-textarea {
width: 100%; min-height: 80px; padding: 9px 11px;
background: var(--surface-inset); border: none; border-radius: var(--radius-md);
box-shadow: var(--ring-default); resize: vertical;
font-family: var(--font-sans); font-size: var(--text-sm); color: var(--text-primary);
line-height: var(--leading-normal); outline: none;
transition: var(--transition-colors);
}
.cc-textarea::placeholder { color: var(--text-muted); }
.cc-textarea:focus { box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
</style>