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
|
# 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:
|
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
|
||||||
|
|
||||||
|
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>
|
<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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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;"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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 ?? ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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?',
|
||||||
|
|||||||
@@ -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 & cron tasks
|
Juggling community bots & 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">
|
||||||
|
|||||||
@@ -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' }"
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user