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 # 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: Includes:
* Full control plane - Up to 5 game server instances
* Auto-Wiper - Non-commercial servers only
* Plugin management - Auto-wiper with rollback
* Public site - Plugin management (Rust uMod/Oxide)
* RBAC - File manager + real-time console
- Scheduled tasks
## Webstore Add-On — $10/month - Public server page
- Community support
Integrated monetization platform.
## Modules — $9.99+
Optional feature expansions.
--- ---
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> <span class="crumb crumb--cluster">{{ serverName }}</span>
</div> </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 --> <!-- Actions -->
<div class="top__actions"> <div class="top__actions">
<IconButton <IconButton
@@ -296,7 +285,7 @@ const themeIcon = computed(() => theme.value === 'dark' ? 'sun' : 'moon')
@click="toggleTheme" @click="toggleTheme"
/> />
<IconButton icon="bell" label="Alerts" @click="router.push('/alerts')" /> <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 <Avatar
:name="userName" :name="userName"
:size="30" :size="30"

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useAutoDoorsStore } from '@/stores/autodoors' import { useAutoDoorsStore } from '@/stores/autodoors'
import { useThemeGame } from '@/composables/useThemeGame'
import { useGameProfile } from '@/config/gameProfiles'
import Panel from '@/components/ds/data/Panel.vue' import Panel from '@/components/ds/data/Panel.vue'
import Button from '@/components/ds/core/Button.vue' import Button from '@/components/ds/core/Button.vue'
import Icon from '@/components/ds/core/Icon.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' import EmptyState from '@/components/ds/feedback/EmptyState.vue'
const store = useAutoDoorsStore() const store = useAutoDoorsStore()
const { activeGame } = useThemeGame()
const gameProfile = computed(() => useGameProfile(activeGame.value === 'all' ? 'rust' : activeGame.value))
const showCreateModal = ref(false) const showCreateModal = ref(false)
const showImportModal = ref(false) const showImportModal = ref(false)
@@ -159,6 +163,16 @@ function getBool(path: string, def: boolean): boolean {
<template> <template>
<div class="adv"> <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 --> <!-- Page head -->
<div class="adv__head"> <div class="adv__head">
<div class="adv__head-id"> <div class="adv__head-id">
@@ -504,6 +518,7 @@ function getBool(path: string, def: boolean): boolean {
</div> </div>
</div> </div>
</div> </div>
</template>
</div> </div>
</template> </template>

View File

@@ -1,6 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed } from 'vue'
import { useBetterChatStore } from '@/stores/betterchat' import { useBetterChatStore } from '@/stores/betterchat'
import { useThemeGame } from '@/composables/useThemeGame'
import { useGameProfile } from '@/config/gameProfiles'
import Panel from '@/components/ds/data/Panel.vue' import Panel from '@/components/ds/data/Panel.vue'
import Button from '@/components/ds/core/Button.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' import EmptyState from '@/components/ds/feedback/EmptyState.vue'
const store = useBetterChatStore() const store = useBetterChatStore()
const { activeGame } = useThemeGame()
const gameProfile = computed(() => useGameProfile(activeGame.value === 'all' ? 'rust' : activeGame.value))
const activeTab = ref<string>('groups') const activeTab = ref<string>('groups')
const showCreateModal = ref(false) const showCreateModal = ref(false)
@@ -276,6 +280,16 @@ const editGroupFormatConsole = computed<string>({
<template> <template>
<div class="bch"> <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 --> <!-- Page head -->
<div class="bch__head"> <div class="bch__head">
<div class="bch__head-id"> <div class="bch__head-id">
@@ -696,6 +710,7 @@ const editGroupFormatConsole = computed<string>({
</div> </div>
</div> </div>
</div> </div>
</template>
</div> </div>
</template> </template>

View File

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

View File

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

View File

@@ -1,6 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useFurnaceSplitterStore } from '@/stores/furnacesplitter' import { useFurnaceSplitterStore } from '@/stores/furnacesplitter'
import { useThemeGame } from '@/composables/useThemeGame'
import { useGameProfile } from '@/config/gameProfiles'
import Panel from '@/components/ds/data/Panel.vue' import Panel from '@/components/ds/data/Panel.vue'
import Button from '@/components/ds/core/Button.vue' import Button from '@/components/ds/core/Button.vue'
import Icon from '@/components/ds/core/Icon.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' import EmptyState from '@/components/ds/feedback/EmptyState.vue'
const store = useFurnaceSplitterStore() const store = useFurnaceSplitterStore()
const { activeGame } = useThemeGame()
const gameProfile = computed(() => useGameProfile(activeGame.value === 'all' ? 'rust' : activeGame.value))
const showCreateModal = ref(false) const showCreateModal = ref(false)
const showImportModal = ref(false) const showImportModal = ref(false)
@@ -116,6 +120,16 @@ function getBool(path: string, def: boolean): boolean {
<template> <template>
<div class="fsv"> <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 --> <!-- Page head -->
<div class="fsv__head"> <div class="fsv__head">
<div class="fsv__head-id"> <div class="fsv__head-id">
@@ -326,6 +340,7 @@ function getBool(path: string, def: boolean): boolean {
</div> </div>
</div> </div>
</div> </div>
</template>
</div> </div>
</template> </template>

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useApi } from '@/composables/useApi' import { useApi } from '@/composables/useApi'
import { useToastStore } from '@/stores/toast'
import { safeDate } from '@/utils/formatters' import { safeDate } from '@/utils/formatters'
import Panel from '@/components/ds/data/Panel.vue' import Panel from '@/components/ds/data/Panel.vue'
import Button from '@/components/ds/core/Button.vue' import Button from '@/components/ds/core/Button.vue'
@@ -22,6 +23,7 @@ interface ScheduledTask {
} }
const api = useApi() const api = useApi()
const toast = useToastStore()
const tasks = ref<ScheduledTask[]>([]) const tasks = ref<ScheduledTask[]>([])
const isLoading = ref(false) const isLoading = ref(false)
const showModal = ref(false) const showModal = ref(false)
@@ -93,7 +95,7 @@ async function saveTask() {
showModal.value = false showModal.value = false
await fetchTasks() await fetchTasks()
} catch (err) { } 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 tomlCopied = ref(false)
const deployForm = ref<DeploymentConfig>({ const deployForm = ref<DeploymentConfig>({
server_name: 'My Rust Server', server_name: '',
max_players: 100, max_players: 100,
world_size: 4000, world_size: 4000,
seed: Math.floor(Math.random() * 2147483647), seed: Math.floor(Math.random() * 2147483647),
@@ -465,7 +465,7 @@ onMounted(async () => {
} }
if (msg.type === 'event' && msg.event === 'oxide_status') { if (msg.type === 'event' && msg.event === 'oxide_status') {
oxideStatus.value = msg.data as { stage: string; progress: number; message: string; error?: string } 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 isInstallingOxide.value = false
} }
} }
@@ -935,7 +935,7 @@ onMounted(async () => {
<!-- Conan Exiles special concepts (Clans / Thralls / Purge) --> <!-- Conan Exiles special concepts (Clans / Thralls / Purge) -->
<Panel <Panel
v-if="profile.accent === 'conan'" v-if="activeGame === 'conan'"
title="Conan Exiles concepts" title="Conan Exiles concepts"
subtitle="Key admin mechanics for Conan Exiles servers" subtitle="Key admin mechanics for Conan Exiles servers"
> >

View File

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

View File

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

View File

@@ -1,6 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed } from 'vue'
import { useTimedExecuteStore } from '@/stores/timedexecute' import { useTimedExecuteStore } from '@/stores/timedexecute'
import { useThemeGame } from '@/composables/useThemeGame'
import { useGameProfile } from '@/config/gameProfiles'
import Panel from '@/components/ds/data/Panel.vue' import Panel from '@/components/ds/data/Panel.vue'
import Button from '@/components/ds/core/Button.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' import EmptyState from '@/components/ds/feedback/EmptyState.vue'
const store = useTimedExecuteStore() const store = useTimedExecuteStore()
const { activeGame } = useThemeGame()
const gameProfile = computed(() => useGameProfile(activeGame.value === 'all' ? 'rust' : activeGame.value))
const activeTab = ref<string>('timed') const activeTab = ref<string>('timed')
const showCreateModal = ref(false) const showCreateModal = ref(false)
@@ -360,7 +364,7 @@ const importConfigNameModel = computed<string>({
<span class="te__presets-label">Quick add:</span> <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('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('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> </div>
<EmptyState <EmptyState

View File

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

View File

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

View File

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

View File

@@ -1,13 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* EarlyAccess signup page. * EarlyAccess signup page.
* * Backend endpoint: POST /api/early-access — live and functional.
* 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.
*/ */
import { ref, onMounted, onUnmounted } from 'vue' import { ref, onMounted, onUnmounted } from 'vue'
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
@@ -130,12 +124,12 @@ onUnmounted(() => { io?.disconnect() })
<div class="icard"> <div class="icard">
<div class="icard__ic"><Icon name="message-square" :size="16" /></div> <div class="icard__ic"><Icon name="message-square" :size="16" /></div>
<b>Direct feedback channel</b> <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>
<div class="icard"> <div class="icard">
<div class="icard__ic"><Icon name="box" :size="16" /></div> <div class="icard__ic"><Icon name="box" :size="16" /></div>
<b>Rust-first</b> <b>Multi-game</b>
<p>Rust support is complete. Dune, Conan, and Soulmask are in active development.</p> <p>Rust is fully operational today. Dune: Awakening, Conan Exiles, and Soulmask support is in active development.</p>
</div> </div>
<div class="icard"> <div class="icard">
<div class="icard__ic"><Icon name="users" :size="16" /></div> <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?', question: 'Does Corrosion replace AMP or Pterodactyl?',
answer: 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?', question: 'What happens if Corrosion goes offline?',

View File

@@ -197,6 +197,14 @@ const mockActiveGame = activeGame
</span> </span>
<span class="st"><b />online</span> <span class="st"><b />online</span>
</div> </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"> <div class="mock__row">
<span class="g"><Icon name="swords" :size="13" /></span> <span class="g"><Icon name="swords" :size="13" /></span>
<span class="nm"> <span class="nm">
@@ -244,7 +252,7 @@ const mockActiveGame = activeGame
</div> </div>
<div class="pain__item"> <div class="pain__item">
<span class="pain__x"><Icon name="x" :size="14" /></span> <span class="pain__x"><Icon name="x" :size="14" /></span>
Juggling Discord bots &amp; cron tasks Juggling community bots &amp; cron tasks
</div> </div>
<div class="pain__item"> <div class="pain__item">
<span class="pain__x"><Icon name="x" :size="14" /></span> <span class="pain__x"><Icon name="x" :size="14" /></span>
@@ -442,7 +450,7 @@ const mockActiveGame = activeGame
</div> </div>
<div class="feat"> <div class="feat">
<span class="feat__ic"><Icon name="bell" :size="16" /></span> <span class="feat__ic"><Icon name="bell" :size="16" /></span>
<b>Discord / status announcements</b> <b>Webhook / status announcements</b>
</div> </div>
<div class="feat"> <div class="feat">
<span class="feat__ic"><Icon name="undo-2" :size="16" /></span> <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="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="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"><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> </div>
<p <p
class="closing reveal" class="closing reveal"
@@ -620,9 +628,9 @@ const mockActiveGame = activeGame
<div class="plan"> <div class="plan">
<div class="plan__tag" /> <div class="plan__tag" />
<div class="plan__name">Network</div> <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> <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> </div>
<div class="fleetblock reveal"> <div class="fleetblock reveal">

View File

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

View File

@@ -25,7 +25,7 @@ const groups: RoadmapGroup[] = [
status: 'shipped', status: 'shipped',
label: 'Phase 1 — Foundation', label: 'Phase 1 — Foundation',
description: 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: [ items: [
{ text: 'Host agent (Windows + Linux)', note: 'Go binary, outbound NATS, zero inbound ports' }, { 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' }, { text: 'Core control plane', note: 'NestJS API, multi-tenant PostgreSQL, JWT RBAC' },

View File

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

View File

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