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>
This commit is contained in:
100
docs/PRICING.md
100
docs/PRICING.md
@@ -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.
|
||||
1–5 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
|
||||
|
||||
6–10 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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;"
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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 ?? ''
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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?',
|
||||
|
||||
@@ -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 & cron tasks
|
||||
Juggling community bots & 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">
|
||||
|
||||
@@ -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' }"
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user