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>
804 lines
20 KiB
Vue
804 lines
20 KiB
Vue
<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'
|
|
import Badge from '@/components/ds/core/Badge.vue'
|
|
import Button from '@/components/ds/core/Button.vue'
|
|
import Icon from '@/components/ds/core/Icon.vue'
|
|
import Alert from '@/components/ds/feedback/Alert.vue'
|
|
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
|
import Input from '@/components/ds/forms/Input.vue'
|
|
import Select from '@/components/ds/forms/Select.vue'
|
|
import 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)
|
|
const items = ref<PublicStoreItem[]>([])
|
|
const selectedCategory = ref<string>('all')
|
|
const selectedItem = ref<PublicStoreItem | null>(null)
|
|
const showPurchaseModal = ref(false)
|
|
const steamId = ref('')
|
|
const playerName = ref('')
|
|
const isPurchasing = ref(false)
|
|
const purchaseError = ref('')
|
|
const storeError = ref('')
|
|
|
|
const categories = computed(() => {
|
|
const cats = new Set(['all'])
|
|
items.value.forEach(item => {
|
|
if (item.category_name) {
|
|
cats.add(item.category_name)
|
|
}
|
|
})
|
|
return Array.from(cats).map(name => ({
|
|
value: name,
|
|
label: name === 'all' ? 'All items' : name
|
|
}))
|
|
})
|
|
|
|
const filteredItems = computed(() => {
|
|
let result = items.value
|
|
|
|
if (selectedCategory.value !== 'all') {
|
|
result = result.filter(item => item.category_name === selectedCategory.value)
|
|
}
|
|
|
|
return result
|
|
})
|
|
|
|
async function loadStore() {
|
|
isLoading.value = true
|
|
storeError.value = ''
|
|
try {
|
|
// Fetch store info
|
|
const infoResponse = await fetch(`/api/public-store/${subdomain.value}`)
|
|
if (!infoResponse.ok) {
|
|
const error = await infoResponse.json().catch(() => ({ message: 'Store not found' }))
|
|
throw new Error(error.message || 'Failed to load store')
|
|
}
|
|
storeInfo.value = await infoResponse.json()
|
|
|
|
// Fetch items
|
|
const itemsResponse = await fetch(`/api/public-store/${subdomain.value}/items`)
|
|
if (!itemsResponse.ok) {
|
|
const error = await itemsResponse.json().catch(() => ({ message: 'Failed to load items' }))
|
|
throw new Error(error.message || 'Failed to load items')
|
|
}
|
|
items.value = await itemsResponse.json()
|
|
} catch (error: any) {
|
|
storeError.value = error.message || 'Failed to load store'
|
|
storeInfo.value = null
|
|
items.value = []
|
|
} finally {
|
|
isLoading.value = false
|
|
}
|
|
}
|
|
|
|
function openPurchaseModal(item: PublicStoreItem) {
|
|
selectedItem.value = item
|
|
showPurchaseModal.value = true
|
|
steamId.value = ''
|
|
playerName.value = ''
|
|
purchaseError.value = ''
|
|
}
|
|
|
|
function closePurchaseModal() {
|
|
showPurchaseModal.value = false
|
|
selectedItem.value = null
|
|
steamId.value = ''
|
|
playerName.value = ''
|
|
purchaseError.value = ''
|
|
}
|
|
|
|
function validateSteamId(): boolean {
|
|
// Steam ID is 17 digits
|
|
const steamIdRegex = /^\d{17}$/
|
|
return steamIdRegex.test(steamId.value.trim())
|
|
}
|
|
|
|
async function confirmPurchase() {
|
|
if (!selectedItem.value) return
|
|
|
|
if (!validateSteamId()) {
|
|
purchaseError.value = 'Invalid Steam ID. Must be 17 digits.'
|
|
return
|
|
}
|
|
|
|
isPurchasing.value = true
|
|
purchaseError.value = ''
|
|
|
|
try {
|
|
const payload: StorePurchaseRequest = {
|
|
item_id: selectedItem.value.id,
|
|
steam_id: steamId.value.trim(),
|
|
player_name: playerName.value.trim() || 'Unknown Player'
|
|
}
|
|
|
|
const response = await fetch(`/api/public-store/${subdomain.value}/purchase`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
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')
|
|
}
|
|
|
|
const result: StorePurchaseResponse = await response.json()
|
|
|
|
// Open PayPal approval URL in new window
|
|
window.open(result.approval_url, '_blank', 'width=800,height=600')
|
|
|
|
// Close modal and show success message
|
|
closePurchaseModal()
|
|
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 {
|
|
isPurchasing.value = false
|
|
}
|
|
}
|
|
|
|
function formatPrice(price: number): string {
|
|
return safeCurrency(price, '$')
|
|
}
|
|
|
|
function itemTypeTone(itemType: string): 'info' | 'wiping' | 'online' | 'warn' | 'neutral' {
|
|
const tones: Record<string, 'info' | 'wiping' | 'online' | 'warn' | 'neutral'> = {
|
|
kit: 'info',
|
|
rank: 'wiping',
|
|
currency: 'online',
|
|
custom_command: 'warn',
|
|
}
|
|
return tones[itemType] ?? 'neutral'
|
|
}
|
|
|
|
onMounted(() => {
|
|
loadStore()
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="sv-page">
|
|
<!-- Sticky store header -->
|
|
<header class="sv-bar">
|
|
<div class="sv-bar__inner">
|
|
<div class="sv-bar__brand">
|
|
<Logo :size="20" :wordmark="true" />
|
|
<div class="sv-bar__divider" />
|
|
<div class="sv-bar__store">
|
|
<Icon name="shopping-cart" :size="16" />
|
|
<span class="sv-bar__name">{{ storeInfo?.store_name ?? 'Server store' }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- Store description -->
|
|
<div v-if="storeInfo?.description" class="sv-desc-wrap">
|
|
<p class="sv-desc">{{ storeInfo.description }}</p>
|
|
</div>
|
|
|
|
<div class="sv-body">
|
|
<!-- Loading -->
|
|
<div v-if="isLoading" class="sv-state">
|
|
<Icon name="loader" :size="28" class="sv-spin" />
|
|
<span class="sv-state__label">Loading store...</span>
|
|
</div>
|
|
|
|
<!-- Error -->
|
|
<div v-else-if="storeError" class="sv-error">
|
|
<EmptyState
|
|
icon="shopping-bag"
|
|
title="Store unavailable"
|
|
:description="storeError"
|
|
>
|
|
<template #action>
|
|
<Button icon="refresh-cw" variant="secondary" @click="loadStore">Retry</Button>
|
|
</template>
|
|
</EmptyState>
|
|
</div>
|
|
|
|
<!-- Store content -->
|
|
<template v-else-if="storeInfo">
|
|
<!-- Category filter -->
|
|
<div v-if="categories.length > 1" class="sv-filter">
|
|
<Icon name="list" :size="14" class="sv-filter__icon" />
|
|
<Select
|
|
v-model="selectedCategory"
|
|
size="sm"
|
|
:options="categories"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Items grid -->
|
|
<div v-if="filteredItems.length > 0" class="sv-grid">
|
|
<div
|
|
v-for="item in filteredItems"
|
|
:key="item.id"
|
|
class="sv-item"
|
|
>
|
|
<!-- Item image -->
|
|
<div class="sv-item__img-wrap">
|
|
<img
|
|
v-if="item.image_url"
|
|
:src="item.image_url"
|
|
:alt="item.name"
|
|
class="sv-item__img"
|
|
/>
|
|
<div v-else class="sv-item__img-ph">
|
|
<Icon name="package" :size="32" :stroke-width="1.5" />
|
|
</div>
|
|
<div v-if="item.category_name" class="sv-item__cat">
|
|
<Badge tone="neutral">{{ item.category_name }}</Badge>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Item details -->
|
|
<div class="sv-item__body">
|
|
<div class="sv-item__top">
|
|
<h3 class="sv-item__name">{{ item.name }}</h3>
|
|
<span class="sv-item__price">{{ formatPrice(item.price) }}</span>
|
|
</div>
|
|
<p v-if="item.description" class="sv-item__desc">{{ item.description }}</p>
|
|
|
|
<!-- Type badge + limit -->
|
|
<div class="sv-item__meta">
|
|
<Badge :tone="itemTypeTone(item.item_type)">
|
|
{{ item.item_type.replace('_', ' ') }}
|
|
</Badge>
|
|
<span v-if="item.limit_per_player" class="sv-item__limit">
|
|
Limit {{ item.limit_per_player }} per player
|
|
</span>
|
|
</div>
|
|
|
|
<Button
|
|
:block="true"
|
|
icon="shopping-cart"
|
|
@click="openPurchaseModal(item)"
|
|
>Buy now</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Empty category -->
|
|
<EmptyState
|
|
v-else
|
|
icon="package"
|
|
title="No items available"
|
|
:description="selectedCategory === 'all' ? 'This store has no items at the moment.' : 'No items in this category.'"
|
|
/>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Purchase modal -->
|
|
<div
|
|
v-if="showPurchaseModal && selectedItem"
|
|
class="sv-modal-scrim"
|
|
@click.self="closePurchaseModal"
|
|
>
|
|
<div class="sv-modal">
|
|
<!-- Modal header -->
|
|
<div class="sv-modal__head">
|
|
<div>
|
|
<div class="sv-modal__title">Complete purchase</div>
|
|
<div class="sv-modal__sub">You'll be redirected to PayPal to complete payment</div>
|
|
</div>
|
|
<button
|
|
class="sv-modal__close"
|
|
type="button"
|
|
aria-label="Close"
|
|
@click="closePurchaseModal"
|
|
>
|
|
<Icon name="x" :size="16" />
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Modal body -->
|
|
<div class="sv-modal__body">
|
|
<!-- Item preview -->
|
|
<div class="sv-modal__item">
|
|
<div class="sv-modal__thumb">
|
|
<img
|
|
v-if="selectedItem.image_url"
|
|
:src="selectedItem.image_url"
|
|
:alt="selectedItem.name"
|
|
class="sv-modal__thumb-img"
|
|
/>
|
|
<div v-else class="sv-modal__thumb-ph">
|
|
<Icon name="package" :size="20" :stroke-width="1.5" />
|
|
</div>
|
|
</div>
|
|
<div class="sv-modal__item-info">
|
|
<div class="sv-modal__item-name">{{ selectedItem.name }}</div>
|
|
<p v-if="selectedItem.description" class="sv-modal__item-desc">{{ selectedItem.description }}</p>
|
|
<div class="sv-modal__item-price">{{ formatPrice(selectedItem.price) }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Steam ID -->
|
|
<Input
|
|
v-model="steamId"
|
|
label="Steam ID"
|
|
:required="true"
|
|
:mono="true"
|
|
placeholder="76561198012345678"
|
|
:error="purchaseError && !validateSteamId() ? 'Must be your 17-digit Steam ID' : undefined"
|
|
hint="Required for item delivery."
|
|
/>
|
|
|
|
<!-- Player name -->
|
|
<Input
|
|
v-model="playerName"
|
|
label="Player name (optional)"
|
|
placeholder="Your in-game name"
|
|
/>
|
|
|
|
<!-- Purchase error -->
|
|
<Alert v-if="purchaseError" tone="danger" :title="purchaseError" />
|
|
|
|
<!-- Legal disclaimer -->
|
|
<Panel variant="raised">
|
|
<div class="sv-modal__terms">
|
|
<div class="sv-modal__term">
|
|
<Icon name="check" :size="13" class="sv-modal__term-icon" />
|
|
<span>Items delivered automatically to your character after payment</span>
|
|
</div>
|
|
<div class="sv-modal__term">
|
|
<Icon name="check" :size="13" class="sv-modal__term-icon" />
|
|
<span>All purchases are final and non-refundable</span>
|
|
</div>
|
|
<div class="sv-modal__term">
|
|
<Icon name="check" :size="13" class="sv-modal__term-icon" />
|
|
<span>You must be logged into the server to receive items</span>
|
|
</div>
|
|
</div>
|
|
</Panel>
|
|
</div>
|
|
|
|
<!-- Modal footer -->
|
|
<div class="sv-modal__foot">
|
|
<Button
|
|
variant="secondary"
|
|
:disabled="isPurchasing"
|
|
@click="closePurchaseModal"
|
|
>Cancel</Button>
|
|
<Button
|
|
icon="external-link"
|
|
:disabled="isPurchasing || !steamId.trim()"
|
|
:loading="isPurchasing"
|
|
@click="confirmPurchase"
|
|
>{{ isPurchasing ? 'Processing...' : 'Proceed to PayPal' }}</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.sv-page {
|
|
min-height: 100vh;
|
|
background: var(--surface-canvas);
|
|
}
|
|
|
|
/* Store bar */
|
|
.sv-bar {
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 20;
|
|
background: var(--surface-base);
|
|
box-shadow: 0 1px 0 var(--border-subtle);
|
|
}
|
|
|
|
.sv-bar__inner {
|
|
max-width: 1280px;
|
|
margin: 0 auto;
|
|
padding: var(--space-4) var(--space-6);
|
|
}
|
|
|
|
.sv-bar__brand {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-3);
|
|
}
|
|
|
|
.sv-bar__divider {
|
|
width: 1px;
|
|
height: 20px;
|
|
background: var(--border-default);
|
|
flex: none;
|
|
}
|
|
|
|
.sv-bar__store {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-2);
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.sv-bar__name {
|
|
font-size: var(--text-sm);
|
|
font-weight: 600;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
/* Description strip */
|
|
.sv-desc-wrap {
|
|
max-width: 1280px;
|
|
margin: 0 auto;
|
|
padding: var(--space-4) var(--space-6) 0;
|
|
}
|
|
|
|
.sv-desc {
|
|
font-size: var(--text-sm);
|
|
color: var(--text-tertiary);
|
|
line-height: 1.6;
|
|
max-width: 640px;
|
|
}
|
|
|
|
/* Body */
|
|
.sv-body {
|
|
max-width: 1280px;
|
|
margin: 0 auto;
|
|
padding: var(--space-5) var(--space-6) var(--space-8);
|
|
}
|
|
|
|
/* Loading state */
|
|
.sv-state {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: var(--space-4);
|
|
padding: var(--space-16) 0;
|
|
}
|
|
|
|
.sv-spin {
|
|
color: var(--accent);
|
|
animation: sv-rotate 0.7s linear infinite;
|
|
}
|
|
|
|
@keyframes sv-rotate {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.sv-spin { animation: none; }
|
|
}
|
|
|
|
.sv-state__label {
|
|
font-size: var(--text-sm);
|
|
color: var(--text-tertiary);
|
|
}
|
|
|
|
/* Error */
|
|
.sv-error {
|
|
background: var(--surface-base);
|
|
box-shadow: var(--ring-default);
|
|
border-radius: var(--radius-xl);
|
|
}
|
|
|
|
/* Category filter */
|
|
.sv-filter {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-2);
|
|
margin-bottom: var(--space-5);
|
|
}
|
|
|
|
.sv-filter__icon {
|
|
color: var(--text-muted);
|
|
flex: none;
|
|
}
|
|
|
|
/* Items grid */
|
|
.sv-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
|
gap: var(--space-5);
|
|
}
|
|
|
|
/* Item card */
|
|
.sv-item {
|
|
background: var(--surface-base);
|
|
box-shadow: var(--ring-default), var(--shadow-sm);
|
|
border-radius: var(--radius-xl);
|
|
overflow: hidden;
|
|
display: flex;
|
|
flex-direction: column;
|
|
transition: box-shadow var(--dur-fast) var(--ease-standard),
|
|
transform var(--dur-fast) var(--ease-standard);
|
|
}
|
|
|
|
.sv-item:hover {
|
|
box-shadow: var(--ring-strong), var(--shadow-md);
|
|
transform: translateY(-1px);
|
|
}
|
|
|
|
.sv-item__img-wrap {
|
|
position: relative;
|
|
height: 180px;
|
|
background: var(--surface-raised);
|
|
overflow: hidden;
|
|
flex: none;
|
|
}
|
|
|
|
.sv-item__img {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
display: block;
|
|
transition: transform 0.3s var(--ease-standard);
|
|
}
|
|
|
|
.sv-item:hover .sv-item__img {
|
|
transform: scale(1.04);
|
|
}
|
|
|
|
.sv-item__img-ph {
|
|
width: 100%;
|
|
height: 100%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.sv-item__cat {
|
|
position: absolute;
|
|
top: var(--space-3);
|
|
right: var(--space-3);
|
|
}
|
|
|
|
.sv-item__body {
|
|
padding: var(--space-4);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-3);
|
|
flex: 1;
|
|
}
|
|
|
|
.sv-item__top {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: space-between;
|
|
gap: var(--space-2);
|
|
}
|
|
|
|
.sv-item__name {
|
|
font-size: var(--text-base);
|
|
font-weight: 700;
|
|
color: var(--text-primary);
|
|
line-height: 1.3;
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.sv-item__price {
|
|
font-family: var(--font-mono);
|
|
font-size: var(--text-xl);
|
|
font-weight: 700;
|
|
color: var(--accent-text);
|
|
flex: none;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
.sv-item__desc {
|
|
font-size: var(--text-xs);
|
|
color: var(--text-tertiary);
|
|
line-height: 1.55;
|
|
display: -webkit-box;
|
|
-webkit-box-orient: vertical;
|
|
-webkit-line-clamp: 2;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.sv-item__meta {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-2);
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.sv-item__limit {
|
|
font-size: var(--text-xs);
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
/* Modal scrim */
|
|
.sv-modal-scrim {
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 50;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: var(--scrim);
|
|
backdrop-filter: blur(4px);
|
|
padding: var(--space-4);
|
|
}
|
|
|
|
/* Modal */
|
|
.sv-modal {
|
|
background: var(--surface-overlay);
|
|
box-shadow: var(--shadow-pop);
|
|
border-radius: var(--radius-xl);
|
|
max-width: 520px;
|
|
width: 100%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
max-height: calc(100vh - var(--space-8));
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.sv-modal__head {
|
|
padding: var(--space-5) var(--space-6);
|
|
border-bottom: 1px solid var(--border-subtle);
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: space-between;
|
|
gap: var(--space-4);
|
|
flex: none;
|
|
}
|
|
|
|
.sv-modal__title {
|
|
font-size: var(--text-xl);
|
|
font-weight: 700;
|
|
letter-spacing: -0.01em;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.sv-modal__sub {
|
|
font-size: var(--text-xs);
|
|
color: var(--text-tertiary);
|
|
margin-top: var(--space-1);
|
|
}
|
|
|
|
.sv-modal__close {
|
|
width: 30px;
|
|
height: 30px;
|
|
flex: none;
|
|
border-radius: var(--radius-sm);
|
|
border: none;
|
|
cursor: pointer;
|
|
background: transparent;
|
|
color: var(--text-secondary);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transition: background var(--dur-fast) var(--ease-standard),
|
|
color var(--dur-fast) var(--ease-standard);
|
|
}
|
|
|
|
.sv-modal__close:hover {
|
|
background: var(--surface-hover);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.sv-modal__close:focus-visible {
|
|
outline: none;
|
|
box-shadow: var(--focus-ring);
|
|
}
|
|
|
|
.sv-modal__body {
|
|
padding: var(--space-5) var(--space-6);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-4);
|
|
}
|
|
|
|
/* Item preview in modal */
|
|
.sv-modal__item {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: var(--space-4);
|
|
background: var(--surface-raised);
|
|
box-shadow: var(--ring-default);
|
|
border-radius: var(--radius-md);
|
|
padding: var(--space-3);
|
|
}
|
|
|
|
.sv-modal__thumb {
|
|
width: 76px;
|
|
height: 76px;
|
|
border-radius: var(--radius-md);
|
|
background: var(--surface-raised-2);
|
|
overflow: hidden;
|
|
flex: none;
|
|
}
|
|
|
|
.sv-modal__thumb-img {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
display: block;
|
|
}
|
|
|
|
.sv-modal__thumb-ph {
|
|
width: 100%;
|
|
height: 100%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.sv-modal__item-info {
|
|
flex: 1;
|
|
min-width: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-1);
|
|
}
|
|
|
|
.sv-modal__item-name {
|
|
font-size: var(--text-base);
|
|
font-weight: 600;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.sv-modal__item-desc {
|
|
font-size: var(--text-xs);
|
|
color: var(--text-tertiary);
|
|
line-height: 1.5;
|
|
display: -webkit-box;
|
|
-webkit-box-orient: vertical;
|
|
-webkit-line-clamp: 2;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.sv-modal__item-price {
|
|
font-family: var(--font-mono);
|
|
font-size: var(--text-2xl);
|
|
font-weight: 700;
|
|
color: var(--accent-text);
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
/* Terms list */
|
|
.sv-modal__terms {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-2);
|
|
}
|
|
|
|
.sv-modal__term {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: var(--space-2);
|
|
font-size: var(--text-xs);
|
|
color: var(--text-tertiary);
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.sv-modal__term-icon {
|
|
color: var(--accent-text);
|
|
flex: none;
|
|
margin-top: 1px;
|
|
}
|
|
|
|
/* Modal footer */
|
|
.sv-modal__foot {
|
|
padding: var(--space-4) var(--space-6);
|
|
border-top: 1px solid var(--border-subtle);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: flex-end;
|
|
gap: var(--space-3);
|
|
flex: none;
|
|
}
|
|
</style>
|