feat(panel): Beta sweep — multi-game coherence, honesty, UX fixes
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

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>
This commit is contained in:
Vantz Stockwell
2026-06-11 22:06:10 -04:00
parent f2ea415840
commit 6f783bfac8
28 changed files with 265 additions and 99 deletions

View File

@@ -1,27 +1,95 @@
# Pricing
> This document mirrors the live pricing page at corrosionmgmt.com/pricing.
---
## Base License — $50 (Launch Price)
## Hobby — $9.99/month
One server. Lifetime access.
15 game server instances · non-commercial use only.
Includes:
* Full control plane
* Auto-Wiper
* Plugin management
* Public site
* RBAC
## Webstore Add-On — $10/month
Integrated monetization platform.
## Modules — $9.99+
Optional feature expansions.
- Up to 5 game server instances
- Non-commercial servers only
- Auto-wiper with rollback
- Plugin management (Rust uMod/Oxide)
- File manager + real-time console
- Scheduled tasks
- Public server page
- Community support
---
Simple. Transparent. No hidden tiers.
## Community — $19.99/month
610 game server instances · non-commercial use only.
Includes:
- Up to 10 game server instances
- Non-commercial servers only
- Auto-wiper with rollback
- Plugin management (Rust uMod/Oxide)
- File manager + real-time console
- Scheduled tasks
- Public server page
- Community support
---
## Operator — $99.99/month _(Most popular)_
Commercial use permitted, or up to 50 servers.
Includes:
- Up to 50 game server instances
- Commercial use permitted
- All games: Rust, Dune: Awakening, Soulmask, Conan Exiles
- Auto-wiper with rollback
- Plugin + mod management
- File manager + real-time console
- Scheduled tasks + maintenance windows
- Player management + RBAC team access
- Public server page + storefront
- Community support + priority bug triage
---
## Network — Custom pricing
50+ servers · hosting partners and fleets. Contact support@corrosionmgmt.com for pricing.
Includes:
- 50 servers base included
- Fleet Blocks: +$49.99/mo per additional 50 servers
- Commercial use permitted
- All games + multi-game hosts
- Full Operator feature set
- Fleet-level management
- Priority bug triage for platform issues
- Community support
---
## Fleet Block Add-On — +$49.99/month per 50 servers
Stack as many Fleet Blocks as your Network plan operation requires.
---
## Direct 1:1 Support — $125/hour (prepaid 1-hour blocks)
Available to any customer. Billed time with a human — not a support tier. Community support (docs, forum, diagnostics, structured bug reports) is included with every plan at no extra charge.
---
## Commercial Use Definition
Commercial use includes monetized communities, paid access, VIP slots, donations, sponsorship-supported servers, hosting providers, or managing servers for others. Hobby and Community plans are non-commercial only. Operator and Network plans permit commercial use.
---
Simple. Transparent. No per-seat charges. No hidden tiers.

View File

@@ -277,17 +277,6 @@ const themeIcon = computed(() => theme.value === 'dark' ? 'sun' : 'moon')
<span class="crumb crumb--cluster">{{ serverName }}</span>
</div>
<!-- Search -->
<div class="top__search">
<svg class="top__search-icon" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
</svg>
<input placeholder="Search servers, players, configs…" readonly />
<span class="top__kbd">
<kbd class="cc-kbd"></kbd><kbd class="cc-kbd">K</kbd>
</span>
</div>
<!-- Actions -->
<div class="top__actions">
<IconButton
@@ -296,7 +285,7 @@ const themeIcon = computed(() => theme.value === 'dark' ? 'sun' : 'moon')
@click="toggleTheme"
/>
<IconButton icon="bell" label="Alerts" @click="router.push('/alerts')" />
<Button size="sm" icon="rocket">Deploy server</Button>
<Button size="sm" icon="rocket" @click="router.push('/server')">Deploy server</Button>
<Avatar
:name="userName"
:size="30"

View File

@@ -66,8 +66,7 @@ const panelUrl = import.meta.env.VITE_PANEL_URL ?? ''
</div>
<div class="footer__col">
<h5>Company</h5>
<RouterLink :to="{ name: 'landing' }">About</RouterLink>
<RouterLink :to="{ name: 'roadmap' }">Changelog</RouterLink>
<RouterLink :to="{ name: 'roadmap' }">Roadmap</RouterLink>
<a href="mailto:support@corrosionmgmt.com">Contact</a>
</div>
</div>

View File

@@ -51,12 +51,12 @@ export function useWebSocket() {
function connect() {
if (!authStore.isAuthenticated) {
console.log('[WebSocket] Not authenticated, skipping connection')
if (import.meta.env.DEV) console.log('[WebSocket] Not authenticated, skipping connection')
return
}
if (isConnecting.value || isConnected.value) {
console.log('[WebSocket] Already connecting or connected')
if (import.meta.env.DEV) console.log('[WebSocket] Already connecting or connected')
return
}
@@ -65,12 +65,12 @@ export function useWebSocket() {
error.value = null
const url = getWebSocketUrl()
console.log('[WebSocket] Connecting to', url.replace(/token=[^&]+/, 'token=***'))
if (import.meta.env.DEV) console.log('[WebSocket] Connecting to', url.replace(/token=[^&]+/, 'token=***'))
ws.value = new WebSocket(url)
ws.value.onopen = () => {
console.log('[WebSocket] Connected')
if (import.meta.env.DEV) console.log('[WebSocket] Connected')
isConnected.value = true
isConnecting.value = false
reconnectAttempts.value = 0
@@ -80,7 +80,7 @@ export function useWebSocket() {
ws.value.onmessage = (event) => {
try {
const message: WebSocketMessage = JSON.parse(event.data)
console.log('[WebSocket] Message received:', message)
if (import.meta.env.DEV) console.log('[WebSocket] Message received:', message)
// Broadcast to all handlers
messageHandlers.forEach(handler => {
@@ -102,7 +102,7 @@ export function useWebSocket() {
}
ws.value.onclose = (event) => {
console.log('[WebSocket] Closed:', event.code, event.reason)
if (import.meta.env.DEV) console.log('[WebSocket] Closed:', event.code, event.reason)
isConnected.value = false
isConnecting.value = false
@@ -132,7 +132,7 @@ export function useWebSocket() {
30000 // Max 30 seconds
)
console.log(
if (import.meta.env.DEV) console.log(
`[WebSocket] Reconnecting in ${delay}ms (attempt ${reconnectAttempts.value}/${maxReconnectAttempts})`
)
@@ -148,7 +148,7 @@ export function useWebSocket() {
}
if (ws.value) {
console.log('[WebSocket] Disconnecting')
if (import.meta.env.DEV) console.log('[WebSocket] Disconnecting')
ws.value.close(1000, 'Client disconnect')
ws.value = null
}

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useApi } from '@/composables/useApi'
import { useToastStore } from '@/stores/toast'
import { safeDate } from '@/utils/formatters'
import Panel from '@/components/ds/data/Panel.vue'
import Button from '@/components/ds/core/Button.vue'
@@ -30,6 +31,7 @@ interface AlertHistoryEntry {
}
const api = useApi()
const toast = useToastStore()
const config = ref<AlertConfig>({
population_drop_enabled: false,
population_drop_threshold_percent: 50,
@@ -60,9 +62,9 @@ async function saveConfig() {
isSaving.value = true
try {
await api.put('/alerts/config', config.value)
alert('Alert configuration saved')
toast.success('Alert configuration saved')
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to save configuration')
toast.error(err instanceof Error ? err.message : 'Failed to save configuration')
} finally {
isSaving.value = false
}

View File

@@ -98,7 +98,7 @@ const renderCharts = () => {
},
xAxis: {
type: 'category',
data: timeseries.value.timestamps.map(ts => new Date(ts).toLocaleString('en-US', {
data: (timeseries.value.timestamps ?? []).map(ts => new Date(ts).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit'
@@ -116,7 +116,7 @@ const renderCharts = () => {
{
name: 'Players',
type: 'line',
data: timeseries.value.player_count,
data: timeseries.value.player_count ?? [],
smooth: true,
lineStyle: { color: accent, width: 2 },
areaStyle: {
@@ -160,7 +160,7 @@ const renderCharts = () => {
},
xAxis: {
type: 'category',
data: timeseries.value.timestamps.map(ts => new Date(ts).toLocaleString('en-US', {
data: (timeseries.value.timestamps ?? []).map(ts => new Date(ts).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit'
@@ -191,7 +191,7 @@ const renderCharts = () => {
name: 'FPS',
type: 'line',
yAxisIndex: 0,
data: timeseries.value.fps,
data: timeseries.value.fps ?? [],
smooth: true,
lineStyle: { color: '#10b981', width: 2 },
itemStyle: { color: '#10b981' }
@@ -200,7 +200,7 @@ const renderCharts = () => {
name: 'Entities',
type: 'line',
yAxisIndex: 1,
data: timeseries.value.entity_count,
data: timeseries.value.entity_count ?? [],
smooth: true,
lineStyle: { color: '#6366f1', width: 2 },
itemStyle: { color: '#6366f1' }
@@ -287,7 +287,7 @@ onMounted(() => {
label="Unique players"
:value="summary.unique_players ?? '—'"
icon="bar-chart-3"
note="Phase 2.2"
note="Coming soon"
/>
</div>
@@ -302,9 +302,9 @@ onMounted(() => {
</div>
<!-- Player Retention placeholder -->
<Panel eyebrow="Coming in phase 2" title="Player retention">
<Panel eyebrow="Coming soon" title="Player retention">
<template #title-append>
<Badge tone="neutral">Phase 2</Badge>
<Badge tone="neutral">Coming soon</Badge>
</template>
<div class="analytics-view__retention-grid">
<div class="analytics-view__retention-cell">
@@ -324,7 +324,7 @@ onMounted(() => {
</div>
</div>
<p class="analytics-view__retention-footer">
Player retention analytics will be available in phase 2.
Player retention analytics are coming soon.
</p>
</Panel>
</template>

View File

@@ -1,6 +1,8 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useAutoDoorsStore } from '@/stores/autodoors'
import { useThemeGame } from '@/composables/useThemeGame'
import { useGameProfile } from '@/config/gameProfiles'
import Panel from '@/components/ds/data/Panel.vue'
import Button from '@/components/ds/core/Button.vue'
import Icon from '@/components/ds/core/Icon.vue'
@@ -8,6 +10,8 @@ import Switch from '@/components/ds/forms/Switch.vue'
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
const store = useAutoDoorsStore()
const { activeGame } = useThemeGame()
const gameProfile = computed(() => useGameProfile(activeGame.value === 'all' ? 'rust' : activeGame.value))
const showCreateModal = ref(false)
const showImportModal = ref(false)
@@ -159,6 +163,16 @@ function getBool(path: string, def: boolean): boolean {
<template>
<div class="adv">
<!-- uMod-only guard: AutoDoors is an Oxide/uMod plugin -->
<Panel v-if="gameProfile.mods !== 'umod'">
<EmptyState
icon="door-open"
title="Rust / uMod only"
description="Auto Doors is only available for Rust (uMod/Oxide) servers."
/>
</Panel>
<template v-else>
<!-- Page head -->
<div class="adv__head">
<div class="adv__head-id">
@@ -504,6 +518,7 @@ function getBool(path: string, def: boolean): boolean {
</div>
</div>
</div>
</template>
</div>
</template>

View File

@@ -1,6 +1,8 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useBetterChatStore } from '@/stores/betterchat'
import { useThemeGame } from '@/composables/useThemeGame'
import { useGameProfile } from '@/config/gameProfiles'
import Panel from '@/components/ds/data/Panel.vue'
import Button from '@/components/ds/core/Button.vue'
@@ -13,6 +15,8 @@ import Switch from '@/components/ds/forms/Switch.vue'
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
const store = useBetterChatStore()
const { activeGame } = useThemeGame()
const gameProfile = computed(() => useGameProfile(activeGame.value === 'all' ? 'rust' : activeGame.value))
const activeTab = ref<string>('groups')
const showCreateModal = ref(false)
@@ -276,6 +280,16 @@ const editGroupFormatConsole = computed<string>({
<template>
<div class="bch">
<!-- uMod-only guard: BetterChat is an Oxide/uMod plugin -->
<Panel v-if="gameProfile.mods !== 'umod'">
<EmptyState
icon="message-square"
title="Rust / uMod only"
description="Better Chat is only available for Rust (uMod/Oxide) servers."
/>
</Panel>
<template v-else>
<!-- Page head -->
<div class="bch__head">
<div class="bch__head-id">
@@ -696,6 +710,7 @@ const editGroupFormatConsole = computed<string>({
</div>
</div>
</div>
</template>
</div>
</template>

View File

@@ -2,6 +2,7 @@
import { ref, computed, onMounted } from 'vue'
import { useApi } from '@/composables/useApi'
import { useToastStore } from '@/stores/toast'
import { useThemeGame } from '@/composables/useThemeGame'
import type { ChatMessage } from '@/types'
import Panel from '@/components/ds/data/Panel.vue'
import Button from '@/components/ds/core/Button.vue'
@@ -14,6 +15,8 @@ import Tabs from '@/components/ds/navigation/Tabs.vue'
const api = useApi()
const toast = useToastStore()
const { activeGame } = useThemeGame()
const playerIdLabel = computed(() => activeGame.value === 'rust' || activeGame.value === 'all' ? 'Steam ID' : 'Player ID')
const messages = ref<ChatMessage[]>([])
const isLoading = ref(false)
@@ -122,7 +125,7 @@ onMounted(() => {
<Input
v-model="searchQuery"
icon="search"
placeholder="Search messages, players, or Steam IDs…"
:placeholder="`Search messages, players, or ${playerIdLabel}s…`"
size="sm"
style="max-width: 340px;"
/>

View File

@@ -383,7 +383,7 @@ function navServer() { router.push('/server') }
v-model="consoleInput"
:mono="true"
size="sm"
placeholder="say, kick, ban, oxide.reload …"
:placeholder="profile.mods === 'umod' ? 'say, kick, ban, oxide.reload …' : 'say, kick, ban …'"
:disabled="!isConnected"
style="flex: 1"
@keydown.enter="sendConsoleCommand"

View File

@@ -1,6 +1,8 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useFurnaceSplitterStore } from '@/stores/furnacesplitter'
import { useThemeGame } from '@/composables/useThemeGame'
import { useGameProfile } from '@/config/gameProfiles'
import Panel from '@/components/ds/data/Panel.vue'
import Button from '@/components/ds/core/Button.vue'
import Icon from '@/components/ds/core/Icon.vue'
@@ -8,6 +10,8 @@ import Switch from '@/components/ds/forms/Switch.vue'
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
const store = useFurnaceSplitterStore()
const { activeGame } = useThemeGame()
const gameProfile = computed(() => useGameProfile(activeGame.value === 'all' ? 'rust' : activeGame.value))
const showCreateModal = ref(false)
const showImportModal = ref(false)
@@ -116,6 +120,16 @@ function getBool(path: string, def: boolean): boolean {
<template>
<div class="fsv">
<!-- uMod-only guard: Furnace Splitter is an Oxide/uMod plugin -->
<Panel v-if="gameProfile.mods !== 'umod'">
<EmptyState
icon="flame"
title="Rust / uMod only"
description="Furnace Splitter is only available for Rust (uMod/Oxide) servers."
/>
</Panel>
<template v-else>
<!-- Page head -->
<div class="fsv__head">
<div class="fsv__head-id">
@@ -326,6 +340,7 @@ function getBool(path: string, def: boolean): boolean {
</div>
</div>
</div>
</template>
</div>
</template>

View File

@@ -2,6 +2,7 @@
import { ref, onMounted } from 'vue'
import { useApi } from '@/composables/useApi'
import { useAuthStore } from '@/stores/auth'
import { useToastStore } from '@/stores/toast'
import { safeFileSize, safeDate } from '@/utils/formatters'
import Panel from '@/components/ds/data/Panel.vue'
import Button from '@/components/ds/core/Button.vue'
@@ -20,6 +21,7 @@ interface ExportRecord {
const api = useApi()
const authStore = useAuthStore()
const toast = useToastStore()
const exports = ref<ExportRecord[]>([])
const isExporting = ref(false)
const isImporting = ref(false)
@@ -37,7 +39,7 @@ async function createExport() {
isExporting.value = true
try {
const result = await api.post<{ export_id: string }>('/migration/export', { export_type: exportType.value })
alert(`Export created: ${result.export_id}`)
toast.success(`Export created: ${result.export_id}`)
await fetchExports()
} finally {
isExporting.value = false

View File

@@ -3,6 +3,7 @@ import { ref, computed, onMounted } from 'vue'
import { useServerStore } from '@/stores/server'
import { useApi } from '@/composables/useApi'
import { useToastStore } from '@/stores/toast'
import { useThemeGame } from '@/composables/useThemeGame'
import Panel from '@/components/ds/data/Panel.vue'
import Button from '@/components/ds/core/Button.vue'
import Badge from '@/components/ds/core/Badge.vue'
@@ -15,6 +16,8 @@ import Tabs from '@/components/ds/navigation/Tabs.vue'
const server = useServerStore()
const api = useApi()
const toast = useToastStore()
const { activeGame } = useThemeGame()
const playerIdLabel = computed(() => activeGame.value === 'rust' || activeGame.value === 'all' ? 'Steam ID' : 'Player ID')
interface Player {
steam_id: string
@@ -166,7 +169,7 @@ onMounted(() => {
<Input
v-model="searchQuery"
icon="search"
placeholder="Search by name or Steam ID…"
:placeholder="`Search by name or ${playerIdLabel}…`"
size="sm"
:mono="false"
style="max-width: 320px;"
@@ -197,7 +200,7 @@ onMounted(() => {
<thead>
<tr>
<th>Player</th>
<th>Steam ID</th>
<th>{{ playerIdLabel }}</th>
<th>Status</th>
<th>Session</th>
<th>Playtime</th>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useApi } from '@/composables/useApi'
import { useToastStore } from '@/stores/toast'
import { safeDate } from '@/utils/formatters'
import Panel from '@/components/ds/data/Panel.vue'
import Button from '@/components/ds/core/Button.vue'
@@ -22,6 +23,7 @@ interface ScheduledTask {
}
const api = useApi()
const toast = useToastStore()
const tasks = ref<ScheduledTask[]>([])
const isLoading = ref(false)
const showModal = ref(false)
@@ -93,7 +95,7 @@ async function saveTask() {
showModal.value = false
await fetchTasks()
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to save task')
toast.error(err instanceof Error ? err.message : 'Failed to save task')
}
}

View File

@@ -108,7 +108,7 @@ const showCreds = ref(false)
const tomlCopied = ref(false)
const deployForm = ref<DeploymentConfig>({
server_name: 'My Rust Server',
server_name: '',
max_players: 100,
world_size: 4000,
seed: Math.floor(Math.random() * 2147483647),
@@ -465,7 +465,7 @@ onMounted(async () => {
}
if (msg.type === 'event' && msg.event === 'oxide_status') {
oxideStatus.value = msg.data as { stage: string; progress: number; message: string; error?: string }
if (msg.data && (msg.data as any).stage === 'complete' || (msg.data as any).stage === 'failed') {
if (msg.data && ((msg.data as any).stage === 'complete' || (msg.data as any).stage === 'failed')) {
isInstallingOxide.value = false
}
}
@@ -935,7 +935,7 @@ onMounted(async () => {
<!-- Conan Exiles special concepts (Clans / Thralls / Purge) -->
<Panel
v-if="profile.accent === 'conan'"
v-if="activeGame === 'conan'"
title="Conan Exiles concepts"
subtitle="Key admin mechanics for Conan Exiles servers"
>

View File

@@ -166,7 +166,7 @@ onMounted(() => {
<Input
v-model="config.store_name"
label="Store name"
placeholder="My Rust Server Store"
placeholder="My server store"
:required="true"
hint="Displayed to players on the store page"
/>

View File

@@ -1,6 +1,8 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useApi } from '@/composables/useApi'
import { useThemeGame } from '@/composables/useThemeGame'
import { useGameProfile } from '@/config/gameProfiles'
import type { StoreCategory, StoreItem } from '@/types'
import { safeFixed } from '@/utils/formatters'
import Panel from '@/components/ds/data/Panel.vue'
@@ -14,6 +16,8 @@ import Select from '@/components/ds/forms/Select.vue'
import Checkbox from '@/components/ds/forms/Checkbox.vue'
const api = useApi()
const { activeGame } = useThemeGame()
const gameProfile = computed(() => useGameProfile(activeGame.value === 'all' ? 'rust' : activeGame.value))
const tab = ref<'categories' | 'items'>('categories')
const isLoading = ref(false)
@@ -46,12 +50,19 @@ const itemForm = ref({
enabled: true
})
const itemTypes = [
const itemTypesUmod = [
{ value: 'kit', label: 'Kit', example: 'inventory.giveto {steam_id} rifle.ak 1' },
{ value: 'rank', label: 'Rank', example: 'oxide.usergroup add {steam_id} vip' },
{ value: 'currency', label: 'Currency', example: 'eco deposit {steam_id} 1000' },
{ value: 'command', label: 'Custom command', example: 'yourplugin.givereward {steam_id}' }
{ value: 'command', label: 'Custom command', example: 'yourplugin.givereward {steam_id}' },
]
const itemTypesGeneric = [
{ value: 'kit', label: 'Kit', example: 'givecontent {steam_id} item_id 1' },
{ value: 'rank', label: 'Rank', example: 'setrank {steam_id} vip' },
{ value: 'currency', label: 'Currency', example: 'addcurrency {steam_id} 1000' },
{ value: 'command', label: 'Custom command', example: 'yourplugin.givereward {steam_id}' },
]
const itemTypes = computed(() => gameProfile.value.mods === 'umod' ? itemTypesUmod : itemTypesGeneric)
const tabItems = computed(() => [
{ value: 'categories', label: 'Categories', count: categories.value.length },
@@ -251,7 +262,7 @@ function getCategoryName(categoryId: string | null): string {
}
const selectedTypeExample = computed(() => {
const type = itemTypes.find(t => t.value === itemForm.value.item_type)
const type = itemTypes.value.find(t => t.value === itemForm.value.item_type)
return type?.example ?? ''
})

View File

@@ -1,6 +1,8 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useTimedExecuteStore } from '@/stores/timedexecute'
import { useThemeGame } from '@/composables/useThemeGame'
import { useGameProfile } from '@/config/gameProfiles'
import Panel from '@/components/ds/data/Panel.vue'
import Button from '@/components/ds/core/Button.vue'
@@ -12,6 +14,8 @@ import Switch from '@/components/ds/forms/Switch.vue'
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
const store = useTimedExecuteStore()
const { activeGame } = useThemeGame()
const gameProfile = computed(() => useGameProfile(activeGame.value === 'all' ? 'rust' : activeGame.value))
const activeTab = ref<string>('timed')
const showCreateModal = ref(false)
@@ -360,7 +364,7 @@ const importConfigNameModel = computed<string>({
<span class="te__presets-label">Quick add:</span>
<button class="te__preset" @click="addPresetTimer('server.save', 300)">server.save (5 min)</button>
<button class="te__preset" @click="addPresetTimer('say Server restart warning!', 3600)">Restart warning (1 h)</button>
<button class="te__preset" @click="addPresetTimer('oxide.reload *', 7200)">Reload plugins (2 h)</button>
<button v-if="gameProfile.mods === 'umod'" class="te__preset" @click="addPresetTimer('oxide.reload *', 7200)">Reload plugins (2 h)</button>
</div>
<EmptyState

View File

@@ -1,7 +1,9 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useWipeStore } from '@/stores/wipe'
import { useToastStore } from '@/stores/toast'
import { useThemeGame } from '@/composables/useThemeGame'
import { useGameProfile } from '@/config/gameProfiles'
import type { WipeProfile } from '@/types'
import Panel from '@/components/ds/data/Panel.vue'
import Button from '@/components/ds/core/Button.vue'
@@ -13,6 +15,8 @@ import EmptyState from '@/components/ds/feedback/EmptyState.vue'
const wipeStore = useWipeStore()
const toast = useToastStore()
const { activeGame } = useThemeGame()
const gameProfile = computed(() => useGameProfile(activeGame.value === 'all' ? 'rust' : activeGame.value))
const expandedId = ref<string | null>(null)
const showModal = ref(false)
@@ -242,7 +246,7 @@ onMounted(() => {
{{ profile.post_wipe_config.verify_server_started ? 'Yes' : 'No' }}
</Badge>
</div>
<div class="detail-kv">
<div v-if="gameProfile.mods === 'umod'" class="detail-kv">
<span class="detail-k">Verify plugins loaded</span>
<Badge :tone="profile.post_wipe_config.verify_plugins_loaded ? 'online' : 'neutral'">
{{ profile.post_wipe_config.verify_plugins_loaded ? 'Yes' : 'No' }}
@@ -359,6 +363,7 @@ onMounted(() => {
label="Verify correct map"
/>
<Checkbox
v-if="gameProfile.mods === 'umod'"
v-model="form.post_wipe_config.verify_plugins_loaded"
label="Verify plugins loaded"
/>

View File

@@ -1,9 +1,11 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useWipeStore } from '@/stores/wipe'
import { useServerStore } from '@/stores/server'
import { useToastStore } from '@/stores/toast'
import { useApi } from '@/composables/useApi'
import { useThemeGame } from '@/composables/useThemeGame'
import { useGameProfile } from '@/config/gameProfiles'
import { RouterLink } from 'vue-router'
import { safeDate } from '@/utils/formatters'
import Panel from '@/components/ds/data/Panel.vue'
@@ -18,6 +20,8 @@ const wipeStore = useWipeStore()
const server = useServerStore()
const toast = useToastStore()
const api = useApi()
const { activeGame } = useThemeGame()
const profile = computed(() => useGameProfile(activeGame.value === 'all' ? 'rust' : activeGame.value))
const triggerType = ref<'map' | 'blueprint' | 'full'>('map')
const selectedProfileId = ref<string>('')
@@ -71,11 +75,18 @@ async function toggleSchedule(scheduleId: string, currentlyActive: boolean) {
}
}
const WIPE_TYPE_OPTIONS = [
const WIPE_TYPE_OPTIONS_BASE = [
{ value: 'map', label: 'Map' },
{ value: 'full', label: 'Full' },
]
const WIPE_TYPE_OPTIONS_RUST = [
{ value: 'map', label: 'Map' },
{ value: 'blueprint', label: 'Blueprint' },
{ value: 'full', label: 'Full' },
]
const wipeTypeOptions = computed(() =>
profile.value.mods === 'umod' ? WIPE_TYPE_OPTIONS_RUST : WIPE_TYPE_OPTIONS_BASE
)
function profileOptions() {
const opts: { value: string; label: string }[] = [{ value: '', label: 'No profile' }]
@@ -148,7 +159,7 @@ onMounted(async () => {
<div class="cc-field__label">Wipe type</div>
<div class="type-seg">
<button
v-for="opt in WIPE_TYPE_OPTIONS"
v-for="opt in wipeTypeOptions"
:key="opt.value"
type="button"
class="type-seg__btn"

View File

@@ -107,7 +107,7 @@ async function completeSetup() {
<div v-if="step === 1" class="setup-card">
<div class="setup-card__head">
<h1 class="setup-card__title">Configure your server</h1>
<p class="setup-card__sub">Connect your Rust server to Corrosion.</p>
<p class="setup-card__sub">Connect your game server to Corrosion.</p>
</div>
<Alert v-if="error" tone="danger">{{ error }}</Alert>
@@ -117,7 +117,7 @@ async function completeSetup() {
v-model="serverForm.server_name"
label="Server name"
type="text"
placeholder="My Rust Server"
placeholder="My game server"
:required="true"
/>

View File

@@ -1,13 +1,7 @@
<script setup lang="ts">
/**
* EarlyAccess signup page.
*
* Backend endpoint: POST /api/early-access
* The early_access_signups entity exists but no NestJS controller/module exposes it yet.
* TODO: Create backend-nest/src/modules/early-access/ with a @Public() POST /early-access
* controller that accepts { email, name?, game_interest? } and writes to early_access_signups.
* The server_count column on the entity is varchar(10) — map game_interest to it or add a
* migration adding a game_interest column.
* Backend endpoint: POST /api/early-access — live and functional.
*/
import { ref, onMounted, onUnmounted } from 'vue'
import { RouterLink } from 'vue-router'
@@ -130,12 +124,12 @@ onUnmounted(() => { io?.disconnect() })
<div class="icard">
<div class="icard__ic"><Icon name="message-square" :size="16" /></div>
<b>Direct feedback channel</b>
<p>Early access operators have a direct line for platform bug reports and feature input.</p>
<p>Early access operators have a direct feedback channel for platform bug reports and feature input.</p>
</div>
<div class="icard">
<div class="icard__ic"><Icon name="box" :size="16" /></div>
<b>Rust-first</b>
<p>Rust support is complete. Dune, Conan, and Soulmask are in active development.</p>
<b>Multi-game</b>
<p>Rust is fully operational today. Dune: Awakening, Conan Exiles, and Soulmask support is in active development.</p>
</div>
<div class="icard">
<div class="icard__ic"><Icon name="users" :size="16" /></div>

View File

@@ -69,7 +69,7 @@ const groups: FaqGroup[] = [
{
question: 'Does Corrosion replace AMP or Pterodactyl?',
answer:
'It can, depending on your use case. Corrosion is an independent control plane — it does not require an existing game panel. If you are currently using AMP or Pterodactyl, migration tooling is available in the panel.',
'It can, depending on your use case. Corrosion is an independent control plane — it does not require an existing game panel. If you are currently using AMP or Pterodactyl, migration tooling is in active development.',
},
{
question: 'What happens if Corrosion goes offline?',

View File

@@ -197,6 +197,14 @@ const mockActiveGame = activeGame
</span>
<span class="st"><b />online</span>
</div>
<div class="mock__row">
<span class="g"><Icon name="drama" :size="13" /></span>
<span class="nm">
Ritual Cluster · PvE
<small>soul-host · soulmask</small>
</span>
<span class="st"><b />online</span>
</div>
<div class="mock__row">
<span class="g"><Icon name="swords" :size="13" /></span>
<span class="nm">
@@ -244,7 +252,7 @@ const mockActiveGame = activeGame
</div>
<div class="pain__item">
<span class="pain__x"><Icon name="x" :size="14" /></span>
Juggling Discord bots &amp; cron tasks
Juggling community bots &amp; cron tasks
</div>
<div class="pain__item">
<span class="pain__x"><Icon name="x" :size="14" /></span>
@@ -442,7 +450,7 @@ const mockActiveGame = activeGame
</div>
<div class="feat">
<span class="feat__ic"><Icon name="bell" :size="16" /></span>
<b>Discord / status announcements</b>
<b>Webhook / status announcements</b>
</div>
<div class="feat">
<span class="feat__ic"><Icon name="undo-2" :size="16" /></span>
@@ -577,7 +585,7 @@ const mockActiveGame = activeGame
<div class="chip-card"><Icon name="timer" :size="16" style="color:var(--accent-text)" />Wipe countdown</div>
<div class="chip-card"><Icon name="puzzle" :size="16" style="color:var(--accent-text)" />Mod / plugin list</div>
<div class="chip-card"><Icon name="megaphone" :size="16" style="color:var(--accent-text)" />Announcements</div>
<div class="chip-card chip-card--accent"><Icon name="shopping-cart" :size="16" />Integrated webstore</div>
<div class="chip-card chip-card--accent"><Icon name="shopping-cart" :size="16" />Integrated webstore (coming soon)</div>
</div>
<p
class="closing reveal"
@@ -620,9 +628,9 @@ const mockActiveGame = activeGame
<div class="plan">
<div class="plan__tag" />
<div class="plan__name">Network</div>
<div class="plan__price">$99.99<small>/mo</small></div>
<div class="plan__price">Custom</div>
<div class="plan__scope">50+ servers for fleets and hosting partners. Fleet Blocks add capacity.</div>
<RouterLink class="btn btn--ghost" :to="{ name: 'early-access' }">Start</RouterLink>
<a class="btn btn--ghost" href="mailto:support@corrosionmgmt.com">Contact us</a>
</div>
</div>
<div class="fleetblock reveal">

View File

@@ -41,6 +41,7 @@ interface Plan {
featured: boolean
cta: string
ctaVariant: 'primary' | 'ghost'
ctaHref?: string
features: PlanFeature[]
}
@@ -58,7 +59,7 @@ const plans: Plan[] = [
{ text: 'Up to 5 game server instances' },
{ text: 'Non-commercial servers only' },
{ text: 'Auto-wiper with rollback' },
{ text: 'Plugin management (Rust)' },
{ text: 'Plugin management (Rust uMod/Oxide)' },
{ text: 'File manager + real-time console' },
{ text: 'Scheduled tasks' },
{ text: 'Public server page' },
@@ -78,7 +79,7 @@ const plans: Plan[] = [
{ text: 'Up to 10 game server instances' },
{ text: 'Non-commercial servers only' },
{ text: 'Auto-wiper with rollback' },
{ text: 'Plugin management (Rust)' },
{ text: 'Plugin management (Rust uMod/Oxide)' },
{ text: 'File manager + real-time console' },
{ text: 'Scheduled tasks' },
{ text: 'Public server page' },
@@ -109,13 +110,14 @@ const plans: Plan[] = [
},
{
name: 'Network',
price: '$99.99',
period: '/mo',
price: 'Custom',
period: '',
scope: '50+ servers · hosting partners + fleets',
tag: '',
featured: false,
cta: 'Join early access',
cta: 'Contact us',
ctaVariant: 'ghost',
ctaHref: 'mailto:support@corrosionmgmt.com',
features: [
{ text: '50 servers base included' },
{ text: 'Fleet Blocks: +$49.99/mo per 50 servers' },
@@ -176,7 +178,16 @@ const plans: Plan[] = [
</li>
</ul>
<a
v-if="plan.ctaHref"
class="btn"
:class="plan.ctaVariant === 'primary' ? 'btn--primary' : 'btn--ghost'"
:href="plan.ctaHref"
>
{{ plan.cta }}
</a>
<RouterLink
v-else
class="btn"
:class="plan.ctaVariant === 'primary' ? 'btn--primary' : 'btn--ghost'"
:to="{ name: 'early-access' }"

View File

@@ -25,7 +25,7 @@ const groups: RoadmapGroup[] = [
status: 'shipped',
label: 'Phase 1 — Foundation',
description:
'The core control plane is live. Rust server operators can install the agent, connect their server, and manage it entirely from the panel.',
'The core control plane is live. Game server operators can install the agent, connect their server, and manage it entirely from the panel.',
items: [
{ text: 'Host agent (Windows + Linux)', note: 'Go binary, outbound NATS, zero inbound ports' },
{ text: 'Core control plane', note: 'NestJS API, multi-tenant PostgreSQL, JWT RBAC' },

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useToastStore } from '@/stores/toast'
import Panel from '@/components/ds/data/Panel.vue'
import StatCard from '@/components/ds/data/StatCard.vue'
import Badge from '@/components/ds/core/Badge.vue'
@@ -25,6 +26,7 @@ interface ServerInfo {
const route = useRoute()
const subdomain = route.params.subdomain as string
const toast = useToastStore()
const serverInfo = ref<ServerInfo | null>(null)
const isLoading = ref(false)
const error = ref('')
@@ -48,7 +50,7 @@ async function fetchServerInfo() {
function copyConnectUrl() {
if (serverInfo.value?.connect_url) {
navigator.clipboard.writeText(serverInfo.value.connect_url)
alert('Connect URL copied to clipboard')
toast.success('Connect URL copied to clipboard')
}
}
@@ -142,14 +144,14 @@ onMounted(() => {
<!-- Discord -->
<Panel v-if="serverInfo.discord_invite" title="Community">
<Alert tone="info" title="Join our Discord">
<Alert tone="info" title="Join our community">
<template #actions>
<a
:href="serverInfo.discord_invite"
target="_blank"
rel="noopener noreferrer"
>
<Button size="sm" variant="secondary" icon="external-link">Join Discord</Button>
<Button size="sm" variant="secondary" icon="external-link">Join community</Button>
</a>
</template>
</Alert>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useToastStore } from '@/stores/toast'
import type { PublicStoreInfo, PublicStoreItem, StorePurchaseRequest, StorePurchaseResponse } from '@/types'
import { safeCurrency } from '@/utils/formatters'
import Panel from '@/components/ds/data/Panel.vue'
@@ -15,6 +16,7 @@ import Logo from '@/components/ds/brand/Logo.vue'
const route = useRoute()
const subdomain = computed(() => route.params.subdomain as string)
const toast = useToastStore()
const isLoading = ref(false)
const storeInfo = ref<PublicStoreInfo | null>(null)
@@ -125,6 +127,12 @@ async function confirmPurchase() {
body: JSON.stringify(payload)
})
if (response.status === 503) {
closePurchaseModal()
toast.info("Checkout is coming soon — payments aren't enabled yet.")
return
}
if (!response.ok) {
const error = await response.json().catch(() => ({ message: 'Purchase failed' }))
throw new Error(error.message || 'Purchase failed')
@@ -137,8 +145,7 @@ async function confirmPurchase() {
// Close modal and show success message
closePurchaseModal()
// TODO: Show toast notification
alert('PayPal window opened. Complete your payment there. Your items will be delivered automatically after payment.')
toast.success('PayPal window opened. Complete your payment there. Your items will be delivered automatically after payment.')
} catch (error: any) {
purchaseError.value = error.message || 'Purchase failed. Please try again.'
} finally {