Initialer Laravel Commit für BetiX
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled

This commit is contained in:
2026-04-04 18:01:50 +02:00
commit 0280278978
374 changed files with 65210 additions and 0 deletions

View File

@@ -0,0 +1,502 @@
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3';
import { Chart, registerables } from 'chart.js';
import { onMounted, nextTick } from 'vue';
import CasinoAdminLayout from '@/layouts/admin/CasinoAdminLayout.vue';
Chart.register(...registerables);
const props = defineProps<{
stats: {
total_users: number;
total_wagered: number;
total_payout: number;
house_edge: number;
active_bans: number;
new_users_24h: number;
};
chartData: any[];
recentBets: any[];
recentUsers: any[];
}>();
onMounted(() => {
nextTick(() => {
if ((window as any).lucide) (window as any).lucide.createIcons();
const ctx = document.getElementById('profitChart') as HTMLCanvasElement;
if (ctx) {
new Chart(ctx, {
type: 'line',
data: {
labels: props.chartData.map(d => d.label),
datasets: [
{
label: 'Profit (USD)',
data: props.chartData.map(d => d.profit),
borderColor: '#ff007a',
backgroundColor: 'rgba(255, 0, 122, 0.1)',
fill: true,
tension: 0.4,
borderWidth: 3,
pointRadius: 4,
pointBackgroundColor: '#ff007a'
},
{
label: 'Wagered',
data: props.chartData.map(d => d.wagered),
borderColor: '#3b82f6',
borderDash: [5, 5],
fill: false,
tension: 0.4,
borderWidth: 2,
pointRadius: 0
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true,
labels: { color: '#a1a1aa', font: { size: 12, weight: 'bold' } }
},
tooltip: {
mode: 'index',
intersect: false,
backgroundColor: '#111113',
titleColor: '#fff',
bodyColor: '#a1a1aa',
borderColor: '#1f1f22',
borderWidth: 1
}
},
scales: {
y: {
grid: { color: '#1f1f22' },
ticks: { color: '#a1a1aa' }
},
x: {
grid: { display: false },
ticks: { color: '#a1a1aa' }
}
}
}
});
}
});
});
// Helper to format large numbers
const formatNumber = (num: number) => {
return new Intl.NumberFormat('en-US').format(num);
};
// Helper to format currency
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount || 0);
};
// Calculate trend direction (Mock logic - replace with actual DB calculation if needed)
const trends = {
users: { value: '+5.2%', isPositive: true },
wagered: { value: '+12.4%', isPositive: true },
bans: { value: '-2', isPositive: true }, // Less bans is positive
newUsers: { value: '+15', isPositive: true },
};
</script>
<template>
<CasinoAdminLayout>
<Head title="Casino Dashboard" />
<template #title>
Overview
</template>
<!-- Stats Grid -->
<div class="stats-grid">
<!-- Total Users -->
<div class="stat-card" style="animation: slideUp 0.3s ease-out;">
<div class="stat-header">
<div class="stat-label">Total Users</div>
<div class="stat-icon bg-blue-500/10 text-blue-400">
<i data-lucide="users"></i>
</div>
</div>
<div class="stat-body">
<div class="stat-value">{{ formatNumber(stats.total_users) }}</div>
<div class="stat-trend" :class="trends.users.isPositive ? 'text-green-400' : 'text-red-400'">
<i :data-lucide="trends.users.isPositive ? 'trending-up' : 'trending-down'" class="w-4 h-4 inline mr-1"></i>
{{ trends.users.value }} from last month
</div>
</div>
</div>
<!-- Total Wagered -->
<div class="stat-card" style="animation: slideUp 0.4s ease-out;">
<div class="stat-header">
<div class="stat-label">Total Wagered</div>
<div class="stat-icon bg-green-500/10 text-green-400">
<i data-lucide="dollar-sign"></i>
</div>
</div>
<div class="stat-body">
<div class="stat-value text-green-400">{{ formatCurrency(stats.total_wagered) }}</div>
<div class="stat-trend text-green-400">
<i data-lucide="trending-up" class="w-4 h-4 inline mr-1"></i>
{{ trends.wagered.value }} from last month
</div>
</div>
</div>
<!-- House Edge / GGR -->
<div class="stat-card" style="animation: slideUp 0.5s ease-out;">
<div class="stat-header">
<div class="stat-label">House GGR</div>
<div class="stat-icon bg-purple-500/10 text-purple-400">
<i data-lucide="trending-up"></i>
</div>
</div>
<div class="stat-body">
<div class="stat-value text-purple-400">{{ formatCurrency(stats.house_edge) }}</div>
<div class="stat-trend text-gray-400">
Total profit (Wager - Payout)
</div>
</div>
</div>
<!-- New Users 24h -->
<div class="stat-card" style="animation: slideUp 0.6s ease-out;">
<div class="stat-header">
<div class="stat-label">New Users (24h)</div>
<div class="stat-icon bg-blue-500/10 text-blue-400">
<i data-lucide="user-plus"></i>
</div>
</div>
<div class="stat-body">
<div class="stat-value">{{ formatNumber(stats.new_users_24h) }}</div>
<div class="stat-trend text-green-400">
<i data-lucide="arrow-up-right" class="w-4 h-4 inline mr-1"></i>
{{ trends.newUsers.value }} vs yesterday
</div>
</div>
</div>
</div>
<!-- Profit Chart Panel -->
<div class="panel chart-panel mb-6" style="animation: fadeIn 0.8s ease-out;">
<div class="panel-header">
<h3>Performance Overview (7 Days)</h3>
<div class="flex gap-4 items-center text-xs text-gray-400">
<div class="flex items-center gap-1"><span class="w-3 h-3 rounded-full bg-[#ff007a]"></span> Profit</div>
<div class="flex items-center gap-1"><span class="w-3 h-3 rounded-full bg-blue-500"></span> Wagered</div>
</div>
</div>
<div class="chart-container" style="height: 300px; padding: 20px;">
<canvas id="profitChart"></canvas>
</div>
</div>
<!-- Activity Sections -->
<div class="activity-grid">
<!-- Recent Bets Table -->
<div class="panel list-panel">
<div class="panel-header">
<h3>Recent High-Rollers</h3>
<button class="btn-ghost">View All</button>
</div>
<div class="table-wrap">
<table class="activity-table">
<thead>
<tr>
<th>Player</th>
<th>Game</th>
<th class="text-right">Wager</th>
<th class="text-right">Payout</th>
</tr>
</thead>
<tbody>
<tr v-for="bet in recentBets" :key="bet.id" class="hover-row">
<td class="font-bold flex items-center gap-2">
<div class="avatar-small">{{ bet.user?.username?.charAt(0) || '?' }}</div>
{{ bet.user?.username || 'Unknown' }}
</td>
<td class="text-gray-400">{{ bet.game_name }}</td>
<td class="text-right font-mono">{{ formatCurrency(bet.wager_amount) }}</td>
<td class="text-right font-mono font-bold" :class="bet.payout_amount > bet.wager_amount ? 'text-green-400' : 'text-gray-500'">
{{ bet.payout_amount > 0 ? '+' : '' }}{{ formatCurrency(bet.payout_amount) }}
</td>
</tr>
<tr v-if="!recentBets || recentBets.length === 0">
<td colspan="4" class="text-center py-8 text-gray-500">No bets recorded yet.</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Recent Registrations -->
<div class="panel list-panel">
<div class="panel-header">
<h3>Newest Members</h3>
<Link href="/admin/users" class="btn-ghost">Manage Users</Link>
</div>
<div class="user-list">
<div v-for="u in recentUsers" :key="u.id" class="user-item">
<div class="avatar">{{ u.username.charAt(0).toUpperCase() }}</div>
<div class="u-details">
<div class="u-name">{{ u.username }}</div>
<div class="u-email">{{ u.email }}</div>
</div>
<div class="u-time text-xs text-gray-500">
{{ new Date(u.created_at).toLocaleDateString() }}
</div>
</div>
<div v-if="!recentUsers || recentUsers.length === 0" class="text-center py-8 text-gray-500">
No recent users.
</div>
</div>
</div>
</div>
</CasinoAdminLayout>
</template>
<style scoped>
@keyframes slideUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
/* Stats Grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 20px;
margin-bottom: 24px;
}
.stat-card {
background: #111113;
border: 1px solid #1f1f22;
border-radius: 16px;
padding: 20px;
transition: transform 0.2s, box-shadow 0.2s;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 10px 30px -10px rgba(0,0,0,0.3);
border-color: #27272a;
}
.stat-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16px;
}
.stat-label {
font-size: 14px;
font-weight: 600;
color: #a1a1aa;
}
.stat-icon {
width: 40px;
height: 40px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
}
.stat-icon i {
width: 20px;
height: 20px;
}
.stat-value {
font-size: 28px;
font-weight: 800;
color: #fff;
margin-bottom: 4px;
letter-spacing: -0.5px;
}
.stat-trend {
font-size: 12px;
font-weight: 600;
display: flex;
align-items: center;
}
/* Activity Grid */
.activity-grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 20px;
}
@media (max-width: 1024px) {
.activity-grid {
grid-template-columns: 1fr;
}
}
.panel {
background: #111113;
border: 1px solid #1f1f22;
border-radius: 16px;
overflow: hidden;
}
.panel-header {
padding: 20px 24px;
border-bottom: 1px solid #1f1f22;
display: flex;
justify-content: space-between;
align-items: center;
}
.panel-header h3 {
margin: 0;
font-size: 16px;
font-weight: 700;
color: #fff;
}
.btn-ghost {
background: transparent;
border: none;
color: #3b82f6;
font-size: 13px;
font-weight: 600;
cursor: pointer;
text-decoration: none;
}
.btn-ghost:hover {
text-decoration: underline;
}
/* Table */
.table-wrap {
overflow-x: auto;
}
.activity-table {
width: 100%;
border-collapse: collapse;
text-align: left;
}
.activity-table th {
padding: 12px 24px;
font-size: 12px;
font-weight: 600;
color: #a1a1aa;
text-transform: uppercase;
background: #0c0c0e;
}
.activity-table td {
padding: 16px 24px;
font-size: 14px;
border-bottom: 1px solid #1f1f22;
color: #e4e4e7;
}
.hover-row:hover td {
background: #161618;
}
.activity-table tr:last-child td {
border-bottom: none;
}
.text-right { text-align: right; }
.text-center { text-align: center; }
/* User List */
.user-list {
display: flex;
flex-direction: column;
}
.user-item {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 24px;
border-bottom: 1px solid #1f1f22;
transition: background 0.2s;
}
.user-item:hover {
background: #161618;
}
.user-item:last-child {
border-bottom: none;
}
.avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: #27272a;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
color: #fff;
}
.avatar-small {
width: 24px;
height: 24px;
border-radius: 50%;
background: #27272a;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
color: #fff;
}
.u-details {
flex: 1;
}
.u-name {
font-size: 14px;
font-weight: 700;
color: #fff;
}
.u-email {
font-size: 12px;
color: #a1a1aa;
}
/* Utilities */
.text-green-400 { color: #4ade80; }
.text-red-400 { color: #f87171; }
.text-blue-400 { color: #60a5fa; }
.text-purple-400 { color: #c084fc; }
.text-gray-400 { color: #9ca3af; }
.text-gray-500 { color: #6b7280; }
.bg-blue-500\/10 { background-color: rgba(59, 130, 246, 0.1); }
.bg-green-500\/10 { background-color: rgba(34, 197, 94, 0.1); }
.bg-red-500\/10 { background-color: rgba(239, 68, 68, 0.1); }
.bg-purple-500\/10 { background-color: rgba(168, 85, 247, 0.1); }
.font-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
</style>

View File

@@ -0,0 +1,110 @@
<script setup lang="ts">
import { Head, router } from '@inertiajs/vue3';
import { onMounted, ref, nextTick } from 'vue';
import CasinoAdminLayout from '@/layouts/admin/CasinoAdminLayout.vue';
const props = defineProps<{ aiEnabled?: boolean }>();
const ai = ref(!!props.aiEnabled);
const toggling = ref(false);
async function toggleAi() {
toggling.value = true;
try {
await router.post('/admin/chat/toggle-ai', { enabled: ai.value }, { preserveScroll: true });
} finally {
toggling.value = false;
}
}
const messages = ref<any[]>([]);
const loading = ref(false);
async function loadMessages() {
loading.value = true;
try {
const res = await fetch('/api/chat?limit=50');
if (res.ok) {
const json = await res.json();
messages.value = json?.data || [];
}
} finally {
loading.value = false;
nextTick(() => { if ((window as any).lucide) (window as any).lucide.createIcons(); });
}
}
onMounted(() => {
loadMessages();
});
</script>
<template>
<CasinoAdminLayout>
<Head title="Admin Live Chat" />
<template #title>
Live Chat Management
</template>
<template #actions>
<label class="switch">
<input type="checkbox" v-model="ai" @change="toggleAi" :disabled="toggling" />
<span class="slider"></span>
<span class="ml-2 text-sm" :class="ai ? 'text-green' : 'text-muted'">AI {{ ai ? 'Enabled' : 'Disabled' }}</span>
</label>
</template>
<div class="panel">
<div class="panel-header">
<h3>Recent Messages</h3>
<button class="btn-ghost" @click="loadMessages" :disabled="loading">
<i data-lucide="refresh-ccw"></i> Refresh
</button>
</div>
<div class="chat-list">
<div v-for="m in messages" :key="m.id" class="chat-item">
<div class="avatar">{{ m.user?.username?.charAt(0)?.toUpperCase() || '?' }}</div>
<div class="content">
<div class="meta">
<span class="name">{{ m.user?.username || 'Unknown' }}</span>
<span class="time">{{ new Date(m.created_at).toLocaleString() }}</span>
</div>
<div class="text">{{ m.message }}</div>
</div>
<form :action="`/admin/chat/${m.id}`" method="post" class="actions" @submit.prevent="$el.submit()">
<input type="hidden" name="_method" value="DELETE" />
<button class="btn-danger" title="Delete message"><i data-lucide="trash-2"></i></button>
</form>
</div>
<div v-if="!messages.length && !loading" class="text-center py-8 text-muted">No messages yet.</div>
</div>
</div>
</CasinoAdminLayout>
</template>
<style scoped>
.panel { background: #111113; border: 1px solid #1f1f22; border-radius: 16px; }
.panel-header { display: flex; justify-content: space-between; align-items: center; padding: 16px 20px; border-bottom: 1px solid #1f1f22; }
.chat-list { display: flex; flex-direction: column; }
.chat-item { display: grid; grid-template-columns: 40px 1fr auto; gap: 12px; padding: 14px 20px; border-bottom: 1px solid #1f1f22; }
.avatar { width: 40px; height: 40px; border-radius: 50%; background: #27272a; color: #fff; display: flex; align-items: center; justify-content: center; font-weight: 800; }
.meta { display: flex; gap: 8px; align-items: baseline; }
.meta .name { font-weight: 700; color: #fff; }
.meta .time { color: #a1a1aa; font-size: 12px; }
.text { color: #e4e4e7; }
.actions { display: flex; align-items: center; gap: 8px; }
.btn-ghost { background: transparent; border: none; color: #a1a1aa; display: inline-flex; align-items: center; gap: 6px; cursor: pointer; }
.btn-ghost:hover { color: #fff; }
.btn-danger { background: transparent; border: 1px solid #7f1d1d; color: #ef4444; padding: 6px 10px; border-radius: 8px; cursor: pointer; }
.btn-danger:hover { background: rgba(239, 68, 68, .1); }
/* Switch */
.switch { display: inline-flex; align-items: center; }
.switch input { display: none; }
.switch .slider { width: 40px; height: 22px; background: #27272a; border-radius: 9999px; position: relative; transition: .2s; display: inline-block; }
.switch .slider:after { content: ''; width: 18px; height: 18px; background: #fff; border-radius: 50%; position: absolute; top: 2px; left: 2px; transition: .2s; }
.switch input:checked + .slider { background: #16a34a; }
.switch input:checked + .slider:after { transform: translateX(18px); }
.text-muted { color: #a1a1aa; }
.text-green { color: #16a34a; }
.text-white { color: #fff; }
</style>

View File

@@ -0,0 +1,761 @@
<script setup lang="ts">
import { Head, router } from '@inertiajs/vue3';
import { ref } from 'vue';
import CasinoAdminLayout from '@/layouts/admin/CasinoAdminLayout.vue';
import {
ArrowLeft, Flag, Hash, Clock, CheckCircle2, XCircle,
Ban, MessageSquareOff, AlertTriangle, History,
Calendar, Mail, Shield, Gavel, Loader2, ChevronRight,
UserRound, ShieldAlert, Star, Crown, ArrowRight
} from 'lucide-vue-next';
interface Restriction {
id: number;
type: 'chat_ban' | 'account_ban';
reason: string | null;
active: boolean;
starts_at: string | null;
ends_at: string | null;
created_at: string;
}
interface UserProfile {
id: number;
username: string;
email: string;
avatar: string | null;
avatar_url: string | null;
role: string;
vip_level: number;
is_banned: boolean;
created_at: string;
restrictions?: Restriction[];
}
interface ContextMsg {
id: string | number;
message: string;
user: { id: number; username: string };
created_at: string;
}
interface Report {
id: number;
reporter_id: number;
message_id: string;
message_text: string;
sender_id: number | null;
sender_username: string | null;
reason: string | null;
context_messages: ContextMsg[] | null;
status: 'pending' | 'reviewed' | 'dismissed';
admin_note: string | null;
created_at: string;
}
const props = defineProps<{
report: Report;
senderUser: UserProfile | null;
reporterUser: UserProfile | null;
flash?: string | null;
}>();
// ── Restriction management ────────────────────────────────────
const extendHours = ref<Record<number, number>>({});
const flashMsg = ref(props.flash || '');
function liftRestriction(id: number) {
router.post(`/admin/restrictions/${id}/lift`, {}, {
preserveScroll: true,
onSuccess: () => { flashMsg.value = 'Sperre wurde aufgehoben.'; },
});
}
function extendRestriction(id: number) {
const h = extendHours.value[id];
if (!h || h < 1) return;
router.post(`/admin/restrictions/${id}/extend`, { hours: h }, {
preserveScroll: true,
onSuccess: () => { flashMsg.value = `Sperre um ${h}h verlängert.`; extendHours.value[id] = 0; },
});
}
// ── Punishment ────────────────────────────────────────────────
const punishType = ref<'chat_ban' | 'account_ban'>('chat_ban');
const punishReason = ref('');
const punishHours = ref<number | null>(null);
const submitting = ref(false);
const chatTemplates = [
{ label: '1 Tag', hours: 24, reason: 'Spam' },
{ label: '3 Tage', hours: 72, reason: 'Beleidigung' },
{ label: '7 Tage', hours: 168, reason: 'Belästigung' },
{ label: '30 Tage', hours: 720, reason: 'Schwerer Verstoß' },
{ label: 'Permanent',hours: null, reason: 'Dauerhafter Ausschluss' },
];
const banTemplates = [
{ label: '1 Tag', hours: 24, reason: 'Betrug / Scam' },
{ label: '7 Tage', hours: 168, reason: 'Schwerer Verstoß' },
{ label: '30 Tage', hours: 720, reason: 'Wiederholte Verstöße' },
{ label: 'Permanent',hours: null, reason: 'Dauerhafter Ausschluss' },
];
function applyTemplate(reason: string, hours: number | null) {
punishReason.value = reason;
punishHours.value = hours;
}
function submitPunish() {
if (!punishReason.value || submitting.value) return;
submitting.value = true;
router.post(`/admin/reports/chat/${props.report.id}/punish`, {
type: punishType.value, reason: punishReason.value, hours: punishHours.value,
}, {
preserveScroll: true,
onSuccess: () => { flashMsg.value = 'Strafe erfolgreich verhängt!'; },
onFinish: () => { submitting.value = false; },
});
}
function updateStatus(status: string) {
router.post(`/admin/reports/chat/${props.report.id}`, { status }, { preserveScroll: true });
}
// ── Helpers ───────────────────────────────────────────────────
const statusMeta: Record<string, { color: string; bg: string; border: string; label: string }> = {
pending: { color: '#f59e0b', bg: 'rgba(245,158,11,0.12)', border: 'rgba(245,158,11,0.35)', label: 'Ausstehend' },
reviewed: { color: '#22c55e', bg: 'rgba(34,197,94,0.12)', border: 'rgba(34,197,94,0.35)', label: 'Bearbeitet' },
dismissed: { color: '#6b7280', bg: 'rgba(107,114,128,0.1)', border: 'rgba(107,114,128,0.3)', label: 'Abgelehnt' },
};
const reasonLabels: Record<string, string> = {
spam:'Spam', beleidigung:'Beleidigung', belaestigung:'Belästigung', betrug:'Betrug', sonstiges:'Sonstiges',
};
function rl(r: string | null) { return r ? (reasonLabels[r] ?? r) : null; }
function avUrl(u: UserProfile | null) { return u?.avatar_url || u?.avatar || null; }
function ini(name?: string | null) { return (name || '?')[0].toUpperCase(); }
function fmt(d: string | null) {
if (!d) return '';
return new Date(d).toLocaleString('de-DE', { day:'2-digit', month:'2-digit', year:'2-digit', hour:'2-digit', minute:'2-digit' });
}
function fmtDur(h: number | null) {
if (h === null) return 'Permanent';
if (h < 24) return `${h}h`;
return `${h/24}d`;
}
function timeLeft(d: string | null) {
if (!d) return 'Permanent';
const ms = new Date(d).getTime() - Date.now();
if (ms <= 0) return 'Abgelaufen';
const days = Math.floor(ms / 86400000);
const hrs = Math.floor((ms % 86400000) / 3600000);
return days > 0 ? `${days}T ${hrs}h` : `${hrs}h`;
}
function activeR(u: UserProfile | null) {
return (u?.restrictions ?? []).filter(r => r.active && (!r.ends_at || new Date(r.ends_at) > new Date()));
}
function allR(u: UserProfile | null) { return u?.restrictions ?? []; }
</script>
<template>
<CasinoAdminLayout>
<Head :title="`Case #${report.id}`" />
<template #title>
<div class="pt">
<a href="/admin/reports/chat" class="back"><ArrowLeft :size="14" /> Chat Reports</a>
<span class="ptdiv">/</span>
<span class="pt-case"><Hash :size="12" />{{ report.id }}</span>
<span class="status-chip" :style="{ color: statusMeta[report.status].color, background: statusMeta[report.status].bg, borderColor: statusMeta[report.status].border }">
{{ statusMeta[report.status].label }}
</span>
</div>
</template>
<!-- Flash banner -->
<transition name="fade">
<div v-if="flashMsg" class="flash">
<CheckCircle2 :size="15" />
<span>{{ flashMsg }}</span>
<button @click="flashMsg = ''"><XCircle :size="14" /></button>
</div>
</transition>
<!-- Case summary bar -->
<div class="summary-bar">
<div class="sb-item">
<span class="sb-label">Melder</span>
<span class="sb-val blue">@{{ reporterUser?.username || '' }}</span>
</div>
<ArrowRight :size="14" class="sb-arrow" />
<div class="sb-item">
<span class="sb-label">Gemeldet</span>
<span class="sb-val pink">@{{ senderUser?.username || report.sender_username || '' }}</span>
</div>
<div class="sb-sep" />
<div class="sb-item" v-if="rl(report.reason)">
<span class="sb-label">Grund</span>
<span class="sb-val"><Flag :size="11" /> {{ rl(report.reason) }}</span>
</div>
<div class="sb-item">
<span class="sb-label">Datum</span>
<span class="sb-val">{{ fmt(report.created_at) }}</span>
</div>
<div class="sb-item">
<span class="sb-label">Msg-ID</span>
<span class="sb-val mono">#{{ report.message_id }}</span>
</div>
<!-- Quick status -->
<div class="sb-status-btns">
<button :class="['ssb', { active: report.status === 'reviewed' }]" @click="updateStatus('reviewed')">
<CheckCircle2 :size="12" /> Erledigt
</button>
<button :class="['ssb dismiss', { active: report.status === 'dismissed' }]" @click="updateStatus('dismissed')">
<XCircle :size="12" /> Ablehnen
</button>
</div>
</div>
<!-- Main grid -->
<div class="main-grid">
<!-- LEFT col -->
<div class="col-left">
<!-- Reporter -->
<div class="user-card blue-top">
<div class="uc-label blue"><UserRound :size="10" /> Melder</div>
<div class="uc-row">
<div class="uc-av blue">
<img v-if="avUrl(reporterUser)" :src="avUrl(reporterUser)!" />
<span v-else>{{ ini(reporterUser?.username) }}</span>
</div>
<div class="uc-info">
<div class="uc-name">@{{ reporterUser?.username || '' }}</div>
<div class="uc-email"><Mail :size="9" /> {{ reporterUser?.email || '' }}</div>
<div class="uc-since"><Calendar :size="9" /> seit {{ reporterUser?.created_at ? new Date(reporterUser.created_at).toLocaleDateString('de-DE') : '' }}</div>
</div>
<a v-if="reporterUser" :href="`/admin/users/${reporterUser.id}`" class="uc-open" title="Profil öffnen"><ChevronRight :size="14" /></a>
</div>
<div class="uc-badges">
<span class="badge role"><Crown :size="8" /> {{ reporterUser?.role || 'user' }}</span>
<span v-if="reporterUser?.vip_level && reporterUser.vip_level > 0" class="badge vip"><Star :size="8" /> VIP {{ reporterUser.vip_level }}</span>
<span v-if="reporterUser?.is_banned" class="badge banned"><Ban :size="8" /> Gebannt</span>
<span v-if="activeR(reporterUser).some(r => r.type==='chat_ban')" class="badge cbanned"><MessageSquareOff :size="8" /> Chat-Bann</span>
</div>
<!-- Mini restriction history -->
<div v-if="allR(reporterUser).length" class="mini-hist">
<div class="mh-title"><History :size="9" /> Historie <span class="mh-count">{{ allR(reporterUser).length }}</span></div>
<div v-for="r in allR(reporterUser).slice(0,3)" :key="r.id" class="mh-row" :class="{ active: r.active }">
<span class="mh-type" :class="r.type==='chat_ban'?'chat':'ban'">{{ r.type==='chat_ban'?'Chat':'Acc' }}</span>
<span class="mh-reason">{{ r.reason || '' }}</span>
<span v-if="r.active" class="mh-live"></span>
</div>
</div>
</div>
<!-- Divider -->
<div class="reported-divider"><Flag :size="11" class="df" /> hat gemeldet</div>
<!-- Reported / Sender -->
<div class="user-card pink-top">
<div class="uc-label pink"><ShieldAlert :size="10" /> Gemeldet</div>
<div class="uc-row">
<div class="uc-av pink">
<img v-if="avUrl(senderUser)" :src="avUrl(senderUser)!" />
<span v-else>{{ ini(senderUser?.username ?? report.sender_username) }}</span>
</div>
<div class="uc-info">
<div class="uc-name">@{{ senderUser?.username || report.sender_username || '' }}</div>
<div class="uc-email"><Mail :size="9" /> {{ senderUser?.email || `ID: ${report.sender_id || ''}` }}</div>
<div v-if="senderUser" class="uc-since"><Calendar :size="9" /> seit {{ new Date(senderUser.created_at).toLocaleDateString('de-DE') }}</div>
</div>
<a v-if="senderUser" :href="`/admin/users/${senderUser.id}`" class="uc-open" title="Profil öffnen"><ChevronRight :size="14" /></a>
</div>
<div class="uc-badges">
<span v-if="senderUser" class="badge role"><Crown :size="8" /> {{ senderUser.role }}</span>
<span v-if="senderUser?.vip_level && senderUser.vip_level > 0" class="badge vip"><Star :size="8" /> VIP {{ senderUser.vip_level }}</span>
<span v-if="senderUser?.is_banned" class="badge banned"><Ban :size="8" /> Gebannt</span>
<span v-if="activeR(senderUser).some(r => r.type==='chat_ban')" class="badge cbanned"><MessageSquareOff :size="8" /> Chat-Bann</span>
</div>
<!-- Active restrictions alert -->
<div v-if="activeR(senderUser).length" class="active-alert">
<AlertTriangle :size="13" />
<div>
<div class="aa-title">{{ activeR(senderUser).length }} aktive Sperre(n)</div>
<div class="aa-list">
<div v-for="r in activeR(senderUser)" :key="r.id" class="aa-row">
<span class="mh-type" :class="r.type==='chat_ban'?'chat':'ban'">{{ r.type==='chat_ban'?'Chat-Bann':'Acc-Bann' }}</span>
<span class="aa-until">{{ r.ends_at ? timeLeft(r.ends_at) : 'Permanent' }}</span>
</div>
</div>
</div>
</div>
<!-- Full restriction history with lift/extend -->
<div v-if="allR(senderUser).length" class="restrict-hist">
<div class="mh-title"><History :size="9" /> Sperr-Historie <span class="mh-count">{{ allR(senderUser).length }}</span></div>
<div v-for="r in allR(senderUser)" :key="r.id" class="rh-item" :class="{ active: r.active }">
<div class="rh-row">
<span class="mh-type" :class="r.type==='chat_ban'?'chat':'ban'">{{ r.type==='chat_ban'?'Chat':'Acc' }}</span>
<span class="rh-reason">{{ r.reason || '' }}</span>
<span v-if="r.active" class="mh-live">AKTIV</span>
<span class="rh-date">{{ fmt(r.created_at) }}</span>
</div>
<div v-if="r.ends_at || r.active" class="rh-until">
<Clock :size="9" />
{{ r.ends_at ? fmt(r.ends_at) : 'Permanent' }}
<span v-if="r.active && r.ends_at" class="rh-left">{{ timeLeft(r.ends_at) }}</span>
</div>
<div v-if="r.active" class="rh-actions">
<button class="rha lift" @click="liftRestriction(r.id)">
<CheckCircle2 :size="10" /> Aufheben
</button>
<div class="rha-extend">
<input v-model.number="extendHours[r.id]" type="number" min="1" placeholder="Std." class="rha-input" />
<button class="rha extend" @click="extendRestriction(r.id)">
<Clock :size="10" /> Verlängern
</button>
</div>
</div>
</div>
</div>
<div v-else class="no-hist">Keine Sperr-Historie</div>
</div>
</div>
<!-- CENTER: Chat timeline -->
<div class="col-center">
<div class="ct-head">
<MessageSquareOff :size="15" class="ct-icon" />
<span>Chat-Kontext</span>
<span class="ct-sub">{{ (report.context_messages?.length ?? 0) + 1 }} Nachrichten</span>
</div>
<div class="timeline">
<div v-if="!report.context_messages?.length && !report.message_text" class="tl-empty">
Kein Kontext gespeichert.
</div>
<!-- Context messages -->
<div
v-for="cm in report.context_messages"
:key="cm.id"
class="tl-msg"
:class="{
'by-sender': cm.user?.id === report.sender_id,
'by-reporter': cm.user?.id === report.reporter_id,
}"
>
<div class="tl-av" :class="{ 'av-s': cm.user?.id === report.sender_id, 'av-r': cm.user?.id === report.reporter_id }">
{{ ini(cm.user?.username) }}
</div>
<div class="tl-body">
<div class="tl-meta">
<span class="tl-user">@{{ cm.user?.username || '?' }}</span>
<span class="tl-ts">{{ fmt(cm.created_at) }}</span>
</div>
<div class="tl-text">{{ cm.message }}</div>
</div>
</div>
<!-- Reported message -->
<div class="tl-msg reported">
<div class="rep-flag"><Flag :size="10" /></div>
<div class="tl-av av-s av-rep">{{ ini(report.sender_username) }}</div>
<div class="tl-body">
<div class="tl-meta">
<span class="tl-user rep-user">@{{ report.sender_username || '?' }}</span>
<span class="rep-badge"><Flag :size="9" /> Gemeldet</span>
<span v-if="rl(report.reason)" class="reason-badge">{{ rl(report.reason) }}</span>
<span class="tl-ts">{{ fmt(report.created_at) }}</span>
</div>
<div class="tl-text rep-text">{{ report.message_text }}</div>
</div>
</div>
</div>
</div>
<!-- RIGHT: Punishment -->
<div class="col-right">
<div class="pun-head">
<Gavel :size="15" class="pun-icon" />
<span>Strafe verhängen</span>
<span class="ct-sub" v-if="senderUser">@{{ senderUser.username }}</span>
<span class="ct-sub warn" v-else>Kein Nutzer verknüpft</span>
</div>
<div class="pun-form" :class="{ locked: !senderUser }">
<!-- Type toggle -->
<div class="type-row">
<button :class="['type-btn', { on: punishType==='chat_ban' }]" @click="punishType='chat_ban'">
<MessageSquareOff :size="12" /> Chat-Bann
</button>
<button :class="['type-btn ban', { on: punishType==='account_ban' }]" @click="punishType='account_ban'">
<Ban :size="12" /> Account-Bann
</button>
</div>
<!-- Templates -->
<div class="tpl-block">
<div class="tpl-label">Schnell-Vorlagen</div>
<div class="tpl-list">
<button
v-for="t in (punishType==='chat_ban' ? chatTemplates : banTemplates)"
:key="t.label"
class="tpl-btn"
:class="{ on: punishReason===t.reason && punishHours===t.hours, ban: punishType==='account_ban' }"
@click="applyTemplate(t.reason, t.hours)"
>
<span class="tpl-name">{{ t.label }}</span>
<span class="tpl-dur">{{ fmtDur(t.hours) }}</span>
<span class="tpl-reason">{{ t.reason }}</span>
</button>
</div>
</div>
<div class="or-line"><span>oder anpassen</span></div>
<!-- Custom -->
<div class="custom-block">
<label class="fl">
<span><Clock :size="10" /> Dauer (Stunden)</span>
<input v-model.number="punishHours" type="number" min="1" placeholder="leer = permanent" class="fi" />
</label>
<label class="fl">
<span><Shield :size="10" /> Grund</span>
<textarea v-model="punishReason" rows="2" placeholder="Begründung..." class="fi ta"></textarea>
</label>
<!-- Preview -->
<div v-if="punishReason" class="pun-preview">
<span class="pp-type" :class="punishType==='account_ban'?'ban':'chat'">
{{ punishType==='chat_ban' ? 'Chat-Bann' : 'Account-Bann' }}
</span>
<span class="pp-dur">{{ fmtDur(punishHours) }}</span>
<span v-if="punishHours" class="pp-until">
bis {{ new Date(Date.now() + (punishHours??0)*3600000).toLocaleDateString('de-DE') }}
</span>
</div>
<button
class="pun-btn"
:class="{ ban: punishType==='account_ban' }"
:disabled="!punishReason || !senderUser || submitting"
@click="submitPunish"
>
<Loader2 v-if="submitting" :size="13" class="spin" />
<Ban v-else-if="punishType==='account_ban'" :size="13" />
<MessageSquareOff v-else :size="13" />
{{ punishType==='chat_ban' ? 'Chat-Bann verhängen' : 'Account sperren' }}
</button>
</div>
</div>
</div>
</div><!-- /main-grid -->
</CasinoAdminLayout>
</template>
<style scoped>
/* ─ Page title ─ */
.pt { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
.back { display:flex; align-items:center; gap:4px; color:#555; font-size:12px; text-decoration:none; }
.back:hover { color:#ccc; }
.ptdiv { color:#2a2a2a; }
.pt-case { display:flex; align-items:center; gap:3px; font-weight:800; color:#fff; font-size:14px; }
.status-chip {
font-size:10px; font-weight:800; padding:3px 10px; border-radius:20px;
border:1px solid; text-transform:uppercase; letter-spacing:.5px;
}
/* ─ Flash ─ */
.flash {
display:flex; align-items:center; gap:8px; margin-bottom:16px;
background:rgba(34,197,94,0.1); border:1px solid rgba(34,197,94,0.3);
color:#22c55e; padding:10px 14px; border-radius:10px; font-size:13px; font-weight:600;
}
.flash button { margin-left:auto; background:none; border:none; color:inherit; cursor:pointer; display:flex; }
.fade-enter-active,.fade-leave-active { transition:opacity .3s; }
.fade-enter-from,.fade-leave-to { opacity:0; }
/* ─ Summary bar ─ */
.summary-bar {
display:flex; align-items:center; gap:0; flex-wrap:wrap;
background:#111113; border:1px solid #1e1e21; border-radius:12px;
padding:12px 18px; margin-bottom:18px; gap:16px;
}
.sb-item { display:flex; flex-direction:column; gap:2px; }
.sb-label { font-size:9px; font-weight:700; text-transform:uppercase; letter-spacing:.6px; color:#444; }
.sb-val { font-size:12px; font-weight:700; color:#ccc; display:flex; align-items:center; gap:4px; }
.sb-val.blue { color:#60a5fa; }
.sb-val.pink { color:#f472b6; }
.sb-val.mono { font-family:monospace; color:#666; font-size:11px; }
.sb-arrow { color:#333; flex-shrink:0; }
.sb-sep { width:1px; height:28px; background:#1e1e21; }
.sb-status-btns { margin-left:auto; display:flex; gap:6px; }
.ssb {
display:flex; align-items:center; gap:5px; padding:6px 12px;
border-radius:7px; border:1px solid #252528; background:#161618;
color:#555; font-size:11px; font-weight:700; cursor:pointer; transition:.15s;
}
.ssb:hover { border-color:#3a3a3f; color:#aaa; }
.ssb.active,.ssb:hover.active { border-color:rgba(34,197,94,0.4); background:rgba(34,197,94,0.08); color:#22c55e; }
.ssb.dismiss.active { border-color:rgba(107,114,128,0.4); background:rgba(107,114,128,0.08); color:#6b7280; }
/* ─ Main grid ─ */
.main-grid {
display:grid;
grid-template-columns: 270px 1fr 290px;
gap:16px;
align-items:start;
}
@media(max-width:1300px){ .main-grid { grid-template-columns: 250px 1fr 270px; } }
@media(max-width:1000px){ .main-grid { grid-template-columns:1fr; } }
/* ─ User cards ─ */
.col-left { display:flex; flex-direction:column; gap:8px; }
.user-card {
background:#111113; border:1px solid #1e1e21; border-radius:12px; padding:14px;
display:flex; flex-direction:column; gap:9px; position:relative;
}
.blue-top { border-top:2px solid #3b82f6; }
.pink-top { border-top:2px solid #df006a; }
.uc-label {
font-size:9px; font-weight:800; text-transform:uppercase; letter-spacing:.6px;
display:flex; align-items:center; gap:3px; padding:2px 8px; border-radius:20px;
width:fit-content; border:1px solid;
}
.uc-label.blue { color:#3b82f6; background:rgba(59,130,246,.08); border-color:rgba(59,130,246,.2); }
.uc-label.pink { color:#df006a; background:rgba(223,0,106,.08); border-color:rgba(223,0,106,.2); }
.uc-row { display:flex; align-items:center; gap:10px; }
.uc-av {
width:40px; height:40px; border-radius:10px; flex-shrink:0;
display:flex; align-items:center; justify-content:center;
font-size:16px; font-weight:900; overflow:hidden;
}
.uc-av img { width:100%; height:100%; object-fit:cover; }
.uc-av.blue { background:rgba(59,130,246,.15); color:#3b82f6; }
.uc-av.pink { background:rgba(223,0,106,.15); color:#df006a; }
.uc-info { flex:1; min-width:0; }
.uc-name { font-size:13px; font-weight:800; color:#e0e0e0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.uc-email, .uc-since { display:flex; align-items:center; gap:4px; font-size:10px; color:#555; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.uc-open {
color:#666; background:#161618; border:1px solid #252528; border-radius:7px;
padding:5px; display:flex; cursor:pointer; text-decoration:none; transition:.15s; flex-shrink:0;
}
.uc-open:hover { color:#ccc; border-color:#3a3a3f; }
.uc-badges { display:flex; gap:5px; flex-wrap:wrap; }
.badge {
display:flex; align-items:center; gap:3px;
font-size:9px; font-weight:800; padding:2px 6px; border-radius:5px; border:1px solid;
text-transform:uppercase;
}
.badge.role { color:#555; border-color:#252528; background:#161618; }
.badge.vip { color:#fcd34d; border-color:rgba(252,211,77,.25); background:rgba(252,211,77,.06); }
.badge.banned { color:#ef4444; border-color:rgba(239,68,68,.3); background:rgba(239,68,68,.08); }
.badge.cbanned { color:#f97316; border-color:rgba(249,115,22,.3); background:rgba(249,115,22,.08); }
/* Mini history (reporter) */
.mini-hist { border-top:1px solid #1a1a1c; padding-top:8px; }
.mh-title { display:flex; align-items:center; gap:4px; font-size:9px; font-weight:700; text-transform:uppercase; color:#444; letter-spacing:.5px; margin-bottom:5px; }
.mh-count { margin-left:auto; background:#1a1a1c; border:1px solid #252528; border-radius:8px; padding:0 5px; color:#666; font-size:9px; }
.mh-row { display:flex; align-items:center; gap:6px; padding:4px 7px; border-radius:5px; background:#0e0e10; border:1px solid #1a1a1c; margin-bottom:3px; font-size:10px; }
.mh-row.active { border-color:rgba(239,68,68,.2); background:rgba(239,68,68,.04); }
.mh-type { font-size:9px; font-weight:800; padding:1px 5px; border-radius:3px; }
.mh-type.chat { color:#f97316; background:rgba(249,115,22,.1); }
.mh-type.ban { color:#ef4444; background:rgba(239,68,68,.1); }
.mh-reason { flex:1; color:#666; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.mh-live { color:#ef4444; font-size:12px; }
/* Active alert */
.active-alert {
display:flex; align-items:flex-start; gap:8px;
background:rgba(239,68,68,.06); border:1px solid rgba(239,68,68,.2);
border-radius:8px; padding:8px 10px; color:#ef4444; font-size:12px; font-weight:600;
}
.aa-title { font-size:11px; font-weight:700; color:#ef4444; margin-bottom:4px; }
.aa-list { display:flex; flex-direction:column; gap:3px; }
.aa-row { display:flex; align-items:center; justify-content:space-between; gap:8px; }
.aa-until { font-size:10px; color:#888; font-weight:500; }
/* Full restriction history (sender) */
.restrict-hist { border-top:1px solid #1a1a1c; padding-top:8px; }
.rh-item {
border:1px solid #1a1a1c; border-radius:8px; padding:7px 9px;
margin-bottom:5px; display:flex; flex-direction:column; gap:4px;
background:#0e0e10;
}
.rh-item.active { border-color:rgba(239,68,68,.25); background:rgba(239,68,68,.03); }
.rh-row { display:flex; align-items:center; gap:6px; font-size:11px; }
.rh-reason { flex:1; color:#777; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.rh-date { color:#444; font-size:10px; white-space:nowrap; }
.rh-until { display:flex; align-items:center; gap:4px; font-size:10px; color:#555; }
.rh-left { color:#f97316; font-weight:700; margin-left:4px; }
.rh-actions { display:flex; align-items:center; gap:6px; flex-wrap:wrap; }
.rha {
display:flex; align-items:center; gap:3px; font-size:10px; font-weight:700;
padding:3px 9px; border-radius:5px; border:1px solid; cursor:pointer; transition:.15s;
}
.rha.lift { color:#22c55e; border-color:rgba(34,197,94,.3); background:rgba(34,197,94,.07); }
.rha.lift:hover { background:rgba(34,197,94,.14); }
.rha.extend { color:#f59e0b; border-color:rgba(245,158,11,.3); background:rgba(245,158,11,.07); }
.rha.extend:hover { background:rgba(245,158,11,.14); }
.rha-extend { display:flex; align-items:center; gap:4px; }
.rha-input {
width:56px; padding:3px 7px; background:#0d0d0f;
border:1px solid #252528; color:#ccc; border-radius:5px; font-size:10px;
}
.no-hist { font-size:11px; color:#333; font-style:italic; border-top:1px solid #1a1a1c; padding-top:8px; }
/* Divider */
.reported-divider {
display:flex; align-items:center; justify-content:center; gap:6px;
color:#3a3a3f; font-size:11px; font-weight:700;
}
.df { color:#df006a; }
/* ─ Chat timeline ─ */
.col-center { min-width:0; }
.ct-head {
display:flex; align-items:center; gap:7px; margin-bottom:12px;
font-size:13px; font-weight:800; color:#e0e0e0;
}
.ct-icon { color:#df006a; }
.ct-sub { font-size:11px; font-weight:400; color:#555; margin-left:2px; }
.ct-sub.warn { color:#ef4444; }
.timeline {
background:#0c0c0e; border:1px solid #1a1a1c; border-radius:12px; overflow:hidden;
}
.tl-empty { padding:40px; text-align:center; color:#3a3a3f; font-size:13px; }
.tl-msg {
display:flex; align-items:flex-start; gap:10px;
padding:10px 14px; border-bottom:1px solid #111113; transition:background .1s;
}
.tl-msg:last-child { border-bottom:none; }
.tl-msg:hover { background:rgba(255,255,255,.015); }
.tl-msg.by-sender { background:rgba(223,0,106,.03); }
.tl-msg.by-reporter { background:rgba(59,130,246,.03); }
.tl-av {
width:28px; height:28px; border-radius:7px; flex-shrink:0;
display:flex; align-items:center; justify-content:center;
font-size:11px; font-weight:800; background:#1a1a1c; color:#555;
}
.tl-av.av-s { background:rgba(223,0,106,.15); color:#df006a; }
.tl-av.av-r { background:rgba(59,130,246,.15); color:#3b82f6; }
.tl-av.av-rep { box-shadow:0 0 0 2px rgba(223,0,106,.45); }
.tl-body { flex:1; min-width:0; }
.tl-meta { display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin-bottom:2px; }
.tl-user { font-size:11px; font-weight:700; color:#888; }
.tl-ts { font-size:10px; color:#3a3a3f; margin-left:auto; }
.tl-text { font-size:13px; color:#bbb; line-height:1.45; word-break:break-word; }
/* Reported msg */
.tl-msg.reported {
background:rgba(223,0,106,.06) !important;
border-left:3px solid #df006a;
padding-left:11px;
position:relative;
}
.rep-flag {
position:absolute; left:-1px; top:8px;
width:18px; height:18px; background:#df006a; color:#fff;
border-radius:50%; display:flex; align-items:center; justify-content:center;
}
.rep-user { color:#f472b6 !important; }
.rep-badge {
display:flex; align-items:center; gap:3px;
font-size:9px; font-weight:800; color:#df006a;
background:rgba(223,0,106,.1); border:1px solid rgba(223,0,106,.25);
padding:1px 6px; border-radius:4px; text-transform:uppercase;
}
.reason-badge {
font-size:9px; font-weight:700; color:#f59e0b;
background:rgba(245,158,11,.1); border:1px solid rgba(245,158,11,.25);
padding:1px 6px; border-radius:4px;
}
.rep-text { color:#fff !important; font-weight:500; }
/* ─ Punishment panel ─ */
.col-right { min-width:0; }
.pun-head {
display:flex; align-items:center; gap:7px; margin-bottom:14px;
font-size:13px; font-weight:800; color:#e0e0e0;
}
.pun-icon { color:#f59e0b; }
.pun-form { display:flex; flex-direction:column; gap:14px; }
.pun-form.locked { opacity:.45; pointer-events:none; }
.type-row { display:flex; gap:8px; }
.type-btn {
flex:1; display:flex; align-items:center; justify-content:center; gap:5px;
font-size:12px; font-weight:700; padding:9px; border-radius:9px;
border:1px solid #252528; background:#111113; color:#555; cursor:pointer; transition:.15s;
}
.type-btn.on { border-color:rgba(249,115,22,.4); background:rgba(249,115,22,.1); color:#f97316; }
.type-btn.ban.on { border-color:rgba(239,68,68,.4); background:rgba(239,68,68,.1); color:#ef4444; }
.tpl-block { display:flex; flex-direction:column; gap:6px; }
.tpl-label { font-size:10px; font-weight:700; text-transform:uppercase; color:#444; letter-spacing:.5px; }
.tpl-list { display:flex; flex-direction:column; gap:4px; }
.tpl-btn {
display:grid; grid-template-columns:auto 1fr auto;
align-items:center; gap:6px; padding:8px 11px;
border-radius:8px; border:1px solid #1a1a1c; background:#0e0e10;
cursor:pointer; transition:.15s; text-align:left;
}
.tpl-btn:hover { border-color:#252528; background:#111113; }
.tpl-btn.on { border-color:rgba(249,115,22,.35); background:rgba(249,115,22,.07); }
.tpl-btn.ban.on { border-color:rgba(239,68,68,.35); background:rgba(239,68,68,.07); }
.tpl-name { font-size:12px; font-weight:700; color:#ccc; }
.tpl-dur { font-size:10px; font-weight:700; color:#666; background:#161618; border:1px solid #252528; padding:1px 7px; border-radius:5px; justify-self:end; }
.tpl-reason { grid-column:1/-1; font-size:10px; color:#555; }
.or-line {
text-align:center; color:#2a2a2a; font-size:10px; font-weight:600;
position:relative;
}
.or-line span { position:relative; z-index:1; background:#0f0f11; padding:0 10px; }
.or-line::before { content:''; position:absolute; top:50%; left:0; right:0; height:1px; background:#1a1a1c; }
.custom-block { display:flex; flex-direction:column; gap:10px; }
.fl { display:flex; flex-direction:column; gap:5px; font-size:11px; font-weight:600; color:#555; }
.fl span { display:flex; align-items:center; gap:4px; }
.fi {
background:#0c0c0e; border:1px solid #1e1e21; color:#ccc;
padding:8px 11px; border-radius:8px; font-size:13px; width:100%; box-sizing:border-box;
}
.fi:focus { outline:none; border-color:rgba(223,0,106,.3); }
.ta { resize:none; }
.pun-preview {
display:flex; align-items:center; gap:8px; flex-wrap:wrap;
background:#0c0c0e; border:1px solid #1e1e21; border-radius:8px; padding:8px 11px; font-size:11px;
}
.pp-type { font-weight:800; padding:2px 8px; border-radius:5px; }
.pp-type.chat { color:#f97316; background:rgba(249,115,22,.1); }
.pp-type.ban { color:#ef4444; background:rgba(239,68,68,.1); }
.pp-dur { font-weight:700; color:#888; }
.pp-until { color:#555; font-size:10px; margin-left:auto; }
.pun-btn {
display:flex; align-items:center; justify-content:center; gap:7px;
width:100%; padding:11px; border-radius:10px; border:none; cursor:pointer;
font-size:13px; font-weight:800; color:#fff; transition:.2s;
background:linear-gradient(135deg,#f97316,#ea580c);
}
.pun-btn.ban { background:linear-gradient(135deg,#ef4444,#dc2626); }
.pun-btn:hover:not(:disabled) { filter:brightness(1.1); }
.pun-btn:disabled { opacity:.4; cursor:not-allowed; }
.spin { animation:spin .8s linear infinite; }
@keyframes spin { to { transform:rotate(360deg); } }
</style>

View File

@@ -0,0 +1,354 @@
<script setup lang="ts">
import { Head, router } from '@inertiajs/vue3';
import { ref, computed } from 'vue';
import CasinoAdminLayout from '@/layouts/admin/CasinoAdminLayout.vue';
import {
Search, Flag, Clock, CheckCircle2, XCircle, ChevronRight,
MessageSquareText, Eye, EyeOff
} from 'lucide-vue-next';
interface Reporter {
id: number;
username: string;
email: string;
avatar: string | null;
avatar_url: string | null;
}
interface Report {
id: number;
reporter: Reporter;
message_id: string;
message_text: string;
sender_id: number | null;
sender_username: string | null;
reason: string | null;
status: 'pending' | 'reviewed' | 'dismissed';
admin_note: string | null;
created_at: string;
}
const props = defineProps<{
reports: { data: Report[]; links: any[]; meta: any };
filters: { status?: string; search?: string };
stats: { total: number; pending: number; reviewed: number; dismissed: number };
}>();
const filterStatus = ref(props.filters.status ?? 'pending');
const searchInput = ref(props.filters.search ?? '');
let searchTimer: ReturnType<typeof setTimeout> | null = null;
function applyFilter() {
router.get('/admin/reports/chat', {
status: filterStatus.value || undefined,
search: searchInput.value || undefined,
}, { preserveScroll: true });
}
function onSearchInput() {
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(applyFilter, 400);
}
function setStatus(s: string) {
filterStatus.value = s;
applyFilter();
}
function openCase(id: number) {
router.visit(`/admin/reports/chat/${id}`);
}
function fmt(d: string) {
return new Date(d).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' });
}
const reasonLabels: Record<string, string> = {
spam: 'Spam', beleidigung: 'Beleidigung', belaestigung: 'Belästigung',
betrug: 'Betrug', sonstiges: 'Sonstiges', harassment: 'Belästigung',
offensive: 'Beleidigung', scam: 'Betrug', other: 'Sonstiges',
};
function rl(r: string | null) { return r ? (reasonLabels[r] ?? r) : ''; }
const statusMeta: Record<string, { color: string; bg: string; border: string; label: string }> = {
pending: { color: '#f59e0b', bg: 'rgba(245,158,11,0.12)', border: 'rgba(245,158,11,0.3)', label: 'Pending' },
reviewed: { color: '#22c55e', bg: 'rgba(34,197,94,0.12)', border: 'rgba(34,197,94,0.3)', label: 'Reviewed' },
dismissed: { color: '#6b7280', bg: 'rgba(107,114,128,0.1)', border: 'rgba(107,114,128,0.25)', label: 'Dismissed' },
};
const showingLabel = computed(() => {
if (!filterStatus.value || filterStatus.value === 'all') return 'Alle';
return statusMeta[filterStatus.value]?.label ?? filterStatus.value;
});
</script>
<template>
<CasinoAdminLayout>
<Head title="Admin Chat Reports" />
<template #title>Chat Reports</template>
<!-- Stats row -->
<div class="stats-row">
<button class="stat-card" :class="{ active: !filterStatus || filterStatus === 'all' }" @click="setStatus('all')">
<MessageSquareText :size="16" class="sc-icon all" />
<div class="sc-body">
<span class="sc-val">{{ stats.total }}</span>
<span class="sc-label">Gesamt</span>
</div>
</button>
<button class="stat-card" :class="{ active: filterStatus === 'pending' }" @click="setStatus('pending')">
<Clock :size="16" class="sc-icon pending" />
<div class="sc-body">
<span class="sc-val pending">{{ stats.pending }}</span>
<span class="sc-label">Offen</span>
</div>
<span v-if="stats.pending > 0" class="sc-dot"></span>
</button>
<button class="stat-card" :class="{ active: filterStatus === 'reviewed' }" @click="setStatus('reviewed')">
<CheckCircle2 :size="16" class="sc-icon reviewed" />
<div class="sc-body">
<span class="sc-val reviewed">{{ stats.reviewed }}</span>
<span class="sc-label">Erledigt</span>
</div>
</button>
<button class="stat-card" :class="{ active: filterStatus === 'dismissed' }" @click="setStatus('dismissed')">
<XCircle :size="16" class="sc-icon dismissed" />
<div class="sc-body">
<span class="sc-val dismissed">{{ stats.dismissed }}</span>
<span class="sc-label">Abgelehnt</span>
</div>
</button>
</div>
<!-- Toolbar -->
<div class="toolbar">
<div class="search-wrap">
<Search :size="14" class="search-icon" />
<input
v-model="searchInput"
@input="onSearchInput"
type="text"
placeholder="Case-Nr. oder Username suchen…"
class="search-input"
/>
</div>
<div class="showing-pill">
<Eye :size="12" />
{{ showingLabel }}
<span class="showing-count">{{ reports.meta?.total ?? reports.data.length }}</span>
</div>
</div>
<!-- Table -->
<div class="table-wrap">
<div v-if="!reports.data.length" class="empty-state">
<Flag :size="28" class="empty-icon" />
<div class="empty-text">Keine Reports gefunden</div>
<div class="empty-sub" v-if="searchInput">Für „{{ searchInput }}" wurden keine Ergebnisse gefunden.</div>
</div>
<table v-else class="reports-table">
<thead>
<tr>
<th style="width:70px">Case</th>
<th style="width:150px">Von</th>
<th style="width:150px">Gemeldet</th>
<th style="width:110px">Grund</th>
<th>Nachricht</th>
<th style="width:105px">Status</th>
<th style="width:120px">Datum</th>
<th style="width:28px"></th>
</tr>
</thead>
<tbody>
<tr
v-for="r in reports.data"
:key="r.id"
class="report-row"
:class="`status-${r.status}`"
@click="openCase(r.id)"
>
<td class="td-case">
<span class="case-badge">#{{ r.id }}</span>
</td>
<td>
<div class="user-cell">
<div class="dot reporter"></div>
<span class="uname">@{{ r.reporter?.username || '' }}</span>
</div>
</td>
<td>
<div class="user-cell">
<div class="dot sender"></div>
<span class="uname">@{{ r.sender_username || '' }}</span>
</div>
</td>
<td>
<span class="reason-chip">{{ rl(r.reason) }}</span>
</td>
<td class="td-msg">
<span class="msg-snip">{{ r.message_text }}</span>
</td>
<td>
<span class="status-chip"
:style="{ color: statusMeta[r.status].color, background: statusMeta[r.status].bg, borderColor: statusMeta[r.status].border }"
>{{ statusMeta[r.status].label }}</span>
</td>
<td class="td-date">{{ fmt(r.created_at) }}</td>
<td class="td-arrow"><ChevronRight :size="14" /></td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="pagination" v-if="reports.links && reports.links.length > 3">
<template v-for="link in reports.links" :key="link.label">
<button
class="page-btn"
:class="{ active: link.active, disabled: !link.url }"
:disabled="!link.url"
@click="link.url && router.visit(link.url)"
v-html="link.label"
></button>
</template>
</div>
</CasinoAdminLayout>
</template>
<style scoped>
/* ── Stats ── */
.stats-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
margin-bottom: 18px;
}
.stat-card {
display: flex; align-items: center; gap: 12px;
background: #111113; border: 1px solid #1e1e21; border-radius: 12px;
padding: 14px 16px; cursor: pointer; transition: .15s; position: relative;
text-align: left;
}
.stat-card:hover { border-color: #2a2a2f; background: #141416; }
.stat-card.active { border-color: #333; background: #161618; }
.sc-icon { flex-shrink: 0; }
.sc-icon.all { color: #888; }
.sc-icon.pending { color: #f59e0b; }
.sc-icon.reviewed { color: #22c55e; }
.sc-icon.dismissed { color: #6b7280; }
.sc-body { display: flex; flex-direction: column; gap: 1px; }
.sc-val { font-size: 22px; font-weight: 900; color: #fff; line-height: 1; }
.sc-val.pending { color: #f59e0b; }
.sc-val.reviewed { color: #22c55e; }
.sc-val.dismissed { color: #6b7280; }
.sc-label { font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: .5px; font-weight: 700; }
.sc-dot {
position: absolute; top: 10px; right: 10px;
width: 7px; height: 7px; border-radius: 50%; background: #f59e0b;
box-shadow: 0 0 6px rgba(245,158,11,.6);
animation: pulse-dot 1.5s ease-in-out infinite;
}
@keyframes pulse-dot {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: .5; transform: scale(.7); }
}
/* ── Toolbar ── */
.toolbar {
display: flex; align-items: center; gap: 12px; margin-bottom: 16px;
}
.search-wrap {
flex: 1; position: relative; max-width: 380px;
}
.search-icon {
position: absolute; left: 11px; top: 50%; transform: translateY(-50%); color: #444; pointer-events: none;
}
.search-input {
width: 100%; background: #111113; border: 1px solid #1e1e21; border-radius: 9px;
color: #ccc; font-size: 13px; padding: 9px 12px 9px 34px; outline: none; transition: .15s;
}
.search-input::placeholder { color: #3a3a3f; }
.search-input:focus { border-color: #333; }
.showing-pill {
display: flex; align-items: center; gap: 6px;
background: #111113; border: 1px solid #1e1e21; border-radius: 20px;
padding: 6px 12px; font-size: 12px; color: #555; margin-left: auto;
}
.showing-count {
background: #1a1a1c; border: 1px solid #252528; border-radius: 10px;
padding: 1px 7px; color: #888; font-size: 11px; font-weight: 700;
}
/* ── Table ── */
.table-wrap {
background: #111113; border: 1px solid #1e1e21; border-radius: 14px; overflow: hidden;
}
.empty-state { padding: 60px 20px; text-align: center; }
.empty-icon { color: #2a2a2f; margin: 0 auto 12px; }
.empty-text { color: #555; font-size: 14px; font-weight: 700; }
.empty-sub { color: #3a3a3f; font-size: 12px; margin-top: 4px; }
.reports-table { width: 100%; border-collapse: collapse; table-layout: fixed; }
.reports-table thead tr { border-bottom: 1px solid #1e1e21; }
.reports-table th {
padding: 10px 14px; font-size: 10px; font-weight: 700;
text-transform: uppercase; letter-spacing: .6px; color: #3a3a3f; text-align: left;
}
.reports-table td { padding: 11px 14px; font-size: 13px; vertical-align: middle; }
.report-row { border-bottom: 1px solid #161618; cursor: pointer; transition: background .12s; }
.report-row:last-child { border-bottom: none; }
.report-row:hover { background: rgba(255,255,255,.025); }
.report-row.status-pending { border-left: 2px solid #f59e0b; }
.report-row.status-reviewed { border-left: 2px solid #22c55e; opacity: .65; }
.report-row.status-dismissed { border-left: 2px solid #252528; opacity: .45; }
.td-case { width: 70px; }
.case-badge {
font-size: 11px; font-weight: 800; color: #888;
background: #161618; border: 1px solid #252528;
padding: 3px 9px; border-radius: 6px;
}
.user-cell { display: flex; align-items: center; gap: 7px; white-space: nowrap; overflow: hidden; }
.dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
.dot.reporter { background: #3b82f6; }
.dot.sender { background: #df006a; }
.uname { font-size: 13px; color: #ddd; font-weight: 600; overflow: hidden; text-overflow: ellipsis; }
.reason-chip {
font-size: 10px; font-weight: 700; color: #aaa;
background: #161618; border: 1px solid #252528;
padding: 3px 8px; border-radius: 6px; white-space: nowrap;
}
.td-msg { max-width: 0; }
.msg-snip {
color: #555; font-size: 12px; display: block;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.status-chip {
font-size: 10px; font-weight: 800; padding: 3px 9px;
border-radius: 20px; border: 1px solid;
text-transform: uppercase; letter-spacing: .4px; white-space: nowrap;
}
.td-date { color: #444; font-size: 11px; white-space: nowrap; }
.td-arrow { color: #333; text-align: center; }
/* ── Pagination ── */
.pagination { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 18px; justify-content: center; }
.page-btn {
background: #111; border: 1px solid #222; color: #ccc;
padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 12px; transition: .2s;
}
.page-btn.active { background: #df006a; border-color: #df006a; color: #fff; }
.page-btn.disabled { opacity: .4; cursor: default; }
@media (max-width: 900px) {
.stats-row { grid-template-columns: 1fr 1fr; }
}
</style>

View File

@@ -0,0 +1,155 @@
<script setup lang="ts">
import { useForm, Head } from '@inertiajs/vue3';
import { ref } from 'vue';
import UserLayout from '@/layouts/admin/CasinoAdminLayout.vue';
defineProps<{
users: any;
}>();
const editingUser = ref<any>(null);
const form = useForm({
vip_level: 0,
balance: '0',
is_banned: false,
is_chat_banned: false,
ban_reason: '',
ban_ends_at: '', // optional end time for account ban
chat_ban_ends_at: '', // optional end time for chat ban
role: 'User',
});
const openEditModal = (user: any) => {
editingUser.value = user;
form.vip_level = user.vip_level;
form.balance = user.balance;
form.is_banned = user.is_banned || false;
form.is_chat_banned = user.is_chat_banned || false;
form.ban_reason = user.ban_reason || '';
form.ban_ends_at = '';
form.chat_ban_ends_at = '';
form.role = user.role || 'User';
};
const saveUser = () => {
if (!editingUser.value) return;
form.post(`/admin/users/${editingUser.value.id}`, {
preserveScroll: true,
onSuccess: () => {
editingUser.value = null;
}
});
};
</script>
<template>
<UserLayout>
<Head title="Admin Dashboard" />
<div class="p-4 sm:p-6 lg:p-8">
<h1 class="text-2xl font-bold text-white mb-6">Admin Dashboard</h1>
<div class="bg-[#0f0f0f] border border-[#1f1f1f] rounded-xl shadow-lg">
<div class="overflow-x-auto">
<table class="w-full text-sm text-left text-gray-400">
<thead class="text-xs text-gray-400 uppercase bg-[#141414]">
<tr>
<th scope="col" class="px-6 py-3">ID</th>
<th scope="col" class="px-6 py-3">User</th>
<th scope="col" class="px-6 py-3">Role</th>
<th scope="col" class="px-6 py-3">Balance</th>
<th scope="col" class="px-6 py-3">Status</th>
<th scope="col" class="px-6 py-3"></th>
</tr>
</thead>
<tbody>
<tr v-for="user in users.data" :key="user.id" class="border-b border-[#1f1f1f] hover:bg-[#141414]">
<td class="px-6 py-4 font-bold text-white">{{ user.id }}</td>
<td class="px-6 py-4 font-bold text-white">{{ user.username }}</td>
<td class="px-6 py-4">
<span :class="{'text-red-500': user.role === 'Admin', 'text-blue-400': user.role === 'Staff'}">{{ user.role }}</span>
</td>
<td class="px-6 py-4">${{ user.balance }}</td>
<td class="px-6 py-4 flex gap-2">
<span v-if="user.is_banned" class="px-2 py-1 text-xs font-bold text-red-400 bg-red-900/50 rounded-full">BANNED</span>
<span v-if="user.is_chat_banned" class="px-2 py-1 text-xs font-bold text-orange-400 bg-orange-900/50 rounded-full">MUTED</span>
<span v-if="!user.is_banned && !user.is_chat_banned" class="px-2 py-1 text-xs font-bold text-green-400 bg-green-900/50 rounded-full">Active</span>
</td>
<td class="px-6 py-4 text-right">
<button @click="openEditModal(user)" class="font-medium text-blue-500 hover:underline">Edit</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Edit Modal -->
<div v-if="editingUser" class="fixed inset-0 bg-black/80 z-50 flex items-center justify-center backdrop-blur-sm" @click.self="editingUser = null">
<div class="bg-[#111] border border-[#333] rounded-xl p-6 w-full max-w-md shadow-2xl">
<h2 class="text-lg font-bold mb-4 text-white">Edit {{ editingUser.username }}</h2>
<form @submit.prevent="saveUser" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-xs font-bold text-gray-400 uppercase mb-1">VIP Level</label>
<input type="number" v-model="form.vip_level" class="w-full bg-[#0a0a0a] border border-[#333] rounded-lg p-3 text-white text-sm focus:border-blue-500 outline-none">
</div>
<div>
<label class="block text-xs font-bold text-gray-400 uppercase mb-1">Role</label>
<select v-model="form.role" class="w-full bg-[#0a0a0a] border border-[#333] rounded-lg p-3 text-white text-sm focus:border-blue-500 outline-none">
<option>User</option>
<option>Staff</option>
<option>Admin</option>
</select>
</div>
</div>
<div>
<label class="block text-xs font-bold text-gray-400 uppercase mb-1">Balance</label>
<input type="text" v-model="form.balance" class="w-full bg-[#0a0a0a] border border-[#333] rounded-lg p-3 text-white text-sm focus:border-blue-500 outline-none">
</div>
<div class="border-t border-[#222] pt-4 mt-4">
<h3 class="text-sm font-bold text-red-500 uppercase mb-3">Restrictions</h3>
<div class="space-y-3">
<label class="flex items-center p-3 bg-[#1a0505] border border-red-900/30 rounded-lg cursor-pointer hover:bg-[#2a0a0a] transition">
<input type="checkbox" v-model="form.is_banned" class="w-4 h-4 text-red-600 bg-gray-700 border-gray-600 rounded focus:ring-red-500">
<span class="ml-3 text-sm font-bold text-red-400">Account Ban (Full Lock)</span>
</label>
<label class="flex items-center p-3 bg-[#1a1005] border border-orange-900/30 rounded-lg cursor-pointer hover:bg-[#2a1a0a] transition">
<input type="checkbox" v-model="form.is_chat_banned" class="w-4 h-4 text-orange-600 bg-gray-700 border-gray-600 rounded focus:ring-orange-500">
<span class="ml-3 text-sm font-bold text-orange-400">Chat Ban (Mute)</span>
</label>
</div>
<div v-if="form.is_banned || form.is_chat_banned" class="mt-4 space-y-4">
<div>
<label class="block text-xs font-bold text-gray-400 uppercase mb-1">Reason</label>
<input type="text" v-model="form.ban_reason" placeholder="Why?" class="w-full bg-[#0a0a0a] border border-[#333] rounded-lg p-3 text-white text-sm focus:border-red-500 outline-none">
</div>
<div v-if="form.is_banned">
<label class="block text-xs font-bold text-gray-400 uppercase mb-1">Account Ban ends at (optional)</label>
<input type="datetime-local" v-model="form.ban_ends_at" class="w-full bg-[#0a0a0a] border border-[#333] rounded-lg p-3 text-white text-sm focus:border-red-500 outline-none">
<p class="mt-1 text-[11px] text-gray-500">Leave empty for permanent ban. Uses server timezone.</p>
</div>
<div v-if="form.is_chat_banned">
<label class="block text-xs font-bold text-gray-400 uppercase mb-1">Chat Ban ends at (optional)</label>
<input type="datetime-local" v-model="form.chat_ban_ends_at" class="w-full bg-[#0a0a0a] border border-[#333] rounded-lg p-3 text-white text-sm focus:border-orange-500 outline-none">
<p class="mt-1 text-[11px] text-gray-500">Leave empty for permanent mute. Uses server timezone.</p>
</div>
</div>
</div>
<div class="flex justify-end gap-3 pt-4">
<button type="button" @click="editingUser = null" class="px-4 py-2 text-sm font-bold text-gray-400 hover:text-white transition">Cancel</button>
<button type="submit" :disabled="form.processing" class="px-6 py-2 text-sm font-bold text-white bg-blue-600 rounded-lg hover:bg-blue-500 transition shadow-lg shadow-blue-900/20">Save Changes</button>
</div>
</form>
</div>
</div>
</div>
</UserLayout>
</template>

View File

@@ -0,0 +1,440 @@
<script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3';
import { onMounted, nextTick, ref, computed } from 'vue';
import CasinoAdminLayout from '@/layouts/admin/CasinoAdminLayout.vue';
// Full country list with ISO codes and German names
const COUNTRIES: { code: string; name: string }[] = [
{ code: 'AF', name: 'Afghanistan' }, { code: 'AL', name: 'Albanien' }, { code: 'DZ', name: 'Algerien' },
{ code: 'AD', name: 'Andorra' }, { code: 'AO', name: 'Angola' }, { code: 'AR', name: 'Argentinien' },
{ code: 'AM', name: 'Armenien' }, { code: 'AU', name: 'Australien' }, { code: 'AT', name: 'Österreich' },
{ code: 'AZ', name: 'Aserbaidschan' }, { code: 'BS', name: 'Bahamas' }, { code: 'BH', name: 'Bahrain' },
{ code: 'BD', name: 'Bangladesch' }, { code: 'BY', name: 'Belarus' }, { code: 'BE', name: 'Belgien' },
{ code: 'BZ', name: 'Belize' }, { code: 'BO', name: 'Bolivien' }, { code: 'BA', name: 'Bosnien' },
{ code: 'BR', name: 'Brasilien' }, { code: 'BN', name: 'Brunei' }, { code: 'BG', name: 'Bulgarien' },
{ code: 'KH', name: 'Kambodscha' }, { code: 'CA', name: 'Kanada' }, { code: 'CL', name: 'Chile' },
{ code: 'CN', name: 'China' }, { code: 'CO', name: 'Kolumbien' }, { code: 'HR', name: 'Kroatien' },
{ code: 'CU', name: 'Kuba' }, { code: 'CY', name: 'Zypern' }, { code: 'CZ', name: 'Tschechien' },
{ code: 'DK', name: 'Dänemark' }, { code: 'EG', name: 'Ägypten' }, { code: 'EE', name: 'Estland' },
{ code: 'ET', name: 'Äthiopien' }, { code: 'FI', name: 'Finnland' }, { code: 'FR', name: 'Frankreich' },
{ code: 'GE', name: 'Georgien' }, { code: 'DE', name: 'Deutschland' }, { code: 'GH', name: 'Ghana' },
{ code: 'GR', name: 'Griechenland' }, { code: 'GT', name: 'Guatemala' }, { code: 'HU', name: 'Ungarn' },
{ code: 'IN', name: 'Indien' }, { code: 'ID', name: 'Indonesien' }, { code: 'IR', name: 'Iran' },
{ code: 'IQ', name: 'Irak' }, { code: 'IE', name: 'Irland' }, { code: 'IL', name: 'Israel' },
{ code: 'IT', name: 'Italien' }, { code: 'JP', name: 'Japan' }, { code: 'JO', name: 'Jordanien' },
{ code: 'KZ', name: 'Kasachstan' }, { code: 'KE', name: 'Kenia' }, { code: 'KW', name: 'Kuwait' },
{ code: 'LV', name: 'Lettland' }, { code: 'LB', name: 'Libanon' }, { code: 'LY', name: 'Libyen' },
{ code: 'LT', name: 'Litauen' }, { code: 'LU', name: 'Luxemburg' }, { code: 'MY', name: 'Malaysia' },
{ code: 'MT', name: 'Malta' }, { code: 'MX', name: 'Mexiko' }, { code: 'MD', name: 'Moldawien' },
{ code: 'MC', name: 'Monaco' }, { code: 'ME', name: 'Montenegro' }, { code: 'MA', name: 'Marokko' },
{ code: 'NL', name: 'Niederlande' }, { code: 'NZ', name: 'Neuseeland' }, { code: 'NG', name: 'Nigeria' },
{ code: 'NO', name: 'Norwegen' }, { code: 'OM', name: 'Oman' }, { code: 'PK', name: 'Pakistan' },
{ code: 'PE', name: 'Peru' }, { code: 'PH', name: 'Philippinen' }, { code: 'PL', name: 'Polen' },
{ code: 'PT', name: 'Portugal' }, { code: 'QA', name: 'Katar' }, { code: 'RO', name: 'Rumänien' },
{ code: 'RU', name: 'Russland' }, { code: 'SA', name: 'Saudi-Arabien' }, { code: 'RS', name: 'Serbien' },
{ code: 'SG', name: 'Singapur' }, { code: 'SK', name: 'Slowakei' }, { code: 'SI', name: 'Slowenien' },
{ code: 'ZA', name: 'Südafrika' }, { code: 'KR', name: 'Südkorea' }, { code: 'ES', name: 'Spanien' },
{ code: 'LK', name: 'Sri Lanka' }, { code: 'SE', name: 'Schweden' }, { code: 'CH', name: 'Schweiz' },
{ code: 'SY', name: 'Syrien' }, { code: 'TW', name: 'Taiwan' }, { code: 'TH', name: 'Thailand' },
{ code: 'TN', name: 'Tunesien' }, { code: 'TR', name: 'Türkei' }, { code: 'UA', name: 'Ukraine' },
{ code: 'AE', name: 'Vereinigte Arabische Emirate' }, { code: 'GB', name: 'Vereinigtes Königreich' },
{ code: 'US', name: 'USA' }, { code: 'UY', name: 'Uruguay' }, { code: 'UZ', name: 'Usbekistan' },
{ code: 'VE', name: 'Venezuela' }, { code: 'VN', name: 'Vietnam' }, { code: 'YE', name: 'Jemen' },
{ code: 'ZW', name: 'Simbabwe' },
];
const countryMap: Record<string, string> = Object.fromEntries(COUNTRIES.map(c => [c.code, c.name]));
function getCountryName(code: string): string { return countryMap[code] ?? code; }
const props = defineProps<{
settings: {
enabled: boolean;
mode: 'blacklist' | 'whitelist';
blocked_countries: string[];
allowed_countries: string[];
vpn_block: boolean;
vpn_provider: 'none' | 'ipqualityscore' | 'proxycheck';
vpn_api_key: string;
block_message: string;
redirect_url: string;
};
}>();
const form = useForm({
enabled: props.settings.enabled ?? false,
mode: props.settings.mode ?? 'blacklist',
blocked_countries: [...(props.settings.blocked_countries ?? [])],
allowed_countries: [...(props.settings.allowed_countries ?? [])],
vpn_block: props.settings.vpn_block ?? false,
vpn_provider: props.settings.vpn_provider ?? 'none',
vpn_api_key: props.settings.vpn_api_key ?? '',
block_message: props.settings.block_message ?? 'This service is not available in your region.',
redirect_url: props.settings.redirect_url ?? '',
});
const newBlocked = ref('');
const newAllowed = ref('');
const blockedSearch = ref('');
const allowedSearch = ref('');
const filteredBlockedCountries = computed(() => {
const q = blockedSearch.value.toLowerCase();
return COUNTRIES.filter(c =>
!form.blocked_countries.includes(c.code) &&
(c.name.toLowerCase().includes(q) || c.code.toLowerCase().includes(q))
);
});
const filteredAllowedCountries = computed(() => {
const q = allowedSearch.value.toLowerCase();
return COUNTRIES.filter(c =>
!form.allowed_countries.includes(c.code) &&
(c.name.toLowerCase().includes(q) || c.code.toLowerCase().includes(q))
);
});
function addCountry(list: 'blocked' | 'allowed') {
const code = (list === 'blocked' ? newBlocked.value : newAllowed.value).trim().toUpperCase();
if (!code || code.length !== 2) return;
const arr = list === 'blocked' ? form.blocked_countries : form.allowed_countries;
if (!arr.includes(code)) arr.push(code);
if (list === 'blocked') { newBlocked.value = ''; blockedSearch.value = ''; }
else { newAllowed.value = ''; allowedSearch.value = ''; }
}
function addCountryFromSelect(list: 'blocked' | 'allowed', code: string) {
if (!code) return;
const arr = list === 'blocked' ? form.blocked_countries : form.allowed_countries;
if (!arr.includes(code)) arr.push(code);
if (list === 'blocked') { newBlocked.value = ''; blockedSearch.value = ''; }
else { newAllowed.value = ''; allowedSearch.value = ''; }
}
function removeCountry(list: 'blocked' | 'allowed', code: string) {
if (list === 'blocked') {
form.blocked_countries = form.blocked_countries.filter(c => c !== code);
} else {
form.allowed_countries = form.allowed_countries.filter(c => c !== code);
}
}
function submit() {
form.post('/admin/settings/geo', { preserveScroll: true });
}
onMounted(() => {
nextTick(() => { if ((window as any).lucide) (window as any).lucide.createIcons(); });
});
</script>
<template>
<CasinoAdminLayout>
<Head title="Admin VPN & GeoBlock" />
<template #title>VPN & GeoBlock</template>
<div class="page-wrap">
<!-- Flash -->
<div v-if="($page.props as any).flash?.success" class="alert-success">
<i data-lucide="check-circle"></i>
{{ ($page.props as any).flash.success }}
</div>
<div class="card">
<div class="card-head">
<div class="card-title-group">
<h2>GeoBlock & VPN-Schutz</h2>
<p class="card-subtitle">Steuere den Zugang nach Land und blockiere VPN-Nutzer.</p>
</div>
<button class="btn-primary" @click="submit" :disabled="form.processing">
<i data-lucide="save"></i>
Speichern
</button>
</div>
<!-- Master Toggle -->
<div class="section">
<div class="toggle-row">
<div>
<div class="toggle-label">GeoBlock aktivieren</div>
<div class="toggle-desc">Wenn deaktiviert, haben alle Länder Zugang.</div>
</div>
<button class="toggle-btn" :class="{ active: form.enabled }" @click="form.enabled = !form.enabled">
<span class="toggle-knob"></span>
</button>
</div>
</div>
<div :class="{ 'dimmed': !form.enabled }">
<!-- Mode -->
<div class="section">
<label class="field-label">Modus</label>
<div class="mode-grid">
<label class="mode-card" :class="{ selected: form.mode === 'blacklist' }">
<input type="radio" v-model="form.mode" value="blacklist" class="hidden">
<i data-lucide="shield-x"></i>
<div>
<div class="mode-name">Blacklist</div>
<div class="mode-desc">Alle Länder erlaubt, <b>außer</b> den gesperrten.</div>
</div>
</label>
<label class="mode-card" :class="{ selected: form.mode === 'whitelist' }">
<input type="radio" v-model="form.mode" value="whitelist" class="hidden">
<i data-lucide="shield-check"></i>
<div>
<div class="mode-name">Whitelist</div>
<div class="mode-desc">Nur <b>erlaubte</b> Länder haben Zugang.</div>
</div>
</label>
</div>
</div>
<!-- Blocked Countries -->
<div class="section" v-if="form.mode === 'blacklist'">
<label class="field-label">Gesperrte Länder</label>
<div class="country-picker-row">
<div class="country-search-wrap">
<input type="text" v-model="blockedSearch" placeholder="Land suchen..." class="field-input country-search-input">
<div class="country-dropdown" v-if="blockedSearch.length >= 1">
<button
v-for="c in filteredBlockedCountries.slice(0, 8)"
:key="c.code"
class="country-option"
@click.prevent="addCountryFromSelect('blocked', c.code)"
>
<img :src="`https://flagcdn.com/20x15/${c.code.toLowerCase()}.png`" :alt="c.code" class="option-flag">
<span class="option-name">{{ c.name }}</span>
<span class="option-code">{{ c.code }}</span>
</button>
<p v-if="filteredBlockedCountries.length === 0" class="no-results">Kein Land gefunden.</p>
</div>
</div>
</div>
<div class="tags" v-if="form.blocked_countries.length">
<span v-for="c in form.blocked_countries" :key="c" class="tag tag-red">
<img :src="`https://flagcdn.com/16x12/${c.toLowerCase()}.png`" :alt="c" class="flag-img" @error="(e:any)=>e.target.style.display='none'">
<span class="tag-name">{{ getCountryName(c) }}</span>
<span class="tag-code">{{ c }}</span>
<button @click="removeCountry('blocked', c)"><i data-lucide="x"></i></button>
</span>
</div>
<p class="field-hint" v-else>Noch keine Länder gesperrt.</p>
</div>
<!-- Allowed Countries -->
<div class="section" v-if="form.mode === 'whitelist'">
<label class="field-label">Erlaubte Länder</label>
<div class="country-picker-row">
<div class="country-search-wrap">
<input type="text" v-model="allowedSearch" placeholder="Land suchen..." class="field-input country-search-input">
<div class="country-dropdown" v-if="allowedSearch.length >= 1">
<button
v-for="c in filteredAllowedCountries.slice(0, 8)"
:key="c.code"
class="country-option"
@click.prevent="addCountryFromSelect('allowed', c.code)"
>
<img :src="`https://flagcdn.com/20x15/${c.code.toLowerCase()}.png`" :alt="c.code" class="option-flag">
<span class="option-name">{{ c.name }}</span>
<span class="option-code">{{ c.code }}</span>
</button>
<p v-if="filteredAllowedCountries.length === 0" class="no-results">Kein Land gefunden.</p>
</div>
</div>
</div>
<div class="tags" v-if="form.allowed_countries.length">
<span v-for="c in form.allowed_countries" :key="c" class="tag tag-green">
<img :src="`https://flagcdn.com/16x12/${c.toLowerCase()}.png`" :alt="c" class="flag-img" @error="(e:any)=>e.target.style.display='none'">
<span class="tag-name">{{ getCountryName(c) }}</span>
<span class="tag-code">{{ c }}</span>
<button @click="removeCountry('allowed', c)"><i data-lucide="x"></i></button>
</span>
</div>
<p class="field-hint" v-else>Noch keine Länder erlaubt.</p>
</div>
<div class="divider"></div>
<!-- VPN Block -->
<div class="section">
<div class="toggle-row">
<div>
<div class="toggle-label">VPN/Proxy blockieren</div>
<div class="toggle-desc">Nutzer mit VPN oder Proxy werden gesperrt.</div>
</div>
<button class="toggle-btn" :class="{ active: form.vpn_block }" @click="form.vpn_block = !form.vpn_block">
<span class="toggle-knob"></span>
</button>
</div>
<div v-if="form.vpn_block" class="vpn-settings">
<div class="form-row">
<div class="form-col">
<label class="field-label">VPN-Anbieter</label>
<select v-model="form.vpn_provider" class="field-input">
<option value="none">Kein (deaktiviert)</option>
<option value="ipqualityscore">IPQualityScore</option>
<option value="proxycheck">ProxyCheck.io</option>
</select>
</div>
<div class="form-col" v-if="form.vpn_provider !== 'none'">
<label class="field-label">API-Schlüssel</label>
<input type="text" v-model="form.vpn_api_key" placeholder="API Key..." class="field-input">
</div>
</div>
</div>
</div>
<div class="divider"></div>
<!-- Block Message & Redirect -->
<div class="section">
<div class="form-row">
<div class="form-col full">
<label class="field-label">Sperr-Nachricht</label>
<textarea v-model="form.block_message" rows="3" class="field-input" placeholder="Diese Nachricht wird gesperrten Nutzern angezeigt."></textarea>
</div>
<div class="form-col full">
<label class="field-label">Weiterleitungs-URL <span class="optional">(optional)</span></label>
<input type="url" v-model="form.redirect_url" placeholder="https://example.com/gesperrt" class="field-input">
<p class="field-hint">Wenn angegeben, werden gesperrte Nutzer dorthin weitergeleitet statt eine Fehlermeldung zu sehen.</p>
</div>
</div>
</div>
</div>
<div class="card-foot">
<button class="btn-primary" @click="submit" :disabled="form.processing">
<i data-lucide="save"></i>
{{ form.processing ? 'Wird gespeichert...' : 'Einstellungen speichern' }}
</button>
</div>
</div>
</div>
</CasinoAdminLayout>
</template>
<style scoped>
.page-wrap { max-width: 860px; margin: 0 auto; display: flex; flex-direction: column; gap: 20px; }
.alert-success {
background: rgba(0, 200, 100, 0.1); border: 1px solid rgba(0, 200, 100, 0.3);
color: #00c864; padding: 12px 16px; border-radius: 10px;
display: flex; align-items: center; gap: 10px; font-weight: 600;
}
.alert-success i { width: 18px; height: 18px; flex-shrink: 0; }
.card { background: #0f0f10; border: 1px solid #18181b; border-radius: 14px; overflow: hidden; }
.card-head {
display: flex; align-items: flex-start; justify-content: space-between; gap: 16px;
padding: 24px; border-bottom: 1px solid #18181b;
}
.card-title-group h2 { font-size: 20px; font-weight: 700; color: #fff; margin: 0 0 4px; }
.card-subtitle { color: #71717a; font-size: 13px; margin: 0; }
.card-foot { padding: 20px 24px; border-top: 1px solid #18181b; display: flex; justify-content: flex-end; }
.section { padding: 20px 24px; }
.divider { height: 1px; background: #18181b; margin: 0; }
.dimmed { opacity: 0.4; pointer-events: none; }
/* Toggle */
.toggle-row { display: flex; align-items: center; justify-content: space-between; gap: 20px; }
.toggle-label { font-weight: 600; color: #e4e4e7; font-size: 14px; }
.toggle-desc { color: #71717a; font-size: 12px; margin-top: 2px; }
.toggle-btn {
width: 48px; height: 26px; border-radius: 99px; background: #27272a; border: none; cursor: pointer;
position: relative; transition: background 0.2s; flex-shrink: 0;
}
.toggle-btn.active { background: #df006a; }
.toggle-knob {
position: absolute; top: 3px; left: 3px; width: 20px; height: 20px;
background: #fff; border-radius: 50%; transition: transform 0.2s;
}
.toggle-btn.active .toggle-knob { transform: translateX(22px); }
/* Mode cards */
.mode-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 10px; }
.mode-card {
display: flex; align-items: flex-start; gap: 14px; padding: 16px;
background: #18181b; border: 2px solid #27272a; border-radius: 10px;
cursor: pointer; transition: all 0.15s;
}
.mode-card:hover { border-color: #3f3f46; }
.mode-card.selected { border-color: #df006a; background: rgba(223,0,106,0.06); }
.mode-card i { width: 20px; height: 20px; color: #71717a; flex-shrink: 0; margin-top: 2px; }
.mode-card.selected i { color: #df006a; }
.mode-name { font-weight: 700; font-size: 14px; color: #e4e4e7; }
.mode-desc { font-size: 12px; color: #71717a; margin-top: 2px; }
/* Fields */
.field-label { display: block; font-size: 12px; font-weight: 700; color: #a1a1aa; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px; }
.field-input {
width: 100%; background: #18181b; border: 1px solid #27272a; border-radius: 8px;
color: #e4e4e7; padding: 10px 14px; font-size: 13px; transition: border-color 0.15s;
font-family: inherit;
}
.field-input:focus { outline: none; border-color: #df006a; }
.code-input { width: 100px; text-transform: uppercase; letter-spacing: 2px; font-weight: 700; }
.field-hint { font-size: 11px; color: #52525b; margin-top: 6px; }
.optional { font-weight: 400; color: #52525b; text-transform: none; letter-spacing: 0; }
/* Country picker */
.country-picker-row { margin-bottom: 12px; }
.country-search-wrap { position: relative; }
.country-search-input { width: 100%; }
.country-dropdown {
position: absolute; top: calc(100% + 4px); left: 0; right: 0; z-index: 100;
background: #18181b; border: 1px solid #3f3f46; border-radius: 10px;
overflow: hidden; box-shadow: 0 8px 24px rgba(0,0,0,0.5);
}
.country-option {
display: flex; align-items: center; gap: 10px; width: 100%;
padding: 9px 14px; background: transparent; border: none;
color: #e4e4e7; cursor: pointer; text-align: left; transition: background 0.1s;
}
.country-option:hover { background: rgba(223,0,106,0.1); }
.option-flag { width: 20px; height: 15px; object-fit: cover; border-radius: 2px; flex-shrink: 0; }
.option-name { flex: 1; font-size: 13px; }
.option-code { font-size: 11px; color: #71717a; font-weight: 700; letter-spacing: 1px; }
.no-results { color: #52525b; font-size: 12px; padding: 10px 14px; margin: 0; }
.tags { display: flex; flex-wrap: wrap; gap: 8px; }
.tag {
display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px; border-radius: 6px;
font-size: 12px; font-weight: 600;
}
.tag-name { font-size: 12px; }
.tag-code { font-size: 10px; opacity: 0.6; letter-spacing: 1px; font-weight: 700; }
.tag button { background: transparent; border: none; cursor: pointer; display: flex; align-items: center; padding: 0; }
.tag button i { width: 12px; height: 12px; }
.flag-img { width: 16px; height: 12px; object-fit: cover; border-radius: 2px; flex-shrink: 0; }
.tag-red { background: rgba(255,62,62,0.1); color: #ff3e3e; border: 1px solid rgba(255,62,62,0.25); }
.tag-red button { color: #ff3e3e; }
.tag-green { background: rgba(0,200,100,0.1); color: #00c864; border: 1px solid rgba(0,200,100,0.25); }
.tag-green button { color: #00c864; }
/* VPN Settings */
.vpn-settings { margin-top: 16px; }
/* Form layout */
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 8px; }
.form-col.full { grid-column: 1 / -1; }
/* Primary button */
.btn-primary {
display: inline-flex; align-items: center; gap: 8px;
background: #df006a; border: none; color: #fff; padding: 10px 20px;
border-radius: 8px; font-weight: 700; font-size: 13px; cursor: pointer; transition: all 0.15s;
}
.btn-primary:hover { background: #b8005a; }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary i { width: 16px; height: 16px; }
.hidden { display: none; }
@media (max-width: 640px) {
.mode-grid { grid-template-columns: 1fr; }
.form-row { grid-template-columns: 1fr; }
.card-head { flex-direction: column; }
}
</style>

View File

@@ -0,0 +1,297 @@
<script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3';
import { ref, computed, onMounted, nextTick } from 'vue';
import AdminLayout from '@/layouts/admin/CasinoAdminLayout.vue';
import { csrfFetch } from '@/utils/csrfFetch';
const props = defineProps<{
settings: {
mode: 'live'|'sandbox';
api_key?: string;
ipn_secret?: string;
address_mode?: string;
enabled_currencies: string[];
global_min_usd: number;
global_max_usd: number;
btx_per_usd: number;
per_currency_overrides: Record<string, { min_usd?: number|null; max_usd?: number|null; btx_per_usd?: number|null }>;
success_url: string;
cancel_url: string;
};
defaults: {
commonCurrencies: string[];
modes: string[];
addressModes: string[];
}
}>();
const allCurrencies = ref<string[]>(props.defaults.commonCurrencies);
const addCurrency = ref('');
const showApiKey = ref(false);
const showIpnSecret = ref(false);
const testStatus = ref<'idle'|'loading'|'ok'|'fail'>('idle');
const testMessage = ref('');
const form = useForm({
mode: props.settings.mode || 'live',
api_key: props.settings.api_key || '',
ipn_secret: props.settings.ipn_secret || '',
address_mode: props.settings.address_mode || 'per_payment',
enabled_currencies: [...(props.settings.enabled_currencies || [])],
global_min_usd: props.settings.global_min_usd ?? 10,
global_max_usd: props.settings.global_max_usd ?? 10000,
btx_per_usd: props.settings.btx_per_usd ?? 1.0,
per_currency_overrides: { ...(props.settings.per_currency_overrides || {}) },
success_url: props.settings.success_url || '/wallet?deposit=success',
cancel_url: props.settings.cancel_url || '/wallet?deposit=cancel',
});
const pickList = computed(() => {
const set = new Set(form.enabled_currencies);
return allCurrencies.value.filter(c => !set.has(c));
});
function addToWhitelist(cur: string) {
const up = cur.toUpperCase();
if (!form.enabled_currencies.includes(up)) form.enabled_currencies.push(up);
addCurrency.value = '';
}
function removeFromWhitelist(cur: string) {
form.enabled_currencies = form.enabled_currencies.filter(c => c !== cur);
if (form.per_currency_overrides[cur]) {
const rest: any = { ...(form.per_currency_overrides as any) };
delete rest[cur];
form.per_currency_overrides = rest as any;
}
}
function ensureOverride(cur: string) {
if (!form.per_currency_overrides[cur]) {
form.per_currency_overrides[cur] = { min_usd: null, max_usd: null, btx_per_usd: null } as any;
}
}
async function submit() {
await form.post('/admin/payments/settings', { preserveScroll: true });
}
async function testConnection() {
testStatus.value = 'loading';
testMessage.value = '';
try {
const res = await csrfFetch('/admin/payments/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({ api_key: form.api_key }),
});
const json = await res.json();
if (res.ok && json.ok) {
testStatus.value = 'ok';
testMessage.value = json.message || 'Verbindung erfolgreich!';
} else {
testStatus.value = 'fail';
testMessage.value = json.message || 'Verbindung fehlgeschlagen.';
}
} catch {
testStatus.value = 'fail';
testMessage.value = 'Netzwerkfehler.';
}
}
onMounted(() => {
nextTick(() => { if ((window as any).lucide) (window as any).lucide.createIcons(); });
});
</script>
<template>
<AdminLayout>
<Head title="Admin Payment Settings" />
<section class="content">
<div class="wrap">
<div class="panel">
<header class="page-head">
<div class="head-flex">
<div class="title-group">
<div class="title">NOWPayments Einstellungen</div>
<p class="subtitle">Konfiguriere LiveModus, CoinsWhitelist, Limits und BTXKurs.</p>
</div>
<div class="actions">
<button class="btn primary" @click="submit" :disabled="form.processing">
<i data-lucide="save"></i>
Speichern
</button>
</div>
</div>
</header>
<!-- API Credentials -->
<div class="section-title">API Zugangsdaten</div>
<div class="form-grid">
<div class="form-item full">
<label class="lbl">NOWPayments API Key</label>
<div class="secret-row">
<input :type="showApiKey ? 'text' : 'password'" v-model="form.api_key" placeholder="Dein API Key..." />
<button type="button" class="btn icon-btn" @click="showApiKey = !showApiKey">
<i :data-lucide="showApiKey ? 'eye-off' : 'eye'"></i>
</button>
<button type="button" class="btn" :class="testStatus === 'ok' ? 'success' : testStatus === 'fail' ? 'danger' : ''" @click="testConnection" :disabled="!form.api_key || testStatus === 'loading'">
<i data-lucide="zap"></i>
{{ testStatus === 'loading' ? 'Teste...' : 'Verbindung testen' }}
</button>
</div>
<small v-if="testMessage" :class="testStatus === 'ok' ? 'text-green' : 'text-red'">{{ testMessage }}</small>
</div>
<div class="form-item full">
<label class="lbl">IPN Secret (Webhook-Signatur)</label>
<div class="secret-row">
<input :type="showIpnSecret ? 'text' : 'password'" v-model="form.ipn_secret" placeholder="Dein IPN Secret..." />
<button type="button" class="btn icon-btn" @click="showIpnSecret = !showIpnSecret">
<i :data-lucide="showIpnSecret ? 'eye-off' : 'eye'"></i>
</button>
</div>
</div>
</div>
<div class="hr"></div>
<!-- General Settings -->
<div class="section-title">Allgemein</div>
<div class="form-grid">
<div class="form-item">
<label class="lbl">Modus</label>
<select v-model="form.mode">
<option v-for="m in props.defaults.modes" :key="m" :value="m">{{ m.toUpperCase() }}</option>
</select>
<small>Für LiveBetrieb auf <b>LIVE</b> stellen.</small>
</div>
<div class="form-item">
<label class="lbl">Adress-Modus</label>
<select v-model="form.address_mode">
<option value="per_payment">Per Zahlung (neu je Transaktion)</option>
<option value="per_user">Per Nutzer (fixe Adresse)</option>
</select>
</div>
<div class="form-item">
<label class="lbl">Globales Minimum (USD/BTX)</label>
<input type="number" step="0.01" v-model.number="form.global_min_usd" />
</div>
<div class="form-item">
<label class="lbl">Globales Maximum (USD/BTX)</label>
<input type="number" step="0.01" v-model.number="form.global_max_usd" />
</div>
<div class="form-item">
<label class="lbl">BTX pro USD (global)</label>
<input type="number" step="0.00000001" v-model.number="form.btx_per_usd" />
<small>Standard 1.0 (1 BTX = 1 USD). Overrides je Währung optional unten.</small>
</div>
<div class="form-item"><!-- spacer --></div>
<div class="form-item">
<label class="lbl">Success URL</label>
<input type="text" v-model="form.success_url" />
</div>
<div class="form-item">
<label class="lbl">Cancel URL</label>
<input type="text" v-model="form.cancel_url" />
</div>
</div>
<div class="hr"></div>
<h3>CoinsWhitelist</h3>
<div class="whitelist">
<div class="tags">
<span v-for="cur in form.enabled_currencies" :key="cur" class="tag">
{{ cur }}
<button type="button" class="x" @click="removeFromWhitelist(cur)">×</button>
</span>
</div>
<div class="add-row">
<select v-model="addCurrency">
<option value="" disabled>+ Währung hinzufügen</option>
<option v-for="cur in pickList" :key="cur" :value="cur">{{ cur }}</option>
</select>
<button class="btn" :disabled="!addCurrency" @click="addToWhitelist(addCurrency)">Hinzufügen</button>
</div>
</div>
<div class="hr"></div>
<h3>PerWährung Overrides</h3>
<table class="table">
<thead>
<tr>
<th>Währung</th>
<th>Min USD</th>
<th>Max USD</th>
<th>BTX pro USD</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="cur in form.enabled_currencies" :key="cur">
<td>{{ cur }}</td>
<td>
<input type="number" step="0.01"
:value="form.per_currency_overrides[cur]?.min_usd ?? ''"
@input="(e:any)=>{ ensureOverride(cur); form.per_currency_overrides[cur].min_usd = e.target.value ? parseFloat(e.target.value) : null }" />
</td>
<td>
<input type="number" step="0.01"
:value="form.per_currency_overrides[cur]?.max_usd ?? ''"
@input="(e:any)=>{ ensureOverride(cur); form.per_currency_overrides[cur].max_usd = e.target.value ? parseFloat(e.target.value) : null }" />
</td>
<td>
<input type="number" step="0.00000001"
:value="form.per_currency_overrides[cur]?.btx_per_usd ?? ''"
@input="(e:any)=>{ ensureOverride(cur); form.per_currency_overrides[cur].btx_per_usd = e.target.value ? parseFloat(e.target.value) : null }" />
</td>
<td>
<button class="btn small" @click="() => { const o=form.per_currency_overrides[cur]; if(o){o.min_usd=null;o.max_usd=null;o.btx_per_usd=null;} }">Zurücksetzen</button>
</td>
</tr>
</tbody>
</table>
<div class="foot">
<button class="btn primary" @click="submit" :disabled="form.processing">
<i data-lucide="save"></i>
Speichern
</button>
</div>
</div>
</div>
</section>
</AdminLayout>
</template>
<style scoped>
.content { padding: 20px; }
.wrap { max-width: 1100px; margin: 0 auto; }
.panel { background: #0f0f10; border: 1px solid #18181b; border-radius: 12px; padding: 16px; }
.page-head .title { font-size: 22px; font-weight: 700; }
.subtitle { color: #a1a1aa; margin-top: 4px; }
.actions { display: flex; gap: 10px; }
.form-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; margin-top: 12px; }
.form-item .lbl { display: block; font-weight: 600; margin-bottom: 6px; }
input, select { width: 100%; background: #0b0b0c; border: 1px solid #1f1f22; border-radius: 8px; padding: 10px; color: #e5e7eb; }
.btn { background: #1f2937; border: 1px solid #374151; color: #e5e7eb; padding: 8px 14px; border-radius: 8px; cursor: pointer; }
.btn.primary { background: #ff007a; border-color: #ff2b8f; color: white; }
.btn.small { padding: 6px 10px; font-size: 12px; }
.hr { height: 1px; background: #1f1f22; margin: 16px 0; }
.whitelist .tags { display: flex; gap: 8px; flex-wrap: wrap; }
.tag { background: #18181b; border: 1px solid #27272a; padding: 6px 10px; border-radius: 999px; display: inline-flex; align-items: center; gap: 6px; }
.tag .x { background: transparent; border: 0; color: #aaa; cursor: pointer; }
.table { width: 100%; border-collapse: collapse; }
.table th, .table td { border-bottom: 1px solid #1f1f22; padding: 10px; text-align: left; }
.foot { display: flex; justify-content: flex-end; margin-top: 16px; }
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,359 @@
<script setup lang="ts">
import { Head, router } from '@inertiajs/vue3';
import { ref, computed } from 'vue';
import CasinoAdminLayout from '@/layouts/admin/CasinoAdminLayout.vue';
import {
Search, Flag, Clock, CheckCircle2, XCircle, ChevronRight, UserRound, Eye
} from 'lucide-vue-next';
interface UserRef {
id: number;
username: string;
email: string;
avatar: string | null;
avatar_url: string | null;
role?: string;
vip_level?: number;
}
interface Report {
id: number;
reporter: UserRef;
profile: UserRef;
reason: string;
details: string | null;
status: 'pending' | 'reviewed' | 'dismissed';
admin_note: string | null;
created_at: string;
}
const props = defineProps<{
reports: { data: Report[]; links: any[]; meta: any };
filters: { status?: string; search?: string };
stats: { total: number; pending: number; reviewed: number; dismissed: number };
}>();
const filterStatus = ref(props.filters.status ?? 'pending');
const searchInput = ref(props.filters.search ?? '');
let searchTimer: ReturnType<typeof setTimeout> | null = null;
function applyFilter() {
router.get('/admin/reports/profiles', {
status: filterStatus.value || undefined,
search: searchInput.value || undefined,
}, { preserveScroll: true });
}
function onSearchInput() {
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(applyFilter, 400);
}
function setStatus(s: string) {
filterStatus.value = s;
applyFilter();
}
function openReport(id: number) {
router.visit(`/admin/reports/profiles/${id}`);
}
function avatarSrc(u: UserRef | null) {
if (!u) return null;
return u.avatar_url || u.avatar || null;
}
function initials(name?: string) { return (name || '?')[0].toUpperCase(); }
function fmt(d: string) {
return new Date(d).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' });
}
const reasonLabels: Record<string, string> = {
spam: 'Spam', harassment: 'Belästigung', inappropriate: 'Unangemessen',
fake: 'Fake', other: 'Sonstiges',
};
function rl(r: string) { return reasonLabels[r] ?? r; }
const statusMeta: Record<string, { color: string; bg: string; border: string; label: string }> = {
pending: { color: '#f59e0b', bg: 'rgba(245,158,11,0.12)', border: 'rgba(245,158,11,0.3)', label: 'Pending' },
reviewed: { color: '#22c55e', bg: 'rgba(34,197,94,0.12)', border: 'rgba(34,197,94,0.3)', label: 'Reviewed' },
dismissed: { color: '#6b7280', bg: 'rgba(107,114,128,0.1)', border: 'rgba(107,114,128,0.25)', label: 'Dismissed' },
};
const showingLabel = computed(() => {
if (!filterStatus.value || filterStatus.value === 'all') return 'Alle';
return statusMeta[filterStatus.value]?.label ?? filterStatus.value;
});
</script>
<template>
<CasinoAdminLayout>
<Head title="Admin Profil Reports" />
<template #title>Profil Reports</template>
<!-- Stats row -->
<div class="stats-row">
<button class="stat-card" :class="{ active: !filterStatus || filterStatus === 'all' }" @click="setStatus('all')">
<UserRound :size="16" class="sc-icon all" />
<div class="sc-body">
<span class="sc-val">{{ stats.total }}</span>
<span class="sc-label">Gesamt</span>
</div>
</button>
<button class="stat-card" :class="{ active: filterStatus === 'pending' }" @click="setStatus('pending')">
<Clock :size="16" class="sc-icon pending" />
<div class="sc-body">
<span class="sc-val pending">{{ stats.pending }}</span>
<span class="sc-label">Offen</span>
</div>
<span v-if="stats.pending > 0" class="sc-dot"></span>
</button>
<button class="stat-card" :class="{ active: filterStatus === 'reviewed' }" @click="setStatus('reviewed')">
<CheckCircle2 :size="16" class="sc-icon reviewed" />
<div class="sc-body">
<span class="sc-val reviewed">{{ stats.reviewed }}</span>
<span class="sc-label">Erledigt</span>
</div>
</button>
<button class="stat-card" :class="{ active: filterStatus === 'dismissed' }" @click="setStatus('dismissed')">
<XCircle :size="16" class="sc-icon dismissed" />
<div class="sc-body">
<span class="sc-val dismissed">{{ stats.dismissed }}</span>
<span class="sc-label">Abgelehnt</span>
</div>
</button>
</div>
<!-- Toolbar -->
<div class="toolbar">
<div class="search-wrap">
<Search :size="14" class="search-icon" />
<input
v-model="searchInput"
@input="onSearchInput"
type="text"
placeholder="Case-Nr. oder Username suchen…"
class="search-input"
/>
</div>
<div class="showing-pill">
<Eye :size="12" />
{{ showingLabel }}
<span class="showing-count">{{ reports.meta?.total ?? reports.data.length }}</span>
</div>
</div>
<!-- List -->
<div class="reports-list">
<div v-if="!reports.data.length" class="empty-state">
<Flag :size="28" class="empty-icon-svg" />
<div class="empty-text">Keine Reports gefunden</div>
<div class="empty-sub" v-if="searchInput">Für „{{ searchInput }}" wurden keine Ergebnisse gefunden.</div>
</div>
<div
v-for="r in reports.data"
:key="r.id"
class="report-card"
:class="`status-${r.status}`"
@click="openReport(r.id)"
>
<div class="card-inner">
<!-- Case badge -->
<div class="case-badge">#{{ r.id }}</div>
<!-- Reporter -->
<div class="user-block">
<div class="av-wrap reporter-av">
<img v-if="avatarSrc(r.reporter)" :src="avatarSrc(r.reporter)!" class="av-img" />
<span v-else class="av-fb">{{ initials(r.reporter?.username) }}</span>
</div>
<div class="user-meta">
<span class="meta-label">Melder</span>
<span class="meta-name">@{{ r.reporter?.username }}</span>
</div>
</div>
<div class="sep-arrow"></div>
<!-- Reported -->
<div class="user-block">
<div class="av-wrap reported-av">
<img v-if="avatarSrc(r.profile)" :src="avatarSrc(r.profile)!" class="av-img" />
<span v-else class="av-fb">{{ initials(r.profile?.username) }}</span>
</div>
<div class="user-meta">
<span class="meta-label">Gemeldet</span>
<span class="meta-name">@{{ r.profile?.username }}</span>
<span class="role-tag" v-if="r.profile?.role">{{ r.profile.role }}</span>
</div>
</div>
<!-- Reason -->
<span class="reason-chip">{{ rl(r.reason) }}</span>
<!-- Details snippet -->
<span v-if="r.details" class="detail-snip">{{ r.details }}</span>
<!-- Right meta -->
<div class="right-meta">
<span class="status-chip"
:style="{ color: statusMeta[r.status].color, background: statusMeta[r.status].bg, borderColor: statusMeta[r.status].border }"
>{{ statusMeta[r.status].label }}</span>
<span class="ts">{{ fmt(r.created_at) }}</span>
<ChevronRight :size="14" class="chevron" />
</div>
</div>
</div>
</div>
<!-- Pagination -->
<div class="pagination" v-if="reports.links && reports.links.length > 3">
<template v-for="link in reports.links" :key="link.label">
<button
class="page-btn"
:class="{ active: link.active, disabled: !link.url }"
:disabled="!link.url"
@click="link.url && router.visit(link.url)"
v-html="link.label"
></button>
</template>
</div>
</CasinoAdminLayout>
</template>
<style scoped>
/* ── Stats ── */
.stats-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
margin-bottom: 18px;
}
.stat-card {
display: flex; align-items: center; gap: 12px;
background: #111113; border: 1px solid #1e1e21; border-radius: 12px;
padding: 14px 16px; cursor: pointer; transition: .15s; position: relative;
text-align: left;
}
.stat-card:hover { border-color: #2a2a2f; background: #141416; }
.stat-card.active { border-color: #333; background: #161618; }
.sc-icon { flex-shrink: 0; }
.sc-icon.all { color: #888; }
.sc-icon.pending { color: #f59e0b; }
.sc-icon.reviewed { color: #22c55e; }
.sc-icon.dismissed { color: #6b7280; }
.sc-body { display: flex; flex-direction: column; gap: 1px; }
.sc-val { font-size: 22px; font-weight: 900; color: #fff; line-height: 1; }
.sc-val.pending { color: #f59e0b; }
.sc-val.reviewed { color: #22c55e; }
.sc-val.dismissed { color: #6b7280; }
.sc-label { font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: .5px; font-weight: 700; }
.sc-dot {
position: absolute; top: 10px; right: 10px;
width: 7px; height: 7px; border-radius: 50%; background: #f59e0b;
box-shadow: 0 0 6px rgba(245,158,11,.6);
animation: pulse-dot 1.5s ease-in-out infinite;
}
@keyframes pulse-dot {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: .5; transform: scale(.7); }
}
/* ── Toolbar ── */
.toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
.search-wrap { flex: 1; position: relative; max-width: 380px; }
.search-icon { position: absolute; left: 11px; top: 50%; transform: translateY(-50%); color: #444; pointer-events: none; }
.search-input {
width: 100%; background: #111113; border: 1px solid #1e1e21; border-radius: 9px;
color: #ccc; font-size: 13px; padding: 9px 12px 9px 34px; outline: none; transition: .15s;
}
.search-input::placeholder { color: #3a3a3f; }
.search-input:focus { border-color: #333; }
.showing-pill {
display: flex; align-items: center; gap: 6px;
background: #111113; border: 1px solid #1e1e21; border-radius: 20px;
padding: 6px 12px; font-size: 12px; color: #555; margin-left: auto;
}
.showing-count {
background: #1a1a1c; border: 1px solid #252528; border-radius: 10px;
padding: 1px 7px; color: #888; font-size: 11px; font-weight: 700;
}
/* ── List ── */
.reports-list { display: flex; flex-direction: column; gap: 6px; }
.empty-state { text-align: center; padding: 60px 20px; background: #111113; border: 1px solid #1e1e21; border-radius: 14px; }
.empty-icon-svg { color: #2a2a2f; margin: 0 auto 12px; }
.empty-text { color: #555; font-size: 14px; font-weight: 700; }
.empty-sub { color: #3a3a3f; font-size: 12px; margin-top: 4px; }
.report-card {
background: #111113; border: 1px solid #1e1e21; border-radius: 12px;
overflow: hidden; cursor: pointer; transition: .15s;
}
.report-card:hover { background: #141416; border-color: #272729; }
.report-card.status-pending { border-left: 3px solid #f59e0b; }
.report-card.status-reviewed { border-left: 3px solid #22c55e; opacity: .65; }
.report-card.status-dismissed { border-left: 3px solid #252528; opacity: .45; }
.card-inner {
display: flex; align-items: center; gap: 14px;
padding: 12px 16px; flex-wrap: wrap;
}
.case-badge {
font-size: 11px; font-weight: 800; color: #666;
background: #161618; border: 1px solid #252528;
padding: 3px 9px; border-radius: 6px; white-space: nowrap; flex-shrink: 0;
}
.user-block { display: flex; align-items: center; gap: 9px; }
.av-wrap { width: 34px; height: 34px; border-radius: 50%; overflow: hidden; flex-shrink: 0; }
.av-img { width: 100%; height: 100%; object-fit: cover; }
.av-fb {
width: 34px; height: 34px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 13px; font-weight: 800; border: 1px solid;
}
.reporter-av .av-fb { background: rgba(59,130,246,.12); color: #3b82f6; border-color: rgba(59,130,246,.25); }
.reported-av .av-fb { background: rgba(223,0,106,.12); color: #df006a; border-color: rgba(223,0,106,.25); }
.user-meta { display: flex; flex-direction: column; gap: 1px; }
.meta-label { font-size: 9px; color: #555; text-transform: uppercase; letter-spacing: .4px; }
.meta-name { font-size: 12px; font-weight: 700; color: #ddd; white-space: nowrap; }
.role-tag { font-size: 9px; color: #666; background: #161618; padding: 1px 5px; border-radius: 4px; border: 1px solid #252528; width: fit-content; }
.sep-arrow { color: #333; font-size: 16px; flex-shrink: 0; }
.reason-chip {
font-size: 10px; font-weight: 700; color: #f59e0b;
background: rgba(245,158,11,.08); border: 1px solid rgba(245,158,11,.25);
padding: 3px 10px; border-radius: 20px; white-space: nowrap; flex-shrink: 0;
}
.detail-snip {
font-size: 11px; color: #555; flex: 1; min-width: 0;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.right-meta { margin-left: auto; display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
.status-chip {
font-size: 10px; font-weight: 800; padding: 3px 9px;
border-radius: 20px; border: 1px solid;
text-transform: uppercase; letter-spacing: .4px; white-space: nowrap;
}
.ts { font-size: 11px; color: #444; white-space: nowrap; }
.chevron { color: #333; flex-shrink: 0; }
/* ── Pagination ── */
.pagination { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 18px; justify-content: center; }
.page-btn {
background: #111; border: 1px solid #222; color: #ccc;
padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 12px; transition: .2s;
}
.page-btn.active { background: #df006a; border-color: #df006a; color: #fff; }
.page-btn.disabled { opacity: .4; cursor: default; }
@media (max-width: 900px) {
.stats-row { grid-template-columns: 1fr 1fr; }
}
</style>

View File

@@ -0,0 +1,137 @@
<script setup lang="ts">
import { Head, router } from '@inertiajs/vue3';
import { ref } from 'vue';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import UserLayout from '@/layouts/admin/CasinoAdminLayout.vue';
const props = defineProps<{ promos: any }>();
const form = ref({
code: '',
description: '',
bonus_amount: 0,
wager_multiplier: 30,
per_user_limit: 1,
global_limit: null as number | null,
starts_at: '',
ends_at: '',
min_deposit: null as number | null,
bonus_expires_days: 7 as number | null,
is_active: true,
});
function toUtcIso(dt: string): string | null {
if (!dt) return null;
// Interpret the datetime-local as local time and convert to UTC ISO string
const d = new Date(dt);
if (isNaN(d.getTime())) return null;
return d.toISOString();
}
function submitCreate() {
const payload = {
...form.value,
starts_at: toUtcIso(form.value.starts_at),
ends_at: toUtcIso(form.value.ends_at),
} as any;
router.post('/admin/promos', payload, {
preserveScroll: true,
});
}
function updatePromo(id: number, data: Record<string, any>) {
const payload: Record<string, any> = { ...data };
if (typeof payload.starts_at === 'string') payload.starts_at = toUtcIso(payload.starts_at);
if (typeof payload.ends_at === 'string') payload.ends_at = toUtcIso(payload.ends_at);
router.patch(`/admin/promos/${id}`, payload, { preserveScroll: true });
}
</script>
<template>
<UserLayout>
<Head title="Admin · Promos" />
<div class="p-4 sm:p-6 lg:p-8">
<h1 class="text-2xl font-bold text-white mb-6">Admin · Promos</h1>
<div class="grid gap-8 md:grid-cols-2">
<!-- Create new promo -->
<div class="rounded-lg border border-[#1f1f1f] p-4 space-y-3 bg-[#0f0f0f]">
<h2 class="font-semibold text-lg text-white">Neue Promo erstellen</h2>
<form class="space-y-3" @submit.prevent="submitCreate">
<div class="grid grid-cols-2 gap-3">
<label class="space-y-1">
<span class="text-sm text-gray-300">Code</span>
<Input v-model="form.code" placeholder="WELCOME10" required />
</label>
<label class="space-y-1">
<span class="text-sm text-gray-300">Bonus Betrag</span>
<Input v-model.number="form.bonus_amount" type="number" min="0" step="0.0001" required />
</label>
<label class="space-y-1">
<span class="text-sm text-gray-300">Wager x</span>
<Input v-model.number="form.wager_multiplier" type="number" min="0" />
</label>
<label class="space-y-1">
<span class="text-sm text-gray-300">Pro User Limit</span>
<Input v-model.number="form.per_user_limit" type="number" min="1" />
</label>
<label class="space-y-1">
<span class="text-sm text-gray-300">Globales Limit</span>
<Input v-model.number="form.global_limit" type="number" min="1" placeholder="optional" />
</label>
<label class="space-y-1">
<span class="text-sm text-gray-300">Min. Einzahlung</span>
<Input v-model.number="form.min_deposit" type="number" min="0" step="0.0001" placeholder="optional" />
</label>
<label class="space-y-1">
<span class="text-sm text-gray-300">Startet am</span>
<Input v-model="form.starts_at" type="datetime-local" />
</label>
<label class="space-y-1">
<span class="text-sm text-gray-300">Endet am</span>
<Input v-model="form.ends_at" type="datetime-local" />
</label>
<label class="space-y-1">
<span class="text-sm text-gray-300">Bonus Ablauf (Tage)</span>
<Input v-model.number="form.bonus_expires_days" type="number" min="1" placeholder="optional" />
</label>
</div>
<label class="inline-flex items-center gap-2 text-gray-300">
<input v-model="form.is_active" type="checkbox" />
<span>Aktiv</span>
</label>
<div class="flex gap-2">
<Button type="submit">Erstellen</Button>
</div>
</form>
</div>
<!-- List promos -->
<div class="rounded-lg border border-[#1f1f1f] p-4 space-y-4 bg-[#0f0f0f]">
<h2 class="font-semibold text-lg text-white">Promos</h2>
<div v-if="props.promos?.data?.length" class="space-y-3">
<div v-for="p in props.promos.data" :key="p.id" class="rounded-md border border-[#1f1f1f] bg-[#141414] p-3 flex flex-col gap-2">
<div class="flex items-center gap-2">
<span class="font-mono text-sm px-2 py-0.5 rounded bg-[#1f1f1f] text-gray-200">{{ p.code }}</span>
<span class="text-xs text-gray-400">Bonus: {{ p.bonus_amount }} · Wager x{{ p.wager_multiplier }}</span>
<span class="ml-auto text-xs" :class="p.is_active ? 'text-emerald-500' : 'text-gray-500'">{{ p.is_active ? 'aktiv' : 'inaktiv' }}</span>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs text-gray-300">
<div>Pro User: {{ p.per_user_limit }}</div>
<div v-if="p.global_limit">Global: {{ p.global_limit }}</div>
<div v-if="p.starts_at">Start: {{ p.starts_at }}</div>
<div v-if="p.ends_at">Ende: {{ p.ends_at }}</div>
</div>
<div class="flex flex-wrap gap-2">
<Button size="sm" variant="secondary" @click="updatePromo(p.id, { is_active: !p.is_active })">{{ p.is_active ? 'Deaktivieren' : 'Aktivieren' }}</Button>
</div>
</div>
</div>
<div v-else class="text-sm text-gray-400">Keine Promos vorhanden.</div>
</div>
</div>
</div>
</UserLayout>
</template>

View File

@@ -0,0 +1,281 @@
<script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3';
import { onMounted, nextTick } from 'vue';
import CasinoAdminLayout from '@/layouts/admin/CasinoAdminLayout.vue';
const props = defineProps<{
settings: {
site_name: string;
site_tagline: string;
primary_color: string;
logo_url: string;
favicon_url: string;
maintenance_mode: boolean;
registration_open: boolean;
min_deposit_usd: number;
max_deposit_usd: number;
min_withdrawal_usd: number;
max_withdrawal_usd: number;
max_bet_usd: number;
house_edge_percent: number;
footer_text: string;
support_email: string;
terms_url: string;
privacy_url: string;
currency_symbol: string;
};
}>();
const form = useForm({ ...props.settings });
function submit() {
form.post('/admin/settings/site', { preserveScroll: true });
}
onMounted(() => {
nextTick(() => { if ((window as any).lucide) (window as any).lucide.createIcons(); });
});
</script>
<template>
<CasinoAdminLayout>
<Head title="Admin Site Settings" />
<template #title>Site Einstellungen</template>
<div class="page-wrap">
<!-- Flash -->
<div v-if="($page.props as any).flash?.success" class="alert-success">
<i data-lucide="check-circle"></i>
{{ ($page.props as any).flash.success }}
</div>
<!-- General -->
<div class="card">
<div class="card-head">
<div>
<h2>Allgemein</h2>
<p class="card-subtitle">Name, Branding und Design.</p>
</div>
</div>
<div class="card-body">
<div class="form-grid">
<div class="field">
<label class="field-label">Site-Name</label>
<input type="text" v-model="form.site_name" class="field-input" maxlength="100">
<div class="field-error" v-if="form.errors.site_name">{{ form.errors.site_name }}</div>
</div>
<div class="field">
<label class="field-label">Slogan</label>
<input type="text" v-model="form.site_tagline" class="field-input" maxlength="200">
</div>
<div class="field">
<label class="field-label">Primärfarbe (Hex)</label>
<div class="color-row">
<input type="color" v-model="form.primary_color" class="color-picker">
<input type="text" v-model="form.primary_color" class="field-input" maxlength="7" placeholder="#df006a">
</div>
</div>
<div class="field">
<label class="field-label">Währungssymbol</label>
<input type="text" v-model="form.currency_symbol" class="field-input" maxlength="10" placeholder="BTX">
</div>
<div class="field full">
<label class="field-label">Logo-URL</label>
<input type="url" v-model="form.logo_url" class="field-input" placeholder="https://...">
</div>
<div class="field full">
<label class="field-label">Favicon-URL</label>
<input type="url" v-model="form.favicon_url" class="field-input" placeholder="https://...">
</div>
</div>
</div>
</div>
<!-- Status Toggles -->
<div class="card">
<div class="card-head">
<div>
<h2>Status</h2>
<p class="card-subtitle">Wartungsmodus und Registrierung.</p>
</div>
</div>
<div class="card-body">
<div class="toggle-list">
<div class="toggle-row">
<div class="toggle-info">
<div class="toggle-label">
<i data-lucide="wrench"></i>
Wartungsmodus
</div>
<div class="toggle-desc">Nur Admins haben Zugang zur Seite.</div>
</div>
<button class="toggle-btn" :class="{ active: form.maintenance_mode }" @click="form.maintenance_mode = !form.maintenance_mode">
<span class="toggle-knob"></span>
</button>
</div>
<div class="toggle-row">
<div class="toggle-info">
<div class="toggle-label">
<i data-lucide="user-plus"></i>
Registrierung offen
</div>
<div class="toggle-desc">Neue Nutzer können sich registrieren.</div>
</div>
<button class="toggle-btn" :class="{ active: form.registration_open }" @click="form.registration_open = !form.registration_open">
<span class="toggle-knob"></span>
</button>
</div>
</div>
</div>
</div>
<!-- Limits -->
<div class="card">
<div class="card-head">
<div>
<h2>Limits & House Edge</h2>
<p class="card-subtitle">Einzahlungs-, Auszahlungs- und Wettlimits sowie den Hausvorteil.</p>
</div>
</div>
<div class="card-body">
<div class="form-grid">
<div class="field">
<label class="field-label">Min. Einzahlung (USD)</label>
<input type="number" step="0.01" min="0" v-model.number="form.min_deposit_usd" class="field-input">
</div>
<div class="field">
<label class="field-label">Max. Einzahlung (USD)</label>
<input type="number" step="0.01" min="0" v-model.number="form.max_deposit_usd" class="field-input">
</div>
<div class="field">
<label class="field-label">Min. Auszahlung (USD)</label>
<input type="number" step="0.01" min="0" v-model.number="form.min_withdrawal_usd" class="field-input">
</div>
<div class="field">
<label class="field-label">Max. Auszahlung (USD)</label>
<input type="number" step="0.01" min="0" v-model.number="form.max_withdrawal_usd" class="field-input">
</div>
<div class="field">
<label class="field-label">Max. Wette (USD)</label>
<input type="number" step="0.01" min="0" v-model.number="form.max_bet_usd" class="field-input">
</div>
<div class="field">
<label class="field-label">House Edge (%)</label>
<input type="number" step="0.01" min="0" max="100" v-model.number="form.house_edge_percent" class="field-input">
</div>
</div>
</div>
</div>
<!-- Contact & Legal -->
<div class="card">
<div class="card-head">
<div>
<h2>Kontakt & Rechtliches</h2>
<p class="card-subtitle">Support-E-Mail, Fußzeilentext und Links zu AGB/Datenschutz.</p>
</div>
</div>
<div class="card-body">
<div class="form-grid">
<div class="field">
<label class="field-label">Support-E-Mail</label>
<input type="email" v-model="form.support_email" class="field-input" placeholder="support@example.com">
</div>
<div class="field">
<label class="field-label">AGB-URL</label>
<input type="text" v-model="form.terms_url" class="field-input" placeholder="/terms">
</div>
<div class="field">
<label class="field-label">Datenschutz-URL</label>
<input type="text" v-model="form.privacy_url" class="field-input" placeholder="/privacy">
</div>
<div class="field full">
<label class="field-label">Fußzeilentext</label>
<textarea v-model="form.footer_text" rows="3" class="field-input" maxlength="1000" placeholder="© 2024 BetiX Casino. Alle Rechte vorbehalten."></textarea>
</div>
</div>
</div>
</div>
<div class="save-bar">
<button class="btn-primary" @click="submit" :disabled="form.processing">
<i data-lucide="save"></i>
{{ form.processing ? 'Wird gespeichert...' : 'Alle Einstellungen speichern' }}
</button>
</div>
</div>
</CasinoAdminLayout>
</template>
<style scoped>
.page-wrap { max-width: 900px; margin: 0 auto; display: flex; flex-direction: column; gap: 20px; }
.alert-success {
background: rgba(0, 200, 100, 0.1); border: 1px solid rgba(0, 200, 100, 0.3);
color: #00c864; padding: 12px 16px; border-radius: 10px;
display: flex; align-items: center; gap: 10px; font-weight: 600;
}
.alert-success i { width: 18px; height: 18px; flex-shrink: 0; }
.card { background: #0f0f10; border: 1px solid #18181b; border-radius: 14px; overflow: hidden; }
.card-head { padding: 20px 24px; border-bottom: 1px solid #18181b; }
.card-head h2 { font-size: 16px; font-weight: 700; color: #fff; margin: 0 0 2px; }
.card-subtitle { color: #71717a; font-size: 12px; margin: 0; }
.card-body { padding: 20px 24px; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.field.full { grid-column: 1 / -1; }
.field-label { display: block; font-size: 11px; font-weight: 700; color: #71717a; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px; }
.field-input {
width: 100%; background: #18181b; border: 1px solid #27272a; border-radius: 8px;
color: #e4e4e7; padding: 9px 12px; font-size: 13px; font-family: inherit;
transition: border-color 0.15s; box-sizing: border-box;
}
.field-input:focus { outline: none; border-color: #df006a; }
textarea.field-input { resize: vertical; }
.field-error { color: #ff3e3e; font-size: 11px; margin-top: 4px; }
/* Color picker */
.color-row { display: flex; gap: 8px; align-items: center; }
.color-picker { width: 44px; height: 38px; padding: 2px; border: 1px solid #27272a; border-radius: 8px; background: #18181b; cursor: pointer; }
.color-row .field-input { flex: 1; }
/* Toggles */
.toggle-list { display: flex; flex-direction: column; gap: 0; }
.toggle-row {
display: flex; align-items: center; justify-content: space-between; gap: 20px;
padding: 14px 0; border-bottom: 1px solid #18181b;
}
.toggle-row:last-child { border-bottom: none; }
.toggle-info { display: flex; flex-direction: column; gap: 2px; }
.toggle-label { display: flex; align-items: center; gap: 8px; font-weight: 600; color: #e4e4e7; font-size: 14px; }
.toggle-label i { width: 16px; height: 16px; color: #71717a; }
.toggle-desc { color: #71717a; font-size: 12px; padding-left: 24px; }
.toggle-btn {
width: 48px; height: 26px; border-radius: 99px; background: #27272a; border: none; cursor: pointer;
position: relative; transition: background 0.2s; flex-shrink: 0;
}
.toggle-btn.active { background: #df006a; }
.toggle-knob {
position: absolute; top: 3px; left: 3px; width: 20px; height: 20px;
background: #fff; border-radius: 50%; transition: transform 0.2s;
}
.toggle-btn.active .toggle-knob { transform: translateX(22px); }
/* Save bar */
.save-bar { display: flex; justify-content: flex-end; padding: 4px 0; }
.btn-primary {
display: inline-flex; align-items: center; gap: 8px;
background: #df006a; border: none; color: #fff; padding: 11px 22px;
border-radius: 8px; font-weight: 700; font-size: 13px; cursor: pointer; transition: all 0.15s;
}
.btn-primary:hover { background: #b8005a; }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary i { width: 16px; height: 16px; }
@media (max-width: 640px) {
.form-grid { grid-template-columns: 1fr; }
.field.full { grid-column: 1; }
}
</style>

View File

@@ -0,0 +1,632 @@
<script setup lang="ts">
import { Head, router } from '@inertiajs/vue3';
import { ref, computed, onMounted, nextTick } from 'vue';
import UserLayout from '@/layouts/admin/CasinoAdminLayout.vue';
import { usePage } from '@inertiajs/vue3';
const props = defineProps<{ enabled: boolean; threads: any[]; ollama?: { host: string; model: string; healthy: boolean; error?: string | null } }>();
const page = usePage();
const user = computed(() => (page.props.auth as any).user || {});
const enabled = ref<boolean>(props.enabled);
function saveEnable() {
router.post('/admin/support/settings', { enabled: enabled.value }, { preserveScroll: true });
}
function sendReply(tid: string, textRef: any) {
const text = (textRef.value || '').trim();
if (!text) return;
router.post(`/admin/support/threads/${tid}/message`, { text }, { preserveScroll: true, onFinish: () => { textRef.value = ''; } });
}
function formatTime(isoString: string) {
return new Date(isoString).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
const statusMap = {
new: { text: 'NEU', color: '#888', glow: 'rgba(136,136,136,0.4)' },
ai: { text: 'KI AKTIV', color: '#00f2ff', glow: 'rgba(0,242,255,0.4)' },
stopped: { text: 'KI GESTOPPT', color: '#ffd700', glow: 'rgba(255,215,0,0.4)' },
handoff: { text: 'ÜBERGABE', color: '#ff9f43', glow: 'rgba(255,159,67,0.4)' },
agent: { text: 'AGENT', color: '#ff007a', glow: 'rgba(255,0,122,0.4)' },
closed: { text: 'GESCHLOSSEN', color: '#ff3e3e', glow: 'rgba(255,62,62,0.4)' },
};
const getStatus = (status: string) => statusMap[status as keyof typeof statusMap] || statusMap.new;
const getAvatarFallback = (name: string, background: string = 'random', color: string = 'fff') => {
const cleanName = name ? name.replace(/\s/g, '+') : 'User';
return `https://ui-avatars.com/api/?name=${cleanName}&background=${background}&color=${color}`;
};
// Helper to find the correct avatar property
const getUserAvatar = (u: any) => {
if (!u) return null;
// Try multiple common property names
return u.avatar || u.avatar_url || u.profile_photo_url || null;
};
onMounted(() => {
console.log('Support Threads Data:', props.threads); // Debugging output
nextTick(() => { if ((window as any).lucide) (window as any).lucide.createIcons(); });
});
</script>
<template>
<UserLayout>
<Head title="Admin · Support" />
<div class="admin-support-container">
<!-- Header Section -->
<div class="page-header">
<div class="header-content">
<h1 class="page-title">SUPPORT <span class="highlight">DASHBOARD</span></h1>
<p class="page-subtitle">VERWALTUNG & LIVE-CHAT ÜBERSICHT</p>
</div>
<div class="header-actions">
<!-- Global Toggle -->
<div class="status-pill" @click="enabled = !enabled; saveEnable();">
<div class="sp-label">CHAT SYSTEM</div>
<div class="sp-indicator" :class="{ active: enabled }">
<div class="sp-dot"></div>
<span>{{ enabled ? 'AKTIV' : 'INAKTIV' }}</span>
</div>
</div>
</div>
</div>
<!-- Stats / Info Grid -->
<div class="stats-grid">
<!-- Ollama Card -->
<div class="stat-card">
<div class="sc-icon">
<i data-lucide="bot"></i>
</div>
<div class="sc-info">
<div class="sc-label">KI STATUS (OLLAMA)</div>
<div class="sc-value" :class="props.ollama?.healthy ? 'text-cyan' : 'text-red'">
{{ props.ollama?.healthy ? 'ONLINE' : 'OFFLINE' }}
</div>
<div class="sc-sub">{{ props.ollama?.model || 'Kein Modell' }}</div>
</div>
<div class="sc-glow" :class="props.ollama?.healthy ? 'glow-cyan' : 'glow-red'"></div>
</div>
<!-- Active Threads Card -->
<div class="stat-card">
<div class="sc-icon">
<i data-lucide="message-square"></i>
</div>
<div class="sc-info">
<div class="sc-label">AKTIVE CHATS</div>
<div class="sc-value text-magenta">{{ props.threads?.length || 0 }}</div>
<div class="sc-sub">OFFENE ANFRAGEN</div>
</div>
<div class="sc-glow glow-magenta"></div>
</div>
</div>
<!-- Threads List -->
<div class="threads-section">
<h2 class="section-title"><i data-lucide="layers"></i> LAUFENDE UNTERHALTUNGEN</h2>
<div v-if="!props.threads || props.threads.length === 0" class="empty-state">
<div class="es-icon"><i data-lucide="inbox"></i></div>
<h3>Keine aktiven Support-Anfragen</h3>
<p>Alles ruhig! Warte auf neue Nachrichten von Benutzern.</p>
</div>
<div class="threads-grid">
<div v-for="t in props.threads" :key="t.id" class="thread-card" :class="{ 'handoff-mode': t.status === 'handoff' }">
<!-- Thread Header -->
<div class="tc-header">
<div class="user-profile">
<div class="up-avatar">
<img
:src="getUserAvatar(t.user) || getAvatarFallback(t.user?.username)"
:alt="t.user?.username"
:onerror="`this.onerror=null; this.src='${getAvatarFallback(t.user?.username)}'`"
/>
</div>
<div class="up-info">
<div class="up-name">{{ t.user?.username }}</div>
<div class="up-id">ID: {{ t.user?.id }} · {{ t.user?.email }}</div>
</div>
</div>
<div class="tc-status" :style="{ color: getStatus(t.status).color, borderColor: getStatus(t.status).color, boxShadow: `0 0 10px ${getStatus(t.status).glow}` }">
{{ getStatus(t.status).text }}
</div>
</div>
<!-- Topic Badge -->
<div class="tc-topic" v-if="t.topic">
<i data-lucide="hash"></i> {{ t.topic }}
</div>
<!-- Chat Area -->
<div class="tc-chat-window">
<div v-for="m in t.messages" :key="m.id" class="chat-row" :class="m.sender === 'agent' ? 'right' : 'left'">
<!-- Avatar Left (User/AI) -->
<div class="chat-avatar" v-if="m.sender !== 'agent' && m.sender !== 'system'">
<img v-if="m.sender === 'user'"
:src="getUserAvatar(t.user) || getAvatarFallback(t.user?.username)"
:onerror="`this.onerror=null; this.src='${getAvatarFallback(t.user?.username)}'`"
/>
<img v-else-if="m.sender === 'ai'" :src="getAvatarFallback('AI', '00f2ff', '000')" />
</div>
<div class="msg-bubble" :class="m.sender">
<div class="msg-text">{{ m.body }}</div>
<div class="msg-meta">
<span>{{ formatTime(m.at) }}</span>
</div>
</div>
<!-- Avatar Right (Agent) -->
<div class="chat-avatar" v-if="m.sender === 'agent'">
<img
:src="getUserAvatar(user) || getAvatarFallback(user.name, 'ff007a')"
:onerror="`this.onerror=null; this.src='${getAvatarFallback(user.name, 'ff007a')}'`"
/>
</div>
</div>
</div>
<!-- Actions -->
<div class="tc-actions">
<div class="input-group">
<input
type="text"
:ref="el => t._ref = el"
:disabled="t.status==='closed'"
placeholder="Antwort schreiben..."
@keydown.enter="sendReply(t.id, t._ref)"
/>
<button class="btn-send" :disabled="t.status==='closed'" @click="sendReply(t.id, t._ref)">
<i data-lucide="send"></i>
</button>
</div>
<button class="btn-close" @click="router.post(`/admin/support/threads/${t.id}/close`, {}, { preserveScroll: true })" title="Chat schließen">
<i data-lucide="x-circle"></i>
</button>
</div>
</div>
</div>
</div>
</div>
</UserLayout>
</template>
<style scoped>
/* --- Variables & Base --- */
.admin-support-container {
padding: 30px;
max-width: 1400px;
margin: 0 auto;
color: #fff;
font-family: 'Inter', sans-serif;
}
/* --- Header --- */
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-bottom: 40px;
border-bottom: 1px solid #1f1f1f;
padding-bottom: 20px;
}
.page-title {
font-size: 32px;
font-weight: 900;
letter-spacing: -1px;
margin: 0;
line-height: 1.1;
}
.highlight {
color: #ff007a;
text-shadow: 0 0 20px rgba(255, 0, 122, 0.4);
}
.page-subtitle {
color: #888;
font-size: 12px;
font-weight: 700;
letter-spacing: 1px;
margin-top: 5px;
}
/* --- Status Pill (Toggle) --- */
.status-pill {
background: #0f0f0f;
border: 1px solid #222;
border-radius: 12px;
padding: 6px 8px 6px 16px;
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
transition: 0.3s;
}
.status-pill:hover {
border-color: #333;
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(0,0,0,0.3);
}
.sp-label {
font-size: 11px;
font-weight: 800;
color: #666;
}
.sp-indicator {
background: #1a1a1a;
padding: 6px 12px;
border-radius: 8px;
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
font-weight: 800;
color: #555;
transition: 0.3s;
}
.sp-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #444;
transition: 0.3s;
}
.sp-indicator.active {
background: rgba(0, 242, 255, 0.1);
color: #00f2ff;
}
.sp-indicator.active .sp-dot {
background: #00f2ff;
box-shadow: 0 0 8px #00f2ff;
}
/* --- Stats Grid --- */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
margin-bottom: 40px;
}
.stat-card {
background: #0f0f0f;
border: 1px solid #1f1f1f;
border-radius: 16px;
padding: 20px;
display: flex;
align-items: center;
gap: 20px;
position: relative;
overflow: hidden;
transition: 0.3s;
}
.stat-card:hover {
transform: translateY(-3px);
border-color: #333;
}
.sc-icon {
width: 50px;
height: 50px;
background: #141414;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: #666;
z-index: 2;
}
.sc-icon i { width: 24px; height: 24px; }
.sc-info { z-index: 2; }
.sc-label { font-size: 10px; font-weight: 800; color: #555; letter-spacing: 1px; margin-bottom: 4px; }
.sc-value { font-size: 20px; font-weight: 900; letter-spacing: -0.5px; }
.sc-sub { font-size: 11px; color: #666; margin-top: 2px; font-family: monospace; }
.text-cyan { color: #00f2ff; text-shadow: 0 0 10px rgba(0,242,255,0.3); }
.text-red { color: #ff3e3e; text-shadow: 0 0 10px rgba(255,62,62,0.3); }
.text-magenta { color: #ff007a; text-shadow: 0 0 10px rgba(255,0,122,0.3); }
.sc-glow {
position: absolute;
top: -50%;
right: -20%;
width: 200px;
height: 200px;
border-radius: 50%;
filter: blur(60px);
opacity: 0.15;
z-index: 1;
}
.glow-cyan { background: #00f2ff; }
.glow-red { background: #ff3e3e; }
.glow-magenta { background: #ff007a; }
/* --- Threads Section --- */
.section-title {
font-size: 14px;
font-weight: 800;
color: #888;
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 10px;
letter-spacing: 1px;
}
.section-title i { width: 16px; }
.empty-state {
background: #0f0f0f;
border: 1px solid #1f1f1f;
border-radius: 16px;
padding: 60px;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
}
.es-icon {
width: 60px;
height: 60px;
background: #141414;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #333;
margin-bottom: 20px;
}
.es-icon i { width: 28px; height: 28px; }
.empty-state h3 { font-size: 18px; font-weight: 700; margin: 0 0 8px 0; color: #fff; }
.empty-state p { font-size: 13px; color: #666; margin: 0; }
.threads-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 20px;
}
/* --- Thread Card --- */
.thread-card {
background: #0f0f0f;
border: 1px solid #1f1f1f;
border-radius: 16px;
overflow: hidden;
display: flex;
flex-direction: column;
transition: 0.3s;
height: 600px; /* Taller for better chat view */
}
.thread-card:hover {
border-color: #333;
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
}
.thread-card.handoff-mode {
border-color: #ff9f43;
box-shadow: 0 0 20px rgba(255, 159, 67, 0.15);
}
.tc-header {
padding: 16px;
background: #141414;
border-bottom: 1px solid #1f1f1f;
display: flex;
justify-content: space-between;
align-items: center;
}
.user-profile { display: flex; align-items: center; gap: 12px; }
.up-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
overflow: hidden;
border: 2px solid #222;
}
.up-avatar img { width: 100%; height: 100%; object-fit: cover; }
.up-info { display: flex; flex-direction: column; }
.up-name { font-size: 14px; font-weight: 800; color: #fff; }
.up-id { font-size: 10px; color: #666; font-family: monospace; }
.tc-status {
font-size: 9px;
font-weight: 900;
padding: 4px 8px;
border-radius: 6px;
border: 1px solid;
background: rgba(0,0,0,0.2);
}
.tc-topic {
padding: 8px 16px;
background: #111;
border-bottom: 1px solid #1f1f1f;
font-size: 11px;
color: #888;
display: flex;
align-items: center;
gap: 6px;
}
.tc-topic i { width: 12px; }
/* --- Chat Window --- */
.tc-chat-window {
flex: 1;
background: #0a0a0a;
padding: 16px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 16px;
}
.chat-row {
display: flex;
gap: 10px;
align-items: flex-end;
max-width: 85%;
}
.chat-row.left { align-self: flex-start; }
.chat-row.right { align-self: flex-end; flex-direction: row-reverse; }
.chat-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
border: 1px solid #333;
}
.chat-avatar img { width: 100%; height: 100%; object-fit: cover; }
.msg-bubble {
padding: 12px 16px;
border-radius: 16px;
font-size: 13px;
line-height: 1.5;
position: relative;
word-break: break-word;
}
/* User Bubble */
.user {
background: #1a1a1a;
color: #ddd;
border-bottom-left-radius: 2px;
border: 1px solid #222;
}
/* Agent Bubble */
.agent {
background: rgba(255, 0, 122, 0.1);
color: #fff;
border-bottom-right-radius: 2px;
border: 1px solid rgba(255, 0, 122, 0.3);
}
/* AI Bubble */
.ai {
background: rgba(0, 242, 255, 0.05);
color: #00f2ff;
border-bottom-left-radius: 2px;
border: 1px solid rgba(0, 242, 255, 0.2);
}
/* System Bubble */
.system {
background: transparent;
color: #666;
font-size: 11px;
font-style: italic;
padding: 4px;
border: none;
text-align: center;
width: 100%;
}
.chat-row:has(.system) { max-width: 100%; align-self: center; }
.msg-meta {
display: flex;
justify-content: flex-end;
margin-top: 8px;
font-size: 9px;
font-weight: 700;
opacity: 0.5;
gap: 6px;
}
/* --- Actions --- */
.tc-actions {
padding: 12px;
background: #141414;
border-top: 1px solid #1f1f1f;
display: flex;
gap: 10px;
}
.input-group {
flex: 1;
display: flex;
background: #0a0a0a;
border: 1px solid #222;
border-radius: 10px;
padding: 4px;
transition: 0.2s;
}
.input-group:focus-within {
border-color: #ff007a;
box-shadow: 0 0 10px rgba(255,0,122,0.1);
}
.input-group input {
flex: 1;
background: transparent;
border: none;
color: #fff;
padding: 0 10px;
font-size: 13px;
}
.btn-send {
width: 32px;
height: 32px;
background: #ff007a;
border: none;
border-radius: 8px;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: 0.2s;
}
.btn-send:hover { background: #d40065; }
.btn-send:disabled { background: #333; cursor: not-allowed; }
.btn-send i { width: 16px; }
.btn-close {
width: 42px;
background: rgba(255, 62, 62, 0.1);
border: 1px solid rgba(255, 62, 62, 0.2);
border-radius: 10px;
color: #ff3e3e;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: 0.2s;
}
.btn-close:hover {
background: rgba(255, 62, 62, 0.2);
border-color: #ff3e3e;
}
.btn-close i { width: 20px; }
/* Scrollbar for chat */
.tc-chat-window::-webkit-scrollbar { width: 4px; }
.tc-chat-window::-webkit-scrollbar-track { background: transparent; }
.tc-chat-window::-webkit-scrollbar-thumb { background: #333; border-radius: 2px; }
@media (max-width: 768px) {
.page-header { flex-direction: column; align-items: flex-start; gap: 20px; }
.threads-grid { grid-template-columns: 1fr; }
}
</style>

View File

@@ -0,0 +1,420 @@
<script setup lang="ts">
import { Head, Link, useForm } from '@inertiajs/vue3';
import { ref } from 'vue';
import CasinoAdminLayout from '@/layouts/admin/CasinoAdminLayout.vue';
const props = defineProps<{
user: any;
restrictions: any[];
wallets: any[];
vaultTransfers: any[];
kycDocuments: any[];
}>();
const activeTab = ref('overview');
const form = useForm({
username: props.user.username,
email: props.user.email,
first_name: props.user.first_name || '',
last_name: props.user.last_name || '',
birthdate: props.user.birthdate || '',
gender: props.user.gender || '',
phone: props.user.phone || '',
country: props.user.country || '',
address_line1: props.user.address_line1 || '',
address_line2: props.user.address_line2 || '',
city: props.user.city || '',
postal_code: props.user.postal_code || '',
currency: props.user.currency || '',
role: props.user.role || 'User',
vip_level: props.user.vip_level,
balance: props.user.balance,
vault_balance: props.user.vault_balance,
is_banned: props.user.is_banned,
is_chat_banned: props.user.is_chat_banned,
ban_reason: '',
ban_ends_at: '',
chat_ban_ends_at: '',
});
const saveUser = () => {
form.post(`/admin/users/${props.user.id}`, {
preserveScroll: true,
onSuccess: () => {
// Optional: Show toast
}
});
};
const formatDate = (dateString: string) => {
if (!dateString) return 'N/A';
return new Date(dateString).toLocaleString();
};
</script>
<template>
<CasinoAdminLayout>
<Head :title="`User: ${user.username}`" />
<template #title>
<div class="flex items-center gap-3">
<div class="avatar large">{{ user.username.charAt(0).toUpperCase() }}</div>
<div>
<div>{{ user.username }} <span class="badge text-xs ml-2" :class="user.is_banned ? 'bg-red-500 text-white' : 'bg-gray-800 text-gray-400'">{{ user.is_banned ? 'BANNED' : user.role }}</span></div>
<div class="text-sm text-gray-500 font-normal mt-1">{{ user.email }} ID: {{ user.id }}</div>
</div>
</div>
</template>
<template #actions>
<Link href="/admin/users" class="btn-ghost">
<i data-lucide="arrow-left"></i> Back to Users
</Link>
</template>
<div class="user-layout">
<!-- Sidebar Navigation -->
<aside class="user-sidebar">
<div class="panel">
<nav class="side-nav">
<button :class="{active: activeTab === 'overview'}" @click="activeTab = 'overview'">
<i data-lucide="user"></i> Overview
</button>
<button :class="{active: activeTab === 'financials'}" @click="activeTab = 'financials'">
<i data-lucide="wallet"></i> Financials & Wallets
</button>
<button :class="{active: activeTab === 'security'}" @click="activeTab = 'security'">
<i data-lucide="shield-alert"></i> Security & Bans
</button>
<button :class="{active: activeTab === 'deposits'}" @click="activeTab = 'deposits'">
<i data-lucide="coins"></i> Deposits
</button>
<button :class="{active: activeTab === 'kyc'}" @click="activeTab = 'kyc'">
<i data-lucide="file-check"></i> KYC Documents
</button>
</nav>
</div>
<div class="panel mt-4 p-4">
<h4 class="text-xs font-bold text-gray-500 uppercase mb-3">Quick Stats</h4>
<div class="space-y-3 text-sm">
<div class="flex justify-between">
<span class="text-gray-400">Joined</span>
<span class="text-white">{{ new Date(user.created_at).toLocaleDateString() }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-400">Last Login</span>
<span class="text-white">{{ formatDate(user.last_login_at) }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-400">VIP Level</span>
<span class="text-white">{{ user.vip_level }}</span>
</div>
</div>
</div>
</aside>
<!-- Main Content Area -->
<main class="user-content">
<!-- Overview Tab -->
<div v-show="activeTab === 'overview'" class="panel p-6">
<h3 class="section-title">General Information</h3>
<div class="form-grid">
<div class="form-group">
<label>Username</label>
<input type="text" v-model="form.username" class="input-field" />
</div>
<div class="form-group">
<label>Email Address</label>
<input type="email" v-model="form.email" class="input-field" />
</div>
<div class="form-group">
<label>First Name</label>
<input type="text" v-model="form.first_name" class="input-field" />
</div>
<div class="form-group">
<label>Last Name</label>
<input type="text" v-model="form.last_name" class="input-field" />
</div>
<div class="form-group">
<label>Birthdate</label>
<input type="date" v-model="form.birthdate" class="input-field" />
</div>
<div class="form-group">
<label>Gender</label>
<select v-model="form.gender" class="input-field">
<option value=""></option>
<option value="male">Male</option>
<option value="female">Female</option>
<option value="other">Other</option>
</select>
</div>
<div class="form-group">
<label>Phone</label>
<input type="text" v-model="form.phone" class="input-field" />
</div>
<div class="form-group">
<label>Country</label>
<input type="text" v-model="form.country" class="input-field" />
</div>
<div class="form-group">
<label>Address line 1</label>
<input type="text" v-model="form.address_line1" class="input-field" />
</div>
<div class="form-group">
<label>Address line 2</label>
<input type="text" v-model="form.address_line2" class="input-field" />
</div>
<div class="form-group">
<label>City</label>
<input type="text" v-model="form.city" class="input-field" />
</div>
<div class="form-group">
<label>Postal Code</label>
<input type="text" v-model="form.postal_code" class="input-field" />
</div>
<div class="form-group">
<label>Currency</label>
<input type="text" v-model="form.currency" class="input-field" />
</div>
<div class="form-group">
<label>Role</label>
<select v-model="form.role" class="input-field">
<option value="Admin">Admin</option>
<option value="Moderator">Moderator</option>
<option value="User">User</option>
</select>
</div>
</div>
<div class="mt-6 flex justify-end">
<button class="btn-primary" @click="saveUser" :disabled="form.processing">Save Changes</button>
</div>
</div>
<!-- Financials Tab -->
<div v-show="activeTab === 'financials'" class="space-y-6">
<div class="panel p-6">
<h3 class="section-title">Balances</h3>
<div class="grid grid-cols-2 gap-4">
<div class="form-group">
<label>Main Balance ($)</label>
<input type="text" v-model="form.balance" class="input-field font-mono text-lg" />
</div>
<div class="form-group">
<label>Vault Balance ($)</label>
<input type="text" v-model="form.vault_balance" class="input-field font-mono text-lg" />
</div>
</div>
<div class="mt-4 flex justify-end">
<button class="btn-primary" @click="saveUser" :disabled="form.processing">Update Balances</button>
</div>
</div>
<div class="panel">
<div class="p-6 border-b border-[#1f1f22]">
<h3 class="section-title m-0">Vault History</h3>
</div>
<table class="admin-table">
<thead>
<tr>
<th>Date</th>
<th>Type</th>
<th class="text-right">Amount</th>
</tr>
</thead>
<tbody>
<tr v-for="t in vaultTransfers" :key="t.id">
<td class="text-gray-400">{{ formatDate(t.created_at) }}</td>
<td><span class="badge bg-gray-800">{{ t.type }}</span></td>
<td class="text-right font-mono font-bold" :class="t.amount > 0 ? 'text-green-400' : 'text-red-400'">
{{ t.amount > 0 ? '+' : '' }}{{ t.amount }}
</td>
</tr>
<tr v-if="!vaultTransfers?.length">
<td colspan="3" class="text-center py-6 text-gray-500">No vault transfers found.</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Security & Bans Tab -->
<div v-show="activeTab === 'security'" class="space-y-6">
<div class="panel p-6 border border-orange-900/30 bg-orange-900/5">
<div class="flex justify-between items-start mb-4">
<div>
<h3 class="text-orange-400 font-bold text-lg mb-1 flex items-center gap-2">
<i data-lucide="message-circle-off"></i> Chat Ban
</h3>
<p class="text-sm text-gray-400">Mute this user from sending live chat messages.</p>
</div>
<label class="toggle-switch">
<input type="checkbox" v-model="form.is_chat_banned" />
<span class="slider"></span>
</label>
</div>
<div v-if="form.is_chat_banned" class="space-y-4 pt-4 border-t border-orange-900/20">
<div class="form-group">
<label class="text-orange-400">Chat Ban Expiration</label>
<input type="datetime-local" v-model="form.chat_ban_ends_at" class="input-field border-orange-900/50 focus:border-orange-500" />
</div>
</div>
<div class="mt-4 flex justify-end">
<button class="bg-orange-600 hover:bg-orange-500 text-white px-4 py-2 rounded-lg font-bold transition" @click="saveUser">Apply Chat Ban</button>
</div>
</div>
<div class="panel p-6 border border-red-900/30 bg-red-900/5">
<div class="flex justify-between items-start mb-4">
<div>
<h3 class="text-red-500 font-bold text-lg mb-1 flex items-center gap-2">
<i data-lucide="shield-alert"></i> Account Ban
</h3>
<p class="text-sm text-gray-400">Completely lock this user out of the platform.</p>
</div>
<label class="toggle-switch">
<input type="checkbox" v-model="form.is_banned" />
<span class="slider"></span>
</label>
</div>
<div v-if="form.is_banned" class="space-y-4 pt-4 border-t border-red-900/20">
<div class="form-group">
<label class="text-red-400">Ban Reason (Shown to user)</label>
<input type="text" v-model="form.ban_reason" class="input-field border-red-900/50 focus:border-red-500" placeholder="e.g. Fraudulent Activity" />
</div>
<div class="form-group">
<label class="text-red-400">Ban Expiration (Leave empty for permanent)</label>
<input type="datetime-local" v-model="form.ban_ends_at" class="input-field border-red-900/50 focus:border-red-500" />
</div>
</div>
<div class="mt-4 flex justify-end">
<button class="bg-red-600 hover:bg-red-500 text-white px-4 py-2 rounded-lg font-bold transition" @click="saveUser">Apply Security Settings</button>
</div>
</div>
<div class="panel p-6">
<h3 class="section-title">Active Restrictions Log</h3>
<div class="space-y-3">
<div v-for="res in restrictions" :key="res.id" class="p-3 bg-[#161618] rounded-lg border border-[#27272a] flex justify-between items-center">
<div>
<div class="font-bold text-white">{{ res.type }}</div>
<div class="text-xs text-gray-400">{{ res.reason || 'No reason provided' }}</div>
</div>
<div class="text-right">
<div class="text-xs" :class="res.active ? 'text-red-400' : 'text-gray-500'">{{ res.active ? 'ACTIVE' : 'EXPIRED' }}</div>
<div class="text-xs text-gray-500">{{ formatDate(res.created_at) }}</div>
</div>
</div>
<div v-if="!restrictions?.length" class="text-gray-500 text-sm">No restrictions on record.</div>
</div>
</div>
</div>
<!-- Deposits Tab -->
<div v-show="activeTab === 'deposits'" class="panel">
<div class="p-6 border-b border-[#1f1f22]">
<h3 class="section-title m-0">Crypto Deposits</h3>
</div>
<table class="admin-table">
<thead>
<tr>
<th>Created</th>
<th>Status</th>
<th>Currency</th>
<th class="text-right">Amount</th>
</tr>
</thead>
<tbody>
<tr v-for="d in (props as any).deposits" :key="d.id">
<td class="text-gray-400">{{ formatDate(d.created_at) }}</td>
<td><span class="badge" :class="{'bg-green-600 text-white': d.status==='finished', 'bg-yellow-600 text-white': d.status==='waiting'}">{{ d.status }}</span></td>
<td class="text-gray-300">{{ d.pay_currency }}</td>
<td class="text-right font-mono">{{ d.price_amount }}</td>
</tr>
<tr v-if="!(props as any).deposits?.length">
<td colspan="4" class="text-center py-6 text-gray-500">No deposits yet.</td>
</tr>
</tbody>
</table>
</div>
<!-- KYC Tab -->
<div v-show="activeTab === 'kyc'" class="panel p-6">
<h3 class="section-title">KYC Documents</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div v-for="doc in kycDocuments" :key="doc.id" class="border border-[#27272a] rounded-xl p-4 bg-[#161618]">
<div class="flex justify-between items-start mb-3">
<div>
<div class="font-bold text-white">{{ doc.document_type }}</div>
<div class="text-xs text-gray-400">Uploaded: {{ formatDate(doc.created_at) }}</div>
</div>
<span class="badge" :class="{'bg-green-500': doc.status === 'approved', 'bg-yellow-500': doc.status === 'pending', 'bg-red-500': doc.status === 'rejected'}">{{ doc.status }}</span>
</div>
<div class="aspect-video bg-black rounded-lg flex items-center justify-center overflow-hidden border border-[#27272a]">
<img v-if="doc.file_url" :src="doc.file_url" class="w-full h-full object-cover" />
<i v-else data-lucide="file-image" class="w-10 h-10 text-gray-600"></i>
</div>
<div class="mt-4 flex gap-2">
<button class="flex-1 bg-green-600 hover:bg-green-500 text-white py-2 rounded-lg font-bold text-sm transition">Approve</button>
<button class="flex-1 bg-red-600 hover:bg-red-500 text-white py-2 rounded-lg font-bold text-sm transition">Reject</button>
</div>
</div>
<div v-if="!kycDocuments?.length" class="col-span-full text-center py-10 text-gray-500">
No KYC documents submitted yet.
</div>
</div>
</div>
</main>
</div>
</CasinoAdminLayout>
</template>
<style scoped>
.user-layout { display: grid; grid-template-columns: 240px 1fr; gap: 24px; align-items: start; }
@media (max-width: 900px) { .user-layout { grid-template-columns: 1fr; } }
.panel { background: #111113; border: 1px solid #1f1f22; border-radius: 16px; overflow: hidden; }
.side-nav { display: flex; flex-direction: column; padding: 12px; gap: 4px; }
.side-nav button { display: flex; align-items: center; gap: 10px; padding: 12px 16px; border-radius: 10px; background: transparent; border: none; color: #a1a1aa; font-weight: 600; font-size: 14px; cursor: pointer; transition: 0.2s; text-align: left; }
.side-nav button i { width: 18px; height: 18px; }
.side-nav button:hover { background: #1a1a1d; color: #fff; }
.side-nav button.active { background: #27272a; color: #fff; }
.section-title { font-size: 16px; font-weight: 800; color: #fff; margin-bottom: 20px; border-bottom: 1px solid #1f1f22; padding-bottom: 12px; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
.form-group { display: flex; flex-direction: column; gap: 6px; }
.form-group label { font-size: 12px; font-weight: 700; color: #a1a1aa; text-transform: uppercase; letter-spacing: 0.5px; }
.input-field { background: #09090b; border: 1px solid #27272a; border-radius: 10px; padding: 12px 16px; color: #fff; outline: none; font-size: 14px; transition: border-color 0.2s; }
.input-field:focus { border-color: #3b82f6; }
.input-field.disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary { background: #3b82f6; color: white; border: none; padding: 10px 20px; border-radius: 8px; font-weight: 700; cursor: pointer; transition: 0.2s; }
.btn-primary:hover:not(:disabled) { background: #2563eb; }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-ghost { background: transparent; border: none; color: #a1a1aa; font-weight: 600; font-size: 14px; cursor: pointer; display: flex; align-items: center; gap: 6px; text-decoration: none; }
.btn-ghost:hover { color: #fff; }
.btn-ghost i { width: 16px; height: 16px; }
.avatar { background: #27272a; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: 800; color: #fff; }
.avatar.large { width: 48px; height: 48px; font-size: 20px; background: rgba(59, 130, 246, 0.2); color: #3b82f6; border: 1px solid rgba(59, 130, 246, 0.3); }
.badge { padding: 4px 8px; border-radius: 6px; font-weight: 800; }
.admin-table { width: 100%; border-collapse: collapse; text-align: left; }
.admin-table th { padding: 12px 24px; font-size: 12px; font-weight: 600; color: #a1a1aa; text-transform: uppercase; background: #0c0c0e; border-bottom: 1px solid #1f1f22; }
.admin-table td { padding: 16px 24px; font-size: 14px; border-bottom: 1px solid #1f1f22; color: #e4e4e7; }
.admin-table tr:last-child td { border-bottom: none; }
/* Toggle Switch */
.toggle-switch { position: relative; display: inline-block; width: 44px; height: 24px; }
.toggle-switch input { opacity: 0; width: 0; height: 0; }
.slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #27272a; transition: .4s; border-radius: 34px; }
.slider:before { position: absolute; content: ""; height: 18px; width: 18px; left: 3px; bottom: 3px; background-color: white; transition: .4s; border-radius: 50%; }
input:checked + .slider { background-color: #ef4444; }
input:checked + .slider:before { transform: translateX(20px); }
</style>

View File

@@ -0,0 +1,142 @@
<script setup lang="ts">
import { Head, Link, router } from '@inertiajs/vue3';
import { ref } from 'vue';
import CasinoAdminLayout from '@/layouts/admin/CasinoAdminLayout.vue';
const props = defineProps<{
users: any;
roles: string[];
filters: { search: string, role: string };
}>();
const search = ref(props.filters?.search || '');
const filterRole = ref(props.filters?.role || '');
const searchUsers = () => {
router.get(route('admin.users.index'), {
search: search.value,
role: filterRole.value
}, {
preserveState: true,
replace: true
});
};
</script>
<template>
<CasinoAdminLayout>
<Head title="Admin Users" />
<template #title>
User Management
</template>
<template #actions>
<button class="btn-primary">
<i data-lucide="user-plus"></i> Add User
</button>
</template>
<div class="filters-bar">
<div class="search-input">
<i data-lucide="search"></i>
<input type="text" v-model="search" placeholder="Search by ID, Username, Email..." @keyup.enter="searchUsers" />
</div>
<select v-model="filterRole" @change="searchUsers" class="filter-select">
<option value="">All Roles</option>
<option v-for="r in roles" :key="r" :value="r">{{ r }}</option>
</select>
</div>
<div class="table-container">
<table class="admin-table">
<thead>
<tr>
<th>ID</th>
<th>User</th>
<th>Email</th>
<th>Role</th>
<th>Balance</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="user in users.data" :key="user.id">
<td class="font-mono text-muted">{{ user.id }}</td>
<td class="font-bold text-white flex items-center gap-3">
<div class="avatar">{{ user.username.charAt(0).toUpperCase() }}</div>
{{ user.username }}
</td>
<td class="text-muted">{{ user.email }}</td>
<td>
<span class="badge" :class="{'bg-red': user.role === 'Admin', 'bg-blue': user.role === 'Moderator', 'bg-gray': user.role === 'User'}">
{{ user.role }}
</span>
</td>
<td class="font-mono text-white">${{ user.balance }}</td>
<td>
<span v-if="user.is_banned" class="badge bg-red-dim text-red">BANNED</span>
<span v-else-if="user.is_chat_banned" class="badge bg-orange-dim text-orange">MUTED</span>
<span v-else class="badge bg-green-dim text-green">ACTIVE</span>
</td>
<td>
<div class="flex items-center gap-2">
<Link :href="`/admin/users/${user.id}`" class="btn-icon" title="View Profile">
<i data-lucide="eye"></i>
</Link>
<Link :href="`/admin/users/${user.id}?tab=finances`" class="btn-icon text-green-400" title="Manage Finances">
<i data-lucide="wallet"></i>
</Link>
<button class="btn-icon text-red-400" title="Ban User" v-if="!user.is_banned">
<i data-lucide="ban"></i>
</button>
</div>
</td>
</tr>
</tbody>
</table>
<div class="pagination" v-if="users.last_page > 1">
<!-- Pagination logic here -->
<span class="text-muted">Page {{ users.current_page }} of {{ users.last_page }}</span>
</div>
</div>
</CasinoAdminLayout>
</template>
<style scoped>
.btn-primary { background: #ff007a; color: white; border: none; padding: 10px 16px; border-radius: 8px; font-weight: 700; cursor: pointer; display: inline-flex; align-items: center; gap: 8px; transition: 0.2s; }
.btn-primary:hover { background: #e6006e; }
.filters-bar { display: flex; gap: 16px; margin-bottom: 24px; }
.search-input { position: relative; flex: 1; max-width: 400px; }
.search-input i { position: absolute; left: 14px; top: 50%; transform: translateY(-50%); color: #a1a1aa; width: 18px; height: 18px; }
.search-input input { width: 100%; background: #111113; border: 1px solid #1f1f22; border-radius: 10px; padding: 12px 12px 12px 40px; color: #fff; font-size: 14px; outline: none; }
.search-input input:focus { border-color: #3f3f46; }
.filter-select { background: #111113; border: 1px solid #1f1f22; border-radius: 10px; padding: 12px 16px; color: #fff; font-size: 14px; outline: none; min-width: 150px; }
.table-container { background: #111113; border: 1px solid #1f1f22; border-radius: 16px; overflow: hidden; }
.admin-table { width: 100%; border-collapse: collapse; text-align: left; }
.admin-table th { background: #0c0c0e; padding: 16px; font-size: 12px; font-weight: 700; color: #a1a1aa; text-transform: uppercase; border-bottom: 1px solid #1f1f22; }
.admin-table td { padding: 16px; border-bottom: 1px solid #1f1f22; font-size: 14px; color: #e4e4e7; }
.admin-table tr:last-child td { border-bottom: none; }
.admin-table tr:hover td { background: #18181b; }
.avatar { width: 32px; height: 32px; background: #27272a; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: 800; color: #fff; }
.badge { padding: 4px 8px; border-radius: 6px; font-size: 11px; font-weight: 800; text-transform: uppercase; }
.bg-red { background: #ef4444; color: #fff; }
.bg-blue { background: #3b82f6; color: #fff; }
.bg-gray { background: #27272a; color: #a1a1aa; }
.bg-red-dim { background: rgba(239, 68, 68, 0.1); border: 1px solid rgba(239, 68, 68, 0.2); }
.bg-orange-dim { background: rgba(245, 158, 11, 0.1); border: 1px solid rgba(245, 158, 11, 0.2); }
.bg-green-dim { background: rgba(16, 185, 129, 0.1); border: 1px solid rgba(16, 185, 129, 0.2); }
.text-red { color: #ef4444; }
.text-orange { color: #f59e0b; }
.text-green { color: #10b981; }
.text-muted { color: #a1a1aa; }
.btn-icon { display: inline-flex; background: transparent; border: none; color: #a1a1aa; cursor: pointer; padding: 8px; border-radius: 8px; transition: 0.2s; text-decoration: none; }
.btn-icon:hover { background: #27272a; color: #fff; }
.pagination { padding: 16px; border-top: 1px solid #1f1f22; display: flex; justify-content: center; }
</style>

View File

@@ -0,0 +1,139 @@
<script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3';
import { onMounted, nextTick } from 'vue';
import AdminLayout from '@/layouts/admin/CasinoAdminLayout.vue';
const props = defineProps<{
settings: {
pin_max_attempts: number;
pin_lock_minutes: number;
min_tx_btx: number;
max_tx_btx: number;
daily_max_btx: number;
actions_per_minute: number;
reason_required: boolean;
};
defaults: Record<string, any>;
}>();
const form = useForm({
pin_max_attempts: props.settings.pin_max_attempts ?? 5,
pin_lock_minutes: props.settings.pin_lock_minutes ?? 15,
min_tx_btx: props.settings.min_tx_btx ?? 0.0001,
max_tx_btx: props.settings.max_tx_btx ?? 100000,
daily_max_btx: props.settings.daily_max_btx ?? 100000,
actions_per_minute: props.settings.actions_per_minute ?? 20,
reason_required: props.settings.reason_required ?? true,
});
async function submit() {
await form.post('/admin/wallets/settings', { preserveScroll: true });
}
onMounted(() => {
nextTick(() => { if ((window as any).lucide) (window as any).lucide.createIcons(); });
});
</script>
<template>
<AdminLayout>
<Head title="Admin Wallet Einstellungen" />
<section class="content">
<div class="wrap">
<div class="panel">
<header class="page-head">
<div class="head-flex">
<div class="title-group">
<div class="title">Wallet/Vault Einstellungen</div>
<p class="subtitle">PINPolicy, Limits, Throttles und Vorgaben für manuelle Anpassungen.</p>
</div>
<div class="actions">
<button class="btn primary" @click="submit" :disabled="form.processing">
<i data-lucide="save"></i>
Speichern
</button>
</div>
</div>
</header>
<div class="grid">
<div class="card">
<h3>Vault PIN Policy</h3>
<div class="row">
<label>Max Fehlversuche</label>
<input type="number" min="1" max="20" v-model.number="form.pin_max_attempts" />
</div>
<div class="row">
<label>Sperrdauer (Minuten)</label>
<input type="number" min="1" max="1440" v-model.number="form.pin_lock_minutes" />
</div>
</div>
<div class="card">
<h3>TransferLimits (BTX)</h3>
<div class="row">
<label>Minimum pro Transaktion</label>
<input type="number" step="0.0001" min="0" v-model.number="form.min_tx_btx" />
</div>
<div class="row">
<label>Maximum pro Transaktion</label>
<input type="number" step="0.0001" min="0" v-model.number="form.max_tx_btx" />
</div>
<div class="row">
<label>Tagesmaximum</label>
<input type="number" step="0.0001" min="0" v-model.number="form.daily_max_btx" />
</div>
</div>
<div class="card">
<h3>Throttling</h3>
<div class="row">
<label>Aktionen pro Minute</label>
<input type="number" min="1" max="600" v-model.number="form.actions_per_minute" />
</div>
</div>
<div class="card">
<h3>Manuelle Anpassungen</h3>
<div class="row checkbox">
<label>
<input type="checkbox" v-model="form.reason_required" />
Grund verpflichtend
</label>
</div>
<small>Empfohlen aktiviert: Alle manuellen Gutschriften/Abzüge erfordern einen dokumentierten Grund.</small>
</div>
</div>
<div class="foot">
<button class="btn primary" @click="submit" :disabled="form.processing">
<i data-lucide="save"></i>
Speichern
</button>
</div>
</div>
</div>
</section>
</AdminLayout>
</template>
<style scoped>
.content { padding: 20px; }
.wrap { max-width: 1100px; margin: 0 auto; }
.panel { background: #0f0f10; border: 1px solid #18181b; border-radius: 12px; padding: 16px; }
.page-head .title { font-size: 22px; font-weight: 700; }
.subtitle { color: #a1a1aa; margin-top: 4px; }
.actions { display: flex; gap: 10px; }
.grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; margin-top: 16px; }
@media (max-width: 900px) { .grid { grid-template-columns: 1fr; } }
.card { background: #0b0b0c; border: 1px solid #1f1f22; border-radius: 10px; padding: 14px; }
.card h3 { margin: 0 0 10px 0; font-size: 16px; }
.row { display: grid; grid-template-columns: 220px 1fr; align-items: center; gap: 10px; margin: 10px 0; }
.row.checkbox { grid-template-columns: 1fr; }
label { font-weight: 600; color: #cbd5e1; }
input, select { width: 100%; background: #0b0b0c; border: 1px solid #1f1f22; border-radius: 8px; padding: 10px; color: #e5e7eb; }
.btn { background: #1f2937; border: 1px solid #374151; color: #e5e7eb; padding: 8px 14px; border-radius: 8px; cursor: pointer; }
.btn.primary { background: #ff007a; border-color: #ff2b8f; color: white; }
.foot { display: flex; justify-content: flex-end; margin-top: 16px; }
</style>

View File

@@ -0,0 +1,415 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { Gift, Clock, Lock, Zap } from 'lucide-vue-next';
import { ref, onMounted } from 'vue';
import Button from '@/components/ui/button.vue';
import UserLayout from '@/layouts/user/userlayout.vue';
import { csrfFetch } from '@/utils/csrfFetch';
const activeTab = ref<'available' | 'active' | 'history'>('available');
// Reactive state from API
const loading = ref(true);
const error = ref<string | null>(null);
const available = ref<any[]>([]);
const active = ref<any[]>([]);
const history = ref<any[]>([]);
function formatAmount(b: any): string {
if (!b) return '';
const unit = b.amount_unit;
const val = b.amount_value;
if (!unit || val == null) return '';
if (unit === 'PERCENT') return `${val}%` + (b.max_amount ? ` up to ${formatCurrency(b.max_amount, b.currency)}` : '');
if (unit === 'SPINS') return `${val} Free Spins`;
// currency amounts
return `${formatCurrency(val, b.currency || 'USD')}`;
}
function formatCurrency(v: number, cur: string): string {
try {
return new Intl.NumberFormat(undefined, { style: 'currency', currency: (cur || 'USD') }).format(v);
} catch {
return `${v} ${cur || ''}`.trim();
}
}
function formatMinDeposit(b: any): string {
if (b.min_deposit == null) return '—';
if (b.currency) return formatCurrency(b.min_deposit, b.currency);
return `${b.min_deposit}`;
}
async function loadBonuses() {
loading.value = true;
error.value = null;
try {
const res = await fetch('/api/bonuses/app', { headers: { 'Accept': 'application/json' } });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
available.value = json.available || [];
active.value = json.active || [];
history.value = json.history || [];
} catch (e: any) {
error.value = e?.message || 'Failed to load bonuses';
} finally {
loading.value = false;
}
}
onMounted(loadBonuses);
// Promo redemption UI state
const redeemCode = ref<string>('');
const redeemProcessing = ref<boolean>(false);
const redeemMessage = ref<string | null>(null);
const redeemError = ref<string | null>(null);
async function applyPromo() {
redeemProcessing.value = true;
redeemMessage.value = null;
redeemError.value = null;
try {
const code = (redeemCode.value || '').trim().toUpperCase();
if (!code) {
redeemProcessing.value = false;
redeemError.value = 'Bitte gib einen Code ein.';
return;
}
const res = await csrfFetch('/api/promos/apply', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({ code }),
});
const json = await res.json().catch(() => ({}));
if (!res.ok) {
let msg = json?.message || '';
if (res.status === 401) msg = 'Bitte einloggen, um einen Promo-Code einzulösen.';
else if (res.status === 403) msg = msg || 'Du hast keine Berechtigung, diesen Code einzulösen.';
else if (res.status === 419) msg = 'Sicherheits-Token abgelaufen. Bitte Seite neu laden und erneut versuchen.';
else if (res.status === 422) msg = msg || 'Ungültiger oder inaktiver Promo-Code.';
else if (res.status === 429) msg = msg || 'Zu viele Versuche. Bitte später erneut versuchen.';
throw new Error(msg || `Fehler (${res.status}).`);
}
redeemMessage.value = json?.message || 'Promo applied successfully';
redeemCode.value = '';
await loadBonuses();
} catch (e: any) {
redeemError.value = e?.message || 'Failed to apply promo';
} finally {
redeemProcessing.value = false;
}
}
// Static placeholder for coming soon
const comingSoon = [
{ id: 4, title: 'VIP Cashback', amount: '10% Weekly', unlock: 'Level 5' },
];
</script>
<template>
<UserLayout>
<Head :title="$t('bonuses.title')" />
<div class="bonus-content">
<div class="glow-orb orb-1"></div>
<div class="glow-orb orb-2"></div>
<h1 class="page-title">{{ $t('bonuses.title') }}</h1>
<!-- Tabs -->
<div class="tabs-container">
<div class="tabs">
<button @click="activeTab = 'available'" :class="{ active: activeTab === 'available' }"><Gift class="w-4 h-4" /> {{ $t('bonuses.tabs.available') }}</button>
<button @click="activeTab = 'active'" :class="{ active: activeTab === 'active' }"><Zap class="w-4 h-4" /> {{ $t('bonuses.tabs.active') }}</button>
<button @click="activeTab = 'history'" :class="{ active: activeTab === 'history' }"><Clock class="w-4 h-4" /> {{ $t('bonuses.tabs.history') }}</button>
</div>
</div>
<div class="tab-content">
<!-- Redeem promo code -->
<div class="glass-card p-4 mb-6">
<h2 class="text-lg font-semibold mb-2">{{ $t('bonus.promo_title') }}</h2>
<form class="flex flex-col sm:flex-row gap-3 items-stretch sm:items-end" @submit.prevent="applyPromo">
<input
v-model="redeemCode"
type="text"
:placeholder="$t('bonus.promo_placeholder')"
class="flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm focus:outline-none"
required
/>
<Button type="submit" :disabled="redeemProcessing || !redeemCode">
{{ redeemProcessing ? $t('bonus.promo_redeeming') : $t('bonus.promo_redeem') }}
</Button>
</form>
<p v-if="redeemMessage" class="mt-2 text-emerald-500 text-sm">{{ redeemMessage }}</p>
<p v-if="redeemError" class="mt-2 text-rose-400 text-sm">{{ redeemError }}</p>
</div>
<!-- Loading & Error -->
<div v-if="loading" class="bonus-grid">
<div v-for="i in 3" :key="i" class="bonus-card glass-card skeleton"></div>
</div>
<div v-else-if="error" class="glass-card" style="padding:16px;color:#fca5a5;border-color:#7f1d1d;background:rgba(127,29,29,0.2)">
{{ $t('bonuses.errorLoad') }}: {{ error }}
</div>
<!-- Available -->
<transition name="fade" mode="out-in">
<div v-if="!loading && activeTab === 'available'" class="bonus-grid">
<!-- Hero Bonus (first available if any) -->
<div v-if="available.length" class="bonus-card hero">
<div class="card-bg"></div>
<div class="card-content">
<div class="badge">{{ $t('bonuses.featured') }}</div>
<h2>{{ available[0].title }}</h2>
<p class="amount">{{ formatAmount(available[0]) }}</p>
<p class="desc">{{ $t('bonuses.minDeposit') }}: {{ formatMinDeposit(available[0]) }}</p>
<Button class="neon-button w-full mt-4">{{ $t('bonuses.claim') }}</Button>
</div>
</div>
<div v-for="b in available.slice(1)" :key="b.id" class="bonus-card glass-card">
<div class="card-header">
<div class="icon-box"><Gift class="w-6 h-6" /></div>
<div class="type">{{ b.type || $t('bonuses.bonus') }}</div>
</div>
<h3>{{ b.title }}</h3>
<div class="amount-small">{{ formatAmount(b) }}</div>
<div class="meta">{{ $t('bonuses.minDeposit') }}: {{ formatMinDeposit(b) }}</div>
<Button variant="secondary" class="w-full mt-4">{{ $t('bonuses.activate') }}</Button>
</div>
<!-- Coming Soon -->
<div v-for="b in comingSoon" :key="b.id" class="bonus-card glass-card locked">
<div class="lock-overlay"><Lock class="w-8 h-8 text-[#666]" /></div>
<h3>{{ b.title }}</h3>
<div class="amount-small">{{ b.amount }}</div>
<div class="meta">{{ $t('bonus.unlock_at') }} {{ b.unlock }}</div>
</div>
</div>
</transition>
<!-- Active -->
<transition name="fade" mode="out-in">
<div v-if="!loading && activeTab === 'active'" class="active-list">
<div v-for="b in active" :key="b.id" class="active-card glass-card">
<div class="active-header">
<h3>{{ b.title }}</h3>
<div class="expires" v-if="b.expires_at"><Clock class="w-3 h-3 inline" />
{{ new Date(b.expires_at).toLocaleString() }}
</div>
</div>
<div class="active-amount">{{ formatAmount(b) }}</div>
<div class="wager-section" v-if="b.progress != null">
<div class="wager-info">
<span>{{ $t('bonuses.wagerProgress') }}</span>
<span>{{ b.progress }}%</span>
</div>
<div class="progress-bar">
<div class="fill" :style="{ width: `${b.progress}%` }"></div>
</div>
<div class="wager-details" v-if="b.wagered != null && b.wager_total != null">
{{ $t('bonuses.wageredOf', { wagered: b.wagered, total: b.wager_total }) }}
</div>
</div>
</div>
<div v-if="active.length === 0" class="empty-state">
{{ $t('bonuses.noActive') }}
</div>
</div>
</transition>
<!-- History -->
<transition name="fade" mode="out-in">
<div v-if="!loading && activeTab === 'history'" class="active-list">
<div v-if="history.length === 0" class="empty-state">{{ $t('bonuses.noHistory') }}</div>
<div v-for="b in history" :key="b.id" class="active-card glass-card">
<div class="active-header">
<h3>{{ b.title }}</h3>
<div class="expires"><Clock class="w-3 h-3 inline" /> {{ $t('bonuses.ended') }}</div>
</div>
<div class="active-amount">{{ formatAmount(b) }}</div>
</div>
</div>
</transition>
</div>
</div>
</UserLayout>
</template>
<style scoped>
.bonus-content {
padding: 30px;
max-width: 1000px;
margin: 0 auto;
position: relative;
}
.page-title {
font-size: 28px;
font-weight: 900;
color: white;
margin-bottom: 30px;
text-transform: uppercase;
letter-spacing: 1px;
position: relative; z-index: 2;
}
/* Tabs */
.tabs-container { margin-bottom: 30px; position: relative; z-index: 2; }
.tabs {
display: flex;
gap: 5px;
background: rgba(10, 10, 10, 0.8);
backdrop-filter: blur(10px);
padding: 5px;
border-radius: 12px;
border: 1px solid #151515;
width: fit-content;
}
.tabs button {
background: transparent;
border: none;
color: #666;
padding: 10px 20px;
border-radius: 8px;
font-size: 13px;
font-weight: 700;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
transition: all 0.2s;
white-space: nowrap;
}
.tabs button:hover { color: white; background: rgba(255,255,255,0.05); }
.tabs button.active {
background: #1a1a1a;
color: #fff;
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
border: 1px solid #222;
}
.tabs button.active svg { color: #ff007a; }
/* Grid */
.bonus-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
}
/* Cards */
.bonus-card {
border-radius: 20px;
padding: 25px;
position: relative;
overflow: hidden;
}
.glass-card {
background: rgba(10, 10, 10, 0.6);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.05);
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
transition: transform 0.3s;
}
.glass-card:hover { transform: translateY(-5px); border-color: #333; }
/* Hero Card */
.bonus-card.hero {
grid-column: 1 / -1;
background: linear-gradient(135deg, #0f0f0f 0%, #1a1a1a 100%);
border: 1px solid #222;
display: flex;
align-items: center;
min-height: 250px;
}
.card-bg {
position: absolute; inset: 0;
background: radial-gradient(circle at 80% 50%, rgba(255,0,122,0.15) 0%, transparent 60%);
}
.card-content { position: relative; z-index: 2; max-width: 500px; }
.badge { background: #ff007a; color: white; padding: 4px 10px; border-radius: 4px; font-size: 10px; font-weight: 900; display: inline-block; margin-bottom: 15px; }
.hero h2 { font-size: 32px; font-weight: 900; color: white; margin-bottom: 5px; }
.hero .amount { font-size: 18px; color: #00f2ff; font-weight: 700; margin-bottom: 10px; }
.hero .desc { font-size: 14px; color: #888; }
/* Standard Card */
.card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 15px; }
.icon-box { width: 40px; height: 40px; background: rgba(255,255,255,0.05); border-radius: 10px; display: flex; align-items: center; justify-content: center; }
.type { font-size: 10px; font-weight: 700; color: #666; text-transform: uppercase; border: 1px solid #333; padding: 2px 6px; border-radius: 4px; }
.bonus-card h3 { font-size: 18px; font-weight: 800; color: white; margin-bottom: 5px; }
.amount-small { font-size: 14px; color: #00f2ff; font-weight: 700; margin-bottom: 15px; }
.meta { font-size: 12px; color: #666; }
/* Locked Card */
.locked { opacity: 0.5; cursor: not-allowed; }
.lock-overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,0.5); z-index: 10; }
/* Active Bonus */
.active-list { display: flex; flex-direction: column; gap: 15px; }
.active-card { padding: 25px; }
.active-header { display: flex; justify-content: space-between; margin-bottom: 10px; }
.active-header h3 { font-size: 16px; font-weight: 800; color: white; }
.expires { font-size: 12px; color: #ff007a; font-weight: 600; }
.active-amount { font-size: 24px; font-weight: 900; color: #00f2ff; margin-bottom: 20px; }
.wager-info { display: flex; justify-content: space-between; font-size: 12px; color: #ccc; margin-bottom: 5px; font-weight: 600; }
.progress-bar { height: 6px; background: #222; border-radius: 3px; overflow: hidden; margin-bottom: 8px; }
.fill { height: 100%; background: linear-gradient(90deg, #00f2ff, #00ff9d); border-radius: 3px; }
.wager-details { font-size: 11px; color: #666; text-align: right; }
/* Neon Button */
.neon-button { background: linear-gradient(90deg, #ff007a, #be005b); border: none; position: relative; overflow: hidden; transition: all 0.3s ease; color: white; }
.neon-button:hover { transform: translateY(-2px); box-shadow: 0 0 30px rgba(255, 0, 122, 0.6); filter: brightness(1.1); }
/* Background Orbs */
.glow-orb {
position: absolute;
border-radius: 50%;
filter: blur(100px);
opacity: 0.3;
z-index: 0;
animation: float 10s infinite ease-in-out;
}
.orb-1 { width: 400px; height: 400px; background: #ff007a; top: -100px; left: -100px; }
.orb-2 { width: 500px; height: 500px; background: #00f2ff; bottom: -100px; right: -100px; animation-delay: -5s; }
@keyframes float { 0%, 100% { transform: translate(0, 0); } 50% { transform: translate(30px, 50px); } }
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s ease; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
/* Skeleton loader */
.skeleton {
min-height: 160px;
background: linear-gradient(90deg, rgba(255,255,255,0.04) 25%, rgba(255,255,255,0.08) 37%, rgba(255,255,255,0.04) 63%);
background-size: 400% 100%;
animation: shimmer 1.2s ease-in-out infinite;
border-radius: 20px;
}
@keyframes shimmer {
0% { background-position: 100% 0; }
100% { background-position: -100% 0; }
}
.empty-state { color: #777; text-align: center; padding: 20px; }
/* Mobile tweaks */
@media (max-width: 900px) {
.bonus-content { padding: 20px 16px; }
.page-title { font-size: clamp(20px, 5.6vw, 26px); }
.tabs { width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; }
.tabs::-webkit-scrollbar { display: none; }
.tabs button { padding: 8px 12px; font-size: 12px; }
.bonus-grid { grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 14px; }
.bonus-card { padding: 18px; }
.bonus-card.hero { min-height: 200px; }
.hero h2 { font-size: clamp(18px, 5.5vw, 26px); }
}
@media (max-width: 480px) {
.bonus-grid { grid-template-columns: 1fr; }
.card-content { max-width: 100%; }
.active-card { padding: 18px; }
.active-amount { font-size: 20px; }
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,176 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { ref, computed, onMounted, onUnmounted } from 'vue';
const props = defineProps<{
reason?: string;
ends_at?: string | null;
}>();
const nowTs = ref<number>(Date.now());
let ticker: number | undefined;
onMounted(() => {
// Tick every second to update countdown
ticker = window.setInterval(() => { nowTs.value = Date.now(); }, 1000);
});
onUnmounted(() => {
if (ticker) window.clearInterval(ticker);
});
const endsAtDate = computed<Date | null>(() => {
if (!props.ends_at) return null;
const d = new Date(props.ends_at);
return isNaN(d.getTime()) ? null : d;
});
const remainingMs = computed<number>(() => {
if (!endsAtDate.value) return 0;
return Math.max(0, endsAtDate.value.getTime() - nowTs.value);
});
function formatCountdown(ms: number): string {
const totalSec = Math.floor(ms / 1000);
const days = Math.floor(totalSec / 86400);
const hours = Math.floor((totalSec % 86400) / 3600);
const minutes = Math.floor((totalSec % 3600) / 60);
const seconds = totalSec % 60;
const pad = (n: number) => n.toString().padStart(2, '0');
if (days > 0) return `${days}d ${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
}
const countdownText = computed<string | null>(() => {
if (!endsAtDate.value || remainingMs.value <= 0) return null;
return formatCountdown(remainingMs.value);
});
const exactEndText = computed<string | null>(() => {
if (!endsAtDate.value) return null;
try {
return endsAtDate.value.toLocaleString();
} catch {
return endsAtDate.value.toISOString();
}
});
</script>
<template>
<Head title="Account Suspended" />
<div class="banned-screen">
<div class="overlay"></div>
<div class="content">
<div class="stamp">BANNED</div>
<h1>Account Suspended</h1>
<p class="reason" v-if="reason">Reason: {{ reason }}</p>
<p v-if="countdownText && exactEndText" class="sub">Ends in <strong>{{ countdownText }}</strong> ({{ exactEndText }})</p>
<p v-else class="sub">Your account has been permanently suspended due to a violation of our Terms of Service.</p>
<div class="actions">
<a href="/logout" class="btn-logout" onclick="event.preventDefault(); document.getElementById('logout-form').submit();">Sign Out</a>
<form id="logout-form" action="/logout" method="POST" style="display: none;">
<input type="hidden" name="_token" :value="$page.props.csrf_token">
</form>
</div>
</div>
</div>
</template>
<style scoped>
.banned-screen {
height: 100vh;
width: 100vw;
background-size: cover;
display: flex;
align-items: center;
justify-content: center;
position: relative;
font-family: 'Impact', sans-serif;
overflow: hidden;
}
.overlay {
position: absolute; inset: 0;
background: radial-gradient(circle, rgba(0,0,0,0.4) 0%, rgba(0,0,0,0.9) 100%);
backdrop-filter: blur(3px);
}
.content {
position: relative; z-index: 10;
text-align: center;
color: #fff;
animation: zoomIn 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
.stamp {
font-size: 80px;
color: #ff3e3e;
border: 8px solid #ff3e3e;
display: inline-block;
padding: 10px 40px;
transform: rotate(-10deg);
margin-bottom: 30px;
text-transform: uppercase;
letter-spacing: 5px;
opacity: 0;
animation: stamp-fall 0.4s cubic-bezier(0.6, 0.04, 0.98, 0.335) 0.5s forwards;
mask-image: url('https://s3-us-west-2.amazonaws.com/s.cdpn.io/8399/grunge.png'); /* Grunge texture effect */
mask-size: 900px;
mix-blend-mode: multiply;
}
h1 {
font-family: 'Helvetica Neue', sans-serif;
font-size: 32px;
text-transform: uppercase;
letter-spacing: 2px;
margin-bottom: 10px;
text-shadow: 0 2px 10px rgba(0,0,0,0.8);
}
.reason {
font-size: 18px;
color: #ffaaaa;
background: rgba(255, 0, 0, 0.1);
padding: 10px 20px;
border-radius: 8px;
display: inline-block;
margin-bottom: 20px;
border: 1px solid rgba(255, 0, 0, 0.3);
}
.sub {
font-family: sans-serif;
font-size: 14px;
color: #ccc;
max-width: 400px;
margin: 0 auto 40px;
line-height: 1.5;
}
.btn-logout {
background: #fff;
color: #000;
padding: 12px 30px;
text-decoration: none;
font-weight: 900;
text-transform: uppercase;
border-radius: 4px;
transition: 0.2s;
}
.btn-logout:hover {
background: #ccc;
transform: scale(1.05);
}
@keyframes stamp-fall {
0% { opacity: 0; transform: rotate(-10deg) scale(3); }
100% { opacity: 1; transform: rotate(-10deg) scale(1); }
}
@keyframes zoomIn {
from { opacity: 0; transform: scale(0.8); }
to { opacity: 1; transform: scale(1); }
}
</style>

120
resources/js/pages/Faq.vue Normal file
View File

@@ -0,0 +1,120 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { ref, onMounted, nextTick } from 'vue';
import UserLayout from '../layouts/user/userlayout.vue';
const faqs = ref([
{
q: 'What is BetiX?',
a: 'BetiX is an online gaming platform. Create an account, verify your email, deposit funds responsibly, and enjoy games. Always follow your local laws and our terms.'
},
{
q: 'How do I secure my account?',
a: 'Use a strong, unique password and enable two-factor authentication (2FA). We also email you when a new login is detected so you can react quickly if it was not you.'
},
{
q: 'I did not receive an email. What should I do?',
a: 'Check your spam folder and ensure your email address is correct. If you still have issues, try again in a few minutes or contact support at the address in the footer.'
},
{
q: 'What are responsible gaming limits?',
a: 'You can set optional limits for losses, wagers, session duration, and timeouts to help manage your play. Adjust these from the Responsible Gaming section in your account.'
},
{
q: 'Why do I see “Too many requests” (429)?',
a: 'Rate limiting protects the platform from abuse. If you hit this limit, wait a minute and try again. Avoid running automated scripts or rapidly refreshing pages.'
},
{
q: 'Which browsers are supported?',
a: 'We support the latest versions of Chrome, Firefox, Safari, and Edge. Enable cookies and JavaScript for the best experience.'
},
{
q: 'How do I contact support?',
a: 'You can reach us via the email address listed in the site footer or the Help/Support section of your account once logged in.'
},
]);
const expanded = ref<Record<number, boolean>>({});
const toggle = (idx: number) => {
expanded.value[idx] = !expanded.value[idx];
};
onMounted(() => {
nextTick(() => {
if (window.lucide) window.lucide.createIcons();
});
});
</script>
<template>
<UserLayout>
<Head title="FAQ" />
<section class="content">
<div class="wrap">
<div class="panel">
<header class="page-head">
<div class="title">Frequently Asked Questions</div>
<p class="subtitle">Answers to common questions about accounts, security, and responsible gaming.</p>
</header>
<div class="grid">
<div class="left">
<div v-for="(item, idx) in faqs" :key="idx" class="faq-item" :class="{ open: expanded[idx] }">
<button class="q" @click="toggle(idx)" :aria-expanded="expanded[idx] ? 'true' : 'false'" :aria-controls="'faq-'+idx">
<span class="q-text">{{ item.q }}</span>
<i data-lucide="chevron-down" class="chev" aria-hidden="true"></i>
</button>
<div class="a" :id="'faq-'+idx" v-show="expanded[idx]">
<p>{{ item.a }}</p>
</div>
</div>
</div>
<aside class="right">
<div class="side-card">
<div class="card-head">Need more help?</div>
<p class="card-text">If your question isnt listed here, reach out to our support team.</p>
<ul class="help-list">
<li><i data-lucide="mail" class="icon"></i> Email: <a href="mailto:support@example.com">Contact Support</a></li>
<li><i data-lucide="shield" class="icon"></i> Read our <a href="/self-exclusion">Responsible Gaming</a> info</li>
</ul>
</div>
</aside>
</div>
</div>
</div>
</section>
</UserLayout>
</template>
<style scoped>
.content { padding: 30px; }
.wrap { max-width: 1100px; margin: 0 auto; }
.panel { background: var(--bg-card, #0a0a0a); border: 1px solid var(--border, #151515); border-radius: 16px; overflow: hidden; }
.page-head { padding: 20px; border-bottom: 1px solid var(--border, #151515); }
.title { font-size: 20px; font-weight: 900; color: #fff; letter-spacing: 2px; text-transform: uppercase; }
.subtitle { color: #9aa0a6; margin-top: 6px; }
.grid { padding: 20px; display: grid; grid-template-columns: 1.6fr .8fr; gap: 22px; }
.left { display: flex; flex-direction: column; gap: 12px; }
.faq-item { border: 1px solid var(--border, #151515); background: #0a0a0a; border-radius: 14px; overflow: hidden; }
.q { width: 100%; text-align: left; background: transparent; color: #e6e6e6; padding: 16px 18px; border: 0; display: flex; align-items: center; justify-content: space-between; gap: 12px; cursor: pointer; font-weight: 800; }
.q:hover { background: rgba(255,255,255,0.02); }
.q-text { font-size: 14px; }
.chev { width: 18px; color: #aaa; transition: transform .2s ease; }
.faq-item.open .chev { transform: rotate(180deg); }
.a { padding: 0 18px 16px; color: #cfcfcf; font-size: 14px; line-height: 1.6; }
.side-card { border: 1px solid var(--border, #151515); background: #0a0a0a; border-radius: 14px; padding: 16px; position: sticky; top: 20px; }
.card-head { color: #fff; font-weight: 900; margin-bottom: 8px; }
.card-text { color: #9aa0a6; margin-bottom: 8px; }
.help-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 10px; }
.help-list a { color: var(--cyan, #00f2ff); text-decoration: none; }
.icon { width: 16px; color: #666; margin-right: 6px; vertical-align: -2px; }
@media (max-width: 900px) {
.grid { grid-template-columns: 1fr; padding: 16px; }
.side-card { position: static; }
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,154 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
const props = defineProps<{
message?: string;
reason?: 'vpn' | 'country' | string;
}>();
const title = props.reason === 'vpn' ? 'VPN erkannt' : 'Region gesperrt';
const icon = props.reason === 'vpn' ? '🛡️' : '🌍';
</script>
<template>
<Head title="Zugang gesperrt" />
<div class="blocked-page">
<div class="noise"></div>
<div class="glow"></div>
<div class="card">
<div class="icon-wrap">
<span class="icon">{{ icon }}</span>
</div>
<h1 class="title">{{ title }}</h1>
<p class="message">
{{ message || 'Dieser Dienst ist in deiner Region nicht verfügbar.' }}
</p>
<div class="divider"></div>
<p class="sub">
<span v-if="reason === 'vpn'">
Bitte deaktiviere dein VPN oder deinen Proxy und versuche es erneut.
</span>
<span v-else>
Falls du glaubst, dass dies ein Fehler ist, kontaktiere bitte den Support.
</span>
</p>
<div class="code">403</div>
</div>
</div>
</template>
<style scoped>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
.blocked-page {
min-height: 100vh;
background: #050505;
display: flex;
align-items: center;
justify-content: center;
font-family: 'Inter', sans-serif;
position: relative;
overflow: hidden;
}
.noise {
position: absolute;
inset: 0;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='0.03'/%3E%3C/svg%3E");
opacity: 0.4;
pointer-events: none;
}
.glow {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -60%);
width: 600px;
height: 600px;
border-radius: 50%;
background: radial-gradient(circle, rgba(223,0,106,0.12) 0%, transparent 70%);
pointer-events: none;
}
.card {
position: relative;
z-index: 10;
background: rgba(15, 15, 16, 0.95);
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 20px;
padding: 48px 40px;
max-width: 480px;
width: 90%;
text-align: center;
box-shadow: 0 40px 80px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(223,0,106,0.1);
backdrop-filter: blur(10px);
}
.icon-wrap {
width: 80px;
height: 80px;
background: rgba(223,0,106,0.08);
border: 1px solid rgba(223,0,106,0.2);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 24px;
animation: pulse 3s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(223,0,106,0.2); }
50% { box-shadow: 0 0 0 12px rgba(223,0,106,0); }
}
.icon {
font-size: 36px;
line-height: 1;
}
.title {
font-size: 28px;
font-weight: 900;
color: #fff;
letter-spacing: -0.5px;
margin-bottom: 16px;
}
.message {
color: #a1a1aa;
font-size: 15px;
line-height: 1.6;
margin-bottom: 24px;
}
.divider {
height: 1px;
background: rgba(255,255,255,0.06);
margin: 0 0 20px;
}
.sub {
color: #52525b;
font-size: 13px;
line-height: 1.6;
margin-bottom: 32px;
}
.code {
font-size: 72px;
font-weight: 900;
color: rgba(223,0,106,0.12);
letter-spacing: -4px;
line-height: 1;
user-select: none;
}
</style>

View File

@@ -0,0 +1,30 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
defineProps<{ message?: string }>();
</script>
<template>
<Head title="Wartung" />
<div class="page">
<div class="glow"></div>
<div class="card">
<div class="icon-wrap"><span class="icon">🔧</span></div>
<h1>Wartungsmodus</h1>
<p>{{ message || 'Wir führen gerade Wartungsarbeiten durch. Bitte komm später zurück.' }}</p>
<div class="code">503</div>
</div>
</div>
</template>
<style scoped>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
.page { min-height: 100vh; background: #050505; display: flex; align-items: center; justify-content: center; font-family: 'Inter', sans-serif; position: relative; overflow: hidden; }
.glow { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -60%); width: 500px; height: 500px; border-radius: 50%; background: radial-gradient(circle, rgba(223,0,106,0.1) 0%, transparent 70%); pointer-events: none; }
.card { position: relative; z-index: 10; background: rgba(15,15,16,0.95); border: 1px solid rgba(255,255,255,0.07); border-radius: 20px; padding: 48px 40px; max-width: 460px; width: 90%; text-align: center; box-shadow: 0 40px 80px rgba(0,0,0,0.6); }
.icon-wrap { width: 80px; height: 80px; background: rgba(223,0,106,0.08); border: 1px solid rgba(223,0,106,0.2); border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto 24px; animation: pulse 3s ease-in-out infinite; }
@keyframes pulse { 0%, 100% { box-shadow: 0 0 0 0 rgba(223,0,106,0.2); } 50% { box-shadow: 0 0 0 12px rgba(223,0,106,0); } }
.icon { font-size: 36px; }
h1 { font-size: 28px; font-weight: 900; color: #fff; letter-spacing: -0.5px; margin-bottom: 16px; }
p { color: #a1a1aa; font-size: 15px; line-height: 1.6; margin-bottom: 32px; }
.code { font-size: 72px; font-weight: 900; color: rgba(223,0,106,0.12); letter-spacing: -4px; line-height: 1; user-select: none; }
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,930 @@
<script setup lang="ts">
import { Head, useForm, usePage } from '@inertiajs/vue3';
import { ref, onMounted, computed } from 'vue';
import html2canvas from 'html2canvas';
import { useNotifications } from '@/composables/useNotifications';
import {
Flag, X, CheckCircle2, AlertCircle, ChevronRight, Loader2,
MessageSquareOff, UserX, ShieldAlert, BadgeDollarSign, HelpCircle
} from 'lucide-vue-next';
import UserLayout from '@/layouts/user/userlayout.vue';
const props = defineProps<{
profile: any;
isOwnProfile: boolean;
isFriend: boolean;
isPending?: boolean;
theyRequestedMe?: boolean;
friendRowId?: number | null;
hasLiked: boolean;
}>();
const { notify } = useNotifications();
const stats = props.profile.stats;
const bestWins = props.profile.best_wins || [];
const comments = ref(props.profile.comments || []);
// Animation for stats
const animatedWager = ref(0);
onMounted(() => {
const target = parseFloat(stats.wagered || 0);
const duration = 1500;
const start = performance.now();
const step = (timestamp: number) => {
const progress = Math.min((timestamp - start) / duration, 1);
animatedWager.value = target * (1 - Math.pow(1 - progress, 3));
if (progress < 1) requestAnimationFrame(step);
};
requestAnimationFrame(step);
});
// Rank & Role Logic
const vipLevelsConfig = [
{ name: 'Newbie', color: '#888888' },
{ name: 'Bronze', color: '#cd7f32' },
{ name: 'Silver', color: '#c0c0c0' },
{ name: 'Gold', color: '#ffd700' },
{ name: 'Platinum', color: '#00f2ff' },
{ name: 'Diamond', color: '#ff007a' },
{ name: 'Obsidian', color: '#ff3e3e' }
];
const currentVipStyle = computed(() => {
const level = props.profile.vip_level || 0;
const idx = Math.min(Math.max(level, 0), vipLevelsConfig.length - 1);
return vipLevelsConfig[idx];
});
const roleConfig = computed(() => {
const role = (props.profile.role || 'User').toLowerCase(); // Case insensitive check
let color = '#888';
let effectClass = '';
let displayName = props.profile.role || 'User';
if (role === 'admin') {
color = '#ff3e3e';
effectClass = 'role-admin';
displayName = 'ADMIN';
} else if (role === 'mod' || role === 'staff') {
color = '#00f2ff';
effectClass = 'role-staff';
displayName = 'STAFF';
} else if (role === 'streamer') {
color = '#a855f7';
effectClass = 'role-streamer';
displayName = 'STREAMER';
}
return { name: displayName, color, effectClass };
});
// Comment Logic
const commentForm = useForm({ content: '' });
const submitComment = () => {
if (!commentForm.content.trim()) return;
commentForm.post(`/profile/${props.profile.id}/comment`, {
preserveScroll: true,
onSuccess: () => {
commentForm.reset();
notify({ type: 'green', title: 'Posted', desc: 'Comment added.', icon: 'message-circle' });
}
});
};
// Report Logic
const isReportOpen = ref(false);
const reportReason = ref('');
const reportSubmitting = ref(false);
const reportDone = ref(false);
const reportReasons = [
{ value: 'spam', label: 'Spam', desc: 'Unerwünschte Werbung oder Wiederholungen', icon: MessageSquareOff, color: '#f59e0b' },
{ value: 'harassment', label: 'Belästigung', desc: 'Belästigendes oder feindseliges Verhalten', icon: UserX, color: '#ef4444' },
{ value: 'inappropriate', label: 'Unangemessenes Profil', desc: 'Anstößige Profilbilder oder Inhalte', icon: ShieldAlert, color: '#f97316' },
{ value: 'fake', label: 'Fake-Profil', desc: 'Vortäuschung einer anderen Identität', icon: BadgeDollarSign, color: '#a855f7' },
{ value: 'other', label: 'Sonstiges', desc: 'Anderer Verstoß gegen die Nutzungsregeln', icon: HelpCircle, color: '#6b7280' },
];
function openReportModal() {
isReportOpen.value = true;
reportReason.value = '';
reportDone.value = false;
}
function closeReportModal() {
isReportOpen.value = false;
}
const submitReport = async () => {
if (!reportReason.value || reportSubmitting.value) return;
reportSubmitting.value = true;
// Build snapshot + screenshot
const snapshot = {
id: props.profile.id,
username: props.profile.username,
avatar: props.profile.avatar,
banner: props.profile.banner,
bio: props.profile.bio,
role: props.profile.role,
vip_level: props.profile.vip_level,
clan_tag: props.profile.clan_tag,
stats: {
wagered: props.profile.stats?.wagered,
wins: props.profile.stats?.wins,
biggest_win: props.profile.stats?.biggest_win,
likes_count: props.profile.stats?.likes_count,
join_date: props.profile.stats?.join_date,
},
best_wins: bestWins.slice(0, 5),
comments: comments.value.slice(0, 10).map((c: any) => ({
id: c.id,
content: c.content,
created_at: c.created_at,
user: { id: c.user?.id, username: c.user?.username, avatar: c.user?.avatar },
})),
captured_at: new Date().toISOString(),
};
let screenshot: string | null = null;
try {
const profileEl = document.querySelector('.profile-page') as HTMLElement | null;
if (profileEl) {
const canvas = await html2canvas(profileEl, {
useCORS: true, allowTaint: false, scale: 1, backgroundColor: '#0a0a0c',
});
screenshot = canvas.toDataURL('image/png');
}
} catch { /* ignore */ }
const csrf = (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement)?.content || '';
try {
const res = await fetch(`/profile/${props.profile.id}/report`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-TOKEN': csrf,
},
body: JSON.stringify({ reason: reportReason.value, snapshot, screenshot }),
});
if (res.ok) {
reportDone.value = true;
setTimeout(closeReportModal, 1800);
}
} catch { /* ignore */ }
reportSubmitting.value = false;
};
// Like Logic
const toggleLike = () => {
useForm({}).post(`/profile/${props.profile.id}/like`, { preserveScroll: true });
};
// Tip Logic
const isTipOpen = ref(false);
const page = usePage();
const myWallets = computed(() => (page.props as any)?.auth?.user?.wallets || []);
const currencies = computed<string[]>(() => myWallets.value.map((w: any) => w.currency));
const tipForm = useForm<{ currency: string; amount: string | number; note?: string }>({
currency: '',
amount: '',
note: ''
});
// Friend request
const localPending = ref(!!props.isPending);
const localIsFriend = ref(props.isFriend);
const localTheyRequested = ref(!!props.theyRequestedMe);
const sendFriendRequest = () => {
if (localIsFriend.value || localPending.value) return;
useForm({ user_id: props.profile.id }).post('/friends/request', {
preserveScroll: true,
onSuccess: () => {
localPending.value = true;
notify({ type: 'magenta', title: 'Friend request', desc: 'Request sent to ' + props.profile.username, icon: 'user-plus' });
},
onError: (e) => {
notify({ type: 'red', title: 'Error', desc: (e && Object.values(e)[0]) as any || 'Failed to send request', icon: 'alert-triangle' });
}
});
};
const acceptFriendRequest = () => {
if (!props.friendRowId) return;
useForm({}).post(`/friends/${props.friendRowId}/accept`, {
preserveScroll: true,
onSuccess: () => {
localTheyRequested.value = false;
localIsFriend.value = true;
notify({ type: 'green', title: 'Friends!', desc: `You and ${props.profile.username} are now friends.`, icon: 'user-check' });
},
onError: () => {
notify({ type: 'red', title: 'Error', desc: 'Could not accept request.', icon: 'x' });
}
});
};
const declineFriendRequest = () => {
if (!props.friendRowId) return;
useForm({}).post(`/friends/${props.friendRowId}/decline`, {
preserveScroll: true,
onSuccess: () => {
localTheyRequested.value = false;
notify({ type: 'gray', title: 'Declined', desc: 'Friend request declined.', icon: 'x' });
},
onError: () => {
notify({ type: 'red', title: 'Error', desc: 'Could not decline request.', icon: 'x' });
}
});
};
onMounted(() => {
// Default to first available currency
if (currencies.value.length && !tipForm.currency) {
tipForm.currency = currencies.value[0];
}
});
const submitTip = () => {
// Basic client validation
const amt = parseFloat(String(tipForm.amount).replace(',', '.'));
if (!amt || amt <= 0) {
notify({ type: 'red', title: 'Invalid amount', desc: 'Please enter a positive amount.', icon: 'alert-triangle' });
return;
}
tipForm.amount = amt;
tipForm.post(`/profile/${props.profile.id}/tip`, {
preserveScroll: true,
onSuccess: () => {
isTipOpen.value = false;
tipForm.reset('amount', 'note');
notify({ type: 'green', title: 'Tip sent', desc: 'Your tip was sent successfully.', icon: 'send' });
},
onError: (errs) => {
const first = errs?.amount || Object.values(errs)[0] || 'Transfer failed';
notify({ type: 'red', title: 'Error', desc: String(first), icon: 'x' });
}
});
};
</script>
<template>
<UserLayout>
<Head :title="`${profile.username}'s Profile`" />
<div class="profile-page" :class="roleConfig.effectClass">
<div class="bg-fx"></div>
<!-- Banner -->
<div class="banner" :style="{ backgroundImage: `url(${profile.banner || '/img/default-banner.jpg'})` }">
<div class="banner-overlay"></div>
</div>
<div class="content-container">
<!-- Header Section -->
<div class="profile-header">
<div class="avatar-wrapper">
<div class="avatar" :style="{ borderColor: roleConfig.effectClass ? roleConfig.color : currentVipStyle.color, boxShadow: `0 0 20px ${roleConfig.effectClass ? roleConfig.color : currentVipStyle.color}40` }">
<img v-if="profile.avatar" :src="profile.avatar" alt="Avatar">
<span v-else>{{ profile.username.charAt(0) }}</span>
</div>
<div class="online-status"></div>
</div>
<div class="header-info">
<div class="name-row">
<h1 class="username" :class="roleConfig.effectClass" :style="{ color: roleConfig.effectClass ? roleConfig.color : '#fff' }">
<span v-if="profile.clan_tag" class="clan-tag">[{{ profile.clan_tag }}]</span>
{{ profile.username }}
</h1>
<div class="badges">
<span class="badge vip" :style="{ color: currentVipStyle.color, borderColor: currentVipStyle.color, background: `${currentVipStyle.color}15` }">
VIP {{ profile.vip_level }}
</span>
<span class="badge role" :class="roleConfig.effectClass" :style="{ color: roleConfig.color, borderColor: roleConfig.color, background: `${roleConfig.color}15` }">
{{ roleConfig.name }}
</span>
</div>
</div>
<p class="bio">{{ profile.bio || 'No bio provided.' }}</p>
</div>
<div class="header-actions">
<button v-if="!isOwnProfile" class="btn-action like" :class="{ active: hasLiked }" @click="toggleLike">
<i data-lucide="heart" :class="{ 'fill-current': hasLiked }"></i>
<span>{{ stats.likes_count }}</span>
</button>
<!-- Incoming friend request: show Accept / Decline -->
<template v-if="!isOwnProfile && localTheyRequested">
<button class="btn-action friend accept" @click="acceptFriendRequest">
<i data-lucide="user-check"></i> Accept
</button>
<button class="btn-action friend decline" @click="declineFriendRequest">
<i data-lucide="x"></i> Decline
</button>
</template>
<!-- Already friends / outgoing request / no request -->
<button v-else-if="!isOwnProfile" class="btn-action friend" :class="{ added: localIsFriend || localPending }" @click="sendFriendRequest" :disabled="localIsFriend || localPending">
<i data-lucide="user-plus" v-if="!localIsFriend && !localPending"></i>
<i data-lucide="check" v-else></i>
{{ localIsFriend ? 'Friends' : (localPending ? 'Pending' : 'Add') }}
</button>
<button v-if="!isOwnProfile && isFriend" class="btn-action tip" @click="isTipOpen = true" title="Send Tip">
<i data-lucide="coins"></i>
Tip
</button>
<button v-if="!isOwnProfile" class="btn-action report" @click="openReportModal" title="Report Profile">
<i data-lucide="flag"></i>
</button>
<a :href="isOwnProfile ? '/trophy' : `/trophy/${profile.username}`" class="btn-action trophy" title="Trophy Room">
<i data-lucide="trophy"></i>
</a>
<a href="/settings" v-if="isOwnProfile" class="btn-edit">
<i data-lucide="edit-2"></i> Edit
</a>
</div>
</div>
<!-- Stats Grid -->
<div class="stats-grid">
<div class="stat-card highlight">
<div class="stat-icon"><i data-lucide="coins"></i></div>
<div class="stat-data">
<div class="stat-label">Total Wagered</div>
<div class="stat-value">${{ animatedWager.toFixed(2) }}</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon"><i data-lucide="trophy"></i></div>
<div class="stat-data">
<div class="stat-label">Total Wins</div>
<div class="stat-value">{{ stats.wins }}</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon"><i data-lucide="star"></i></div>
<div class="stat-data">
<div class="stat-label">Biggest Win</div>
<div class="stat-value text-gold">${{ parseFloat(stats.biggest_win || 0).toFixed(2) }}</div>
<div class="stat-sub" v-if="stats.biggest_win_game">in {{ stats.biggest_win_game }}</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon"><i data-lucide="calendar"></i></div>
<div class="stat-data">
<div class="stat-label">Joined</div>
<div class="stat-value">{{ stats.join_date }}</div>
</div>
</div>
</div>
<!-- Content Columns -->
<div class="profile-cols">
<!-- Left: Wins -->
<div class="col-wins">
<h3><i data-lucide="crown" class="text-gold"></i> Best Wins</h3>
<div class="wins-grid">
<div v-for="(win, index) in bestWins" :key="index" class="win-card">
<div class="win-img" :style="{ backgroundImage: `url('${win.image}')` }"></div>
<div class="win-info">
<div class="win-amount text-gold">${{ win.amount.toFixed(2) }}</div>
<div class="win-game">{{ win.game }}</div>
<div class="win-multi">{{ win.multiplier }}</div>
</div>
</div>
<div v-if="bestWins.length === 0" class="win-card empty">
<i data-lucide="ghost"></i>
<span>No big wins yet</span>
</div>
</div>
</div>
<!-- Right: Comments -->
<div class="col-comments">
<h3><i data-lucide="message-square"></i> Comments</h3>
<div class="comment-input" v-if="!isOwnProfile">
<textarea v-model="commentForm.content" placeholder="Write a comment..." rows="2"></textarea>
<button @click="submitComment" :disabled="commentForm.processing"><i data-lucide="send"></i></button>
</div>
<div class="comments-list">
<div v-for="comment in comments" :key="comment.id" class="comment-item">
<div class="c-avatar">
<img v-if="comment.user?.avatar" :src="comment.user.avatar">
<span v-else>{{ (comment.user?.username || '?').charAt(0) }}</span>
</div>
<div class="c-content">
<div class="c-head">
<span class="c-name">{{ comment.user?.username || '?' }}</span>
<span class="c-time">Just now</span>
</div>
<div class="c-text">{{ comment.content }}</div>
</div>
</div>
<div v-if="comments.length === 0" class="no-comments">No comments yet. Be the first!</div>
</div>
</div>
</div>
</div>
<!-- Tip Modal -->
<div v-if="isTipOpen" class="modal-overlay" @click.self="isTipOpen = false">
<div class="modal-card">
<h3>Send Tip to {{ profile.username }}</h3>
<div class="form-row" style="display:grid; grid-template-columns: 120px 1fr; gap:10px; align-items:center; margin-bottom:10px;">
<label class="lbl">Currency</label>
<select v-model="tipForm.currency">
<option v-for="c in currencies" :key="c" :value="c">{{ c }}</option>
</select>
</div>
<div class="form-row" style="display:grid; grid-template-columns: 120px 1fr; gap:10px; align-items:center; margin-bottom:10px;">
<label class="lbl">Amount</label>
<input v-model="tipForm.amount" type="number" min="0" step="0.00000001" placeholder="0.00" class="input" />
</div>
<div class="form-row" style="margin-bottom:10px;">
<textarea v-model="tipForm.note" rows="2" placeholder="Add a note (optional)" class="input"></textarea>
</div>
<div v-if="tipForm.errors && Object.keys(tipForm.errors).length" class="msg err">{{ tipForm.errors.amount || Object.values(tipForm.errors)[0] }}</div>
<div class="modal-actions">
<button @click="isTipOpen = false" class="btn-cancel">Cancel</button>
<button @click="submitTip" class="btn-confirm" :disabled="tipForm.processing">{{ tipForm.processing ? 'Sending...' : 'Send Tip' }}</button>
</div>
</div>
</div>
<!-- Report Modal (teleported to body) -->
<teleport to="body">
<transition name="modal-fade">
<div v-if="isReportOpen" class="gc-report-overlay" @click.self="closeReportModal">
<div class="gc-report-modal" role="dialog" aria-modal="true" aria-labelledby="pr-title">
<!-- Success -->
<div v-if="reportDone" class="report-success">
<div class="success-ring">
<CheckCircle2 :size="36" />
</div>
<h3>Erfolgreich gemeldet</h3>
<p>Danke für deinen Hinweis.<br>Unser Moderationsteam wird den Fall prüfen.</p>
</div>
<template v-else>
<!-- Header -->
<div class="rm-head">
<div class="rm-head-left">
<div class="rm-head-icon">
<Flag :size="16" />
</div>
<span id="pr-title" class="rm-title">Profil melden</span>
</div>
<button class="rm-close" @click="closeReportModal" aria-label="Schließen">
<X :size="15" />
</button>
</div>
<!-- User card -->
<div class="rm-user">
<div class="rm-avatar-wrap">
<div class="rm-avatar">
<img v-if="profile.avatar" :src="profile.avatar" :alt="profile.username" />
<div v-else class="rm-avatar-fallback">{{ profile.username[0].toUpperCase() }}</div>
</div>
<div class="rm-avatar-glow"></div>
</div>
<div class="rm-user-info">
<span class="rm-username">@{{ profile.username }}</span>
<div class="rm-badges">
<span v-if="profile.role === 'admin'" class="rm-badge admin">ADMIN</span>
<span v-else-if="profile.role === 'staff' || profile.role === 'mod'" class="rm-badge staff">STAFF</span>
<span v-else-if="profile.role === 'streamer'" class="rm-badge streamer">STREAMER</span>
<span v-else class="rm-badge user">USER</span>
<span v-if="profile.vip_level > 0" class="rm-badge vip"> VIP {{ profile.vip_level }}</span>
<span v-if="profile.clan_tag" class="rm-badge clan">[{{ profile.clan_tag }}]</span>
</div>
</div>
</div>
<!-- Reason grid -->
<div class="rm-section">
<div class="rm-section-label">
<Flag :size="12" />
Meldegrund auswählen
</div>
<div class="rm-reasons">
<button
v-for="r in reportReasons"
:key="r.value"
class="rm-reason-btn"
:class="{ selected: reportReason === r.value }"
:style="reportReason === r.value ? `--reason-color: ${r.color}` : ''"
@click="reportReason = r.value"
>
<div class="reason-icon-wrap" :style="`color: ${r.color}`">
<component :is="r.icon" :size="18" />
</div>
<div class="reason-text">
<span class="reason-label">{{ r.label }}</span>
<span class="reason-desc">{{ r.desc }}</span>
</div>
<div class="reason-check-wrap" v-if="reportReason === r.value">
<CheckCircle2 :size="16" />
</div>
<ChevronRight v-else :size="14" class="reason-chevron" />
</button>
</div>
</div>
<!-- Actions -->
<div class="rm-actions">
<button class="rm-btn-cancel" @click="closeReportModal">
<X :size="14" />
Abbrechen
</button>
<button
class="rm-btn-submit"
:disabled="!reportReason || reportSubmitting"
@click="submitReport"
>
<Loader2 v-if="reportSubmitting" :size="15" class="rm-spin" />
<Flag v-else :size="15" />
{{ reportSubmitting ? 'Wird gesendet…' : 'Jetzt melden' }}
</button>
</div>
</template>
</div>
</div>
</transition>
</teleport>
</div>
</UserLayout>
</template>
<style scoped>
.profile-page { min-height: 100vh; background: #050505; padding-bottom: 60px; position: relative; overflow: hidden; }
/* Role Effects & Backgrounds */
.bg-fx { position: absolute; inset: 0; pointer-events: none; z-index: 0; opacity: 0; transition: opacity 0.5s; }
.role-admin .bg-fx {
opacity: 1;
background:
radial-gradient(circle at 20% 30%, rgba(255, 62, 62, 0.15), transparent 60%),
radial-gradient(circle at 80% 70%, rgba(255, 62, 62, 0.1), transparent 60%);
animation: pulse-bg-red 4s infinite alternate;
}
.role-staff .bg-fx {
opacity: 1;
background:
radial-gradient(circle at 20% 30%, rgba(0, 242, 255, 0.15), transparent 60%),
radial-gradient(circle at 80% 70%, rgba(0, 242, 255, 0.1), transparent 60%);
animation: pulse-bg-cyan 4s infinite alternate;
}
@keyframes pulse-bg-red { 0% { opacity: 0.6; transform: scale(1); } 100% { opacity: 1; transform: scale(1.05); } }
@keyframes pulse-bg-cyan { 0% { opacity: 0.6; transform: scale(1); } 100% { opacity: 1; transform: scale(1.05); } }
/* Glitter Text */
.username.role-admin {
text-shadow: 0 0 20px rgba(255, 62, 62, 0.8);
animation: glitter-red 2s infinite alternate;
}
.username.role-staff {
text-shadow: 0 0 20px rgba(0, 242, 255, 0.8);
animation: glitter-cyan 2s infinite alternate;
}
/* Badge Glitter */
.badge.role.role-admin {
box-shadow: 0 0 15px rgba(255, 62, 62, 0.5);
animation: border-pulse-red 1.5s infinite;
background: rgba(255, 62, 62, 0.15) !important;
}
.badge.role.role-staff {
box-shadow: 0 0 15px rgba(0, 242, 255, 0.5);
animation: border-pulse-cyan 1.5s infinite;
background: rgba(0, 242, 255, 0.15) !important;
}
@keyframes glitter-red { 0% { filter: brightness(1); text-shadow: 0 0 10px rgba(255,62,62,0.5); } 100% { filter: brightness(1.5); text-shadow: 0 0 25px rgba(255,62,62,1); } }
@keyframes glitter-cyan { 0% { filter: brightness(1); text-shadow: 0 0 10px rgba(0,242,255,0.5); } 100% { filter: brightness(1.5); text-shadow: 0 0 25px rgba(0,242,255,1); } }
@keyframes border-pulse-red { 0% { border-color: rgba(255,62,62,0.4); box-shadow: 0 0 5px rgba(255,62,62,0.2); } 100% { border-color: rgba(255,62,62,1); box-shadow: 0 0 20px rgba(255,62,62,0.6); } }
@keyframes border-pulse-cyan { 0% { border-color: rgba(0,242,255,0.4); box-shadow: 0 0 5px rgba(0,242,255,0.2); } 100% { border-color: rgba(0,242,255,1); box-shadow: 0 0 20px rgba(0,242,255,0.6); } }
.banner { height: 280px; background-size: cover; background-position: center; position: relative; z-index: 1; }
.banner-overlay { position: absolute; inset: 0; background: linear-gradient(to bottom, transparent 0%, #050505 100%); }
.content-container { max-width: 1100px; margin: -100px auto 0; padding: 0 20px; position: relative; z-index: 10; }
/* Header */
.profile-header { display: flex; align-items: flex-end; gap: 30px; margin-bottom: 50px; }
.avatar-wrapper { position: relative; }
.avatar {
width: 160px; height: 160px; border-radius: 50%; border: 6px solid #050505; background: #111;
overflow: hidden; display: flex; align-items: center; justify-content: center;
font-size: 56px; font-weight: 900; color: #333; transition: 0.3s;
}
.avatar img { width: 100%; height: 100%; object-fit: cover; }
.online-status { position: absolute; bottom: 12px; right: 12px; width: 24px; height: 24px; background: #00ff9d; border: 4px solid #050505; border-radius: 50%; box-shadow: 0 0 10px #00ff9d; }
.header-info { flex: 1; padding-bottom: 15px; }
.name-row { display: flex; align-items: center; gap: 16px; margin-bottom: 10px; flex-wrap: wrap; }
.username { font-size: 36px; font-weight: 900; color: #fff; letter-spacing: -1px; line-height: 1; display: flex; align-items: center; gap: 10px; }
.clan-tag { color: inherit; opacity: 0.8; font-size: 0.6em; background: rgba(255,255,255,0.1); padding: 2px 8px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.1); vertical-align: middle; }
.badges { display: flex; gap: 8px; }
.badge { font-size: 11px; font-weight: 900; padding: 4px 10px; border-radius: 6px; text-transform: uppercase; border: 1px solid transparent; letter-spacing: 0.5px; }
.bio { color: #888; font-size: 15px; max-width: 600px; line-height: 1.6; }
.header-actions { display: flex; gap: 12px; padding-bottom: 20px; }
.btn-action, .btn-edit { display: flex; align-items: center; gap: 8px; padding: 12px 20px; border-radius: 12px; font-weight: 800; font-size: 13px; cursor: pointer; transition: 0.2s; border: none; }
.btn-action { background: #111; border: 1px solid #333; color: #fff; }
.btn-action:hover { background: #1a1a1a; border-color: #555; }
.btn-action.like.active { color: #ff007a; border-color: #ff007a; background: rgba(255,0,122,0.1); }
.btn-action.friend { background: #ff007a; color: #fff; border-color: #ff007a; }
.btn-action.friend:hover { background: #d40065; }
.btn-action.friend.added { background: #111; border-color: #333; color: #fff; }
.btn-action.friend.accept { background: #00c853; border-color: #00c853; }
.btn-action.friend.accept:hover { background: #00a844; }
.btn-action.friend.decline { background: #111; border-color: #ff3e3e; color: #ff3e3e; }
.btn-action.friend.decline:hover { background: rgba(255,62,62,0.1); }
.btn-action.report:hover { color: #ff3e3e; border-color: #ff3e3e; }
.btn-action.trophy:hover { color: #ffd700; border-color: #ffd700; }
.btn-edit { background: #111; border: 1px solid #333; color: #fff; text-decoration: none; }
.btn-edit:hover { background: #1a1a1a; border-color: #444; }
/* Stats Grid */
.stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; margin-bottom: 50px; }
.stat-card { background: #0f0f0f; border: 1px solid #1f1f1f; padding: 24px; border-radius: 20px; display: flex; align-items: center; gap: 16px; transition: 0.3s; position: relative; overflow: hidden; }
.stat-card:hover { transform: translateY(-4px); border-color: #333; box-shadow: 0 10px 30px -10px rgba(0,0,0,0.5); }
.stat-card.highlight { background: linear-gradient(135deg, rgba(255,0,122,0.05), transparent); border-color: rgba(255,0,122,0.2); }
.stat-icon { width: 52px; height: 52px; background: #18181b; border-radius: 14px; display: flex; align-items: center; justify-content: center; color: #666; flex-shrink: 0; }
.stat-card.highlight .stat-icon { color: #ff007a; background: rgba(255,0,122,0.1); }
.stat-label { font-size: 11px; font-weight: 700; color: #666; text-transform: uppercase; margin-bottom: 4px; letter-spacing: 0.5px; }
.stat-value { font-size: 20px; font-weight: 900; color: #fff; }
.stat-sub { font-size: 11px; color: #555; margin-top: 2px; }
.text-magenta { color: #ff007a; }
.text-gold { color: #ffd700; }
/* Columns */
.profile-cols { display: grid; grid-template-columns: 1fr 350px; gap: 30px; }
/* Wins Section */
.col-wins h3, .col-comments h3 { font-size: 20px; font-weight: 800; color: #fff; margin-bottom: 24px; display: flex; align-items: center; gap: 10px; }
.wins-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 20px; }
.win-card { background: #0f0f0f; border: 1px solid #1f1f1f; border-radius: 16px; overflow: hidden; transition: 0.3s; position: relative; }
.win-card:hover { transform: translateY(-4px); border-color: #ffd700; box-shadow: 0 10px 40px -10px rgba(255, 215, 0, 0.15); }
.win-img { height: 140px; background-size: cover; background-position: center; position: relative; }
.win-img::after { content: ''; position: absolute; inset: 0; background: linear-gradient(to bottom, transparent, #0f0f0f); }
.win-info { padding: 16px; position: relative; z-index: 2; margin-top: -40px; }
.win-amount { font-size: 20px; font-weight: 900; margin-bottom: 4px; text-shadow: 0 2px 10px rgba(0,0,0,0.8); }
.win-game { font-size: 12px; color: #ccc; font-weight: 600; }
.win-multi { position: absolute; top: 16px; right: 16px; background: rgba(255, 215, 0, 0.1); color: #ffd700; border: 1px solid rgba(255, 215, 0, 0.3); padding: 4px 8px; border-radius: 6px; font-size: 11px; font-weight: 800; }
.win-card.empty { height: 200px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; color: #333; border: 1px dashed #222; }
.win-card.empty i { width: 32px; height: 32px; }
/* Comments */
.comment-input { display: flex; gap: 10px; background: #111; padding: 10px; border-radius: 12px; border: 1px solid #222; margin-bottom: 20px; }
.comment-input textarea { flex: 1; background: transparent; border: none; color: #fff; font-size: 13px; resize: none; outline: none; }
.comment-input button { background: #ff007a; color: #fff; border: none; width: 36px; height: 36px; border-radius: 8px; cursor: pointer; display: flex; align-items: center; justify-content: center; }
.comment-input button:hover { background: #d40065; }
.comments-list { display: flex; flex-direction: column; gap: 16px; }
.comment-item { display: flex; gap: 12px; }
.c-avatar { width: 36px; height: 36px; border-radius: 10px; background: #222; display: flex; align-items: center; justify-content: center; font-weight: 700; color: #fff; overflow: hidden; flex-shrink: 0; }
.c-avatar img { width: 100%; height: 100%; object-fit: cover; }
.c-content { background: #111; padding: 12px; border-radius: 12px; border: 1px solid #222; flex: 1; }
.c-head { display: flex; justify-content: space-between; margin-bottom: 4px; }
.c-name { font-size: 12px; font-weight: 700; color: #fff; }
.c-time { font-size: 10px; color: #555; }
.c-text { font-size: 13px; color: #ccc; line-height: 1.4; }
.no-comments { text-align: center; color: #444; font-size: 12px; padding: 20px; }
/* Modal */
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.8); z-index: 100; display: flex; align-items: center; justify-content: center; backdrop-filter: blur(5px); }
.modal-card { background: #111; border: 1px solid #333; padding: 24px; border-radius: 16px; width: 100%; max-width: 400px; }
.modal-card h3 { color: #fff; font-size: 18px; font-weight: 800; margin-bottom: 16px; }
.modal-card select { width: 100%; background: #000; border: 1px solid #333; color: #fff; padding: 12px; border-radius: 8px; margin-bottom: 20px; outline: none; }
.modal-actions { display: flex; gap: 10px; justify-content: flex-end; }
.btn-cancel { background: transparent; border: 1px solid #333; color: #ccc; padding: 10px 16px; border-radius: 8px; cursor: pointer; }
.btn-confirm { background: #ff3e3e; border: none; color: #fff; padding: 10px 16px; border-radius: 8px; cursor: pointer; font-weight: 700; }
@media (max-width: 900px) {
.stats-grid { grid-template-columns: 1fr 1fr; }
.profile-cols { grid-template-columns: 1fr; }
}
@media (max-width: 768px) {
.profile-header { flex-direction: column; align-items: center; text-align: center; margin-top: -80px; }
.header-info { padding-bottom: 0; width: 100%; }
.name-row { justify-content: center; }
.header-actions { width: 100%; justify-content: center; flex-wrap: wrap; }
.avatar { width: 120px; height: 120px; font-size: 40px; }
.banner { height: 200px; }
}
/* Extra small phones */
@media (max-width: 420px) {
.username { font-size: clamp(18px, 7vw, 24px); word-break: break-word; }
.bio { font-size: 14px; }
.wins-grid { grid-template-columns: 1fr; }
.stats-grid { grid-template-columns: 1fr 1fr; }
}
/* ===== Report Modal ===== */
.gc-report-overlay {
position: fixed; inset: 0;
background: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(8px) saturate(0.8);
-webkit-backdrop-filter: blur(8px) saturate(0.8);
z-index: 2147483647;
display: flex; align-items: center; justify-content: center;
padding: 16px;
}
.gc-report-modal {
background: #0c0c0e;
border: 1px solid rgba(255,255,255,0.07);
border-radius: 22px;
width: 100%; max-width: 460px;
box-shadow: 0 40px 100px rgba(0,0,0,0.9), 0 0 0 1px rgba(223,0,106,0.05);
overflow: hidden;
animation: modal-pop 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes modal-pop {
from { opacity: 0; transform: scale(0.9) translateY(16px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
.modal-fade-enter-active { transition: opacity 0.25s ease; }
.modal-fade-leave-active { transition: opacity 0.15s ease; }
.modal-fade-enter-from, .modal-fade-leave-to { opacity: 0; }
.rm-head {
display: flex; align-items: center; justify-content: space-between;
padding: 16px 18px;
background: linear-gradient(135deg, rgba(223,0,106,0.12), rgba(0,0,0,0));
border-bottom: 1px solid rgba(255,255,255,0.06);
}
.rm-head-left { display: flex; align-items: center; gap: 10px; }
.rm-head-icon {
width: 30px; height: 30px; border-radius: 9px;
background: rgba(223,0,106,0.18); border: 1px solid rgba(223,0,106,0.35);
color: #df006a; display: flex; align-items: center; justify-content: center;
}
.rm-title { font-size: 14px; font-weight: 800; color: #fff; letter-spacing: 0.2px; }
.rm-close {
width: 28px; height: 28px; border-radius: 8px;
background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.08);
color: #555; cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: 0.2s; flex-shrink: 0;
}
.rm-close:hover { background: rgba(255,255,255,0.1); color: #fff; }
.rm-user {
display: flex; align-items: center; gap: 14px;
padding: 14px 18px;
background: rgba(255,255,255,0.02);
border-bottom: 1px solid rgba(255,255,255,0.05);
}
.rm-avatar-wrap { position: relative; flex-shrink: 0; }
.rm-avatar {
width: 52px; height: 52px; border-radius: 14px; overflow: hidden;
border: 2px solid rgba(255,255,255,0.08); background: #151515; position: relative; z-index: 1;
}
.rm-avatar img { width: 100%; height: 100%; object-fit: cover; display: block; }
.rm-avatar-fallback {
width: 100%; height: 100%; display: flex; align-items: center; justify-content: center;
font-size: 20px; font-weight: 900; color: #555;
}
.rm-avatar-glow {
position: absolute; inset: -4px; border-radius: 18px; z-index: 0;
background: radial-gradient(circle, rgba(223,0,106,0.25), transparent 70%);
filter: blur(6px);
}
.rm-user-info { display: flex; flex-direction: column; gap: 7px; min-width: 0; }
.rm-username { font-size: 15px; font-weight: 800; color: #fff; }
.rm-badges { display: flex; gap: 5px; flex-wrap: wrap; }
.rm-badge {
font-size: 9px; font-weight: 800; padding: 2px 8px; border-radius: 5px;
text-transform: uppercase; letter-spacing: 0.5px; border: 1px solid;
}
.rm-badge.admin { color: #ff3e3e; border-color: rgba(255,62,62,0.35); background: rgba(255,62,62,0.08); }
.rm-badge.staff { color: #3b82f6; border-color: rgba(59,130,246,0.35); background: rgba(59,130,246,0.08); }
.rm-badge.streamer { color: #a855f7; border-color: rgba(168,85,247,0.35); background: rgba(168,85,247,0.08); }
.rm-badge.user { color: #555; border-color: rgba(255,255,255,0.08); background: rgba(255,255,255,0.03); }
.rm-badge.vip { color: #ffd700; border-color: rgba(255,215,0,0.35); background: rgba(255,215,0,0.08); }
.rm-badge.clan { color: #00f2ff; border-color: rgba(0,242,255,0.35); background: rgba(0,242,255,0.08); }
.rm-section { padding: 14px 18px; border-bottom: 1px solid rgba(255,255,255,0.05); }
.rm-section-label {
display: flex; align-items: center; gap: 6px;
font-size: 10px; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.7px; color: #444; margin-bottom: 10px;
}
.rm-reasons { display: flex; flex-direction: column; gap: 7px; }
.rm-reason-btn {
display: flex; align-items: center; gap: 12px;
padding: 11px 14px; border-radius: 12px;
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.06);
color: #888; cursor: pointer; transition: all 0.18s ease;
text-align: left; width: 100%;
}
.rm-reason-btn:hover {
background: rgba(255,255,255,0.06);
border-color: rgba(255,255,255,0.12);
color: #ddd;
transform: translateX(2px);
}
.rm-reason-btn.selected {
background: rgba(var(--reason-color, 223 0 106) / 0.1);
border-color: color-mix(in srgb, var(--reason-color, #df006a) 50%, transparent);
color: #fff;
}
.reason-icon-wrap {
width: 34px; height: 34px; border-radius: 10px;
background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.06);
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
transition: 0.18s;
}
.rm-reason-btn.selected .reason-icon-wrap {
background: rgba(var(--reason-color, 223 0 106) / 0.15);
border-color: color-mix(in srgb, var(--reason-color, #df006a) 40%, transparent);
}
.reason-text { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
.reason-label { font-size: 13px; font-weight: 700; color: inherit; }
.reason-desc { font-size: 11px; color: #555; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.rm-reason-btn.selected .reason-desc { color: #888; }
.reason-check-wrap { color: #22c55e; flex-shrink: 0; }
.reason-chevron { color: #333; flex-shrink: 0; transition: 0.18s; }
.rm-reason-btn:hover .reason-chevron { color: #555; transform: translateX(2px); }
.rm-actions {
display: flex; gap: 10px;
padding: 14px 18px 18px;
}
.rm-btn-cancel {
flex: 1; padding: 11px 14px; border-radius: 11px;
background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08);
color: #888; cursor: pointer; font-size: 13px; font-weight: 700;
transition: 0.2s; display: flex; align-items: center; justify-content: center; gap: 7px;
}
.rm-btn-cancel:hover { background: rgba(255,255,255,0.08); color: #ccc; }
.rm-btn-submit {
flex: 2; padding: 11px 14px; border-radius: 11px;
background: linear-gradient(135deg, #df006a, #a8004e);
border: 1px solid rgba(223,0,106,0.5);
color: #fff; cursor: pointer; font-size: 13px; font-weight: 800;
transition: 0.2s; display: flex; align-items: center; justify-content: center; gap: 8px;
box-shadow: 0 4px 20px rgba(223,0,106,0.25);
}
.rm-btn-submit:hover:not(:disabled) {
background: linear-gradient(135deg, #f2007a, #c0005c);
box-shadow: 0 6px 30px rgba(223,0,106,0.45);
transform: translateY(-1px);
}
.rm-btn-submit:disabled { opacity: 0.35; cursor: not-allowed; box-shadow: none; }
.rm-spin { animation: rm-spin 0.8s linear infinite; }
@keyframes rm-spin { to { transform: rotate(360deg); } }
.report-success {
padding: 44px 24px 40px;
display: flex; flex-direction: column; align-items: center; gap: 14px; text-align: center;
}
.success-ring {
width: 64px; height: 64px; border-radius: 50%;
background: radial-gradient(circle, rgba(34,197,94,0.2), rgba(34,197,94,0.05));
border: 2px solid rgba(34,197,94,0.4);
color: #22c55e; display: flex; align-items: center; justify-content: center;
box-shadow: 0 0 30px rgba(34,197,94,0.25);
animation: success-bounce 0.5s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes success-bounce {
0% { transform: scale(0); opacity: 0; }
60% { transform: scale(1.12); }
100% { transform: scale(1); opacity: 1; }
}
.report-success h3 { font-size: 18px; font-weight: 900; color: #fff; margin: 0; }
.report-success p { font-size: 13px; color: #666; margin: 0; line-height: 1.6; }
@media (max-width: 480px) {
.gc-report-overlay { align-items: flex-end; padding: 0; }
.gc-report-modal { border-radius: 22px 22px 0 0; max-width: 100%; }
.reason-desc { display: none; }
}
</style>

View File

@@ -0,0 +1,508 @@
<script setup lang="ts">
import { ref, computed } from 'vue';
import { useForm, Head } from '@inertiajs/vue3';
import UserLayout from '@/layouts/user/userlayout.vue';
import { useNotifications } from '@/composables/useNotifications';
// Declare route globally for TypeScript if needed, or assume it's available
declare function route(name: string, params?: any): string;
const props = defineProps<{
user: any;
}>();
const { notify } = useNotifications();
// Helper to access global route function safely with fallback
const getRoute = (name: string, params?: any) => {
// @ts-ignore
if (typeof window.route === 'function') {
// @ts-ignore
return window.route(name, params);
}
// Fallback for known routes if Ziggy fails
if (name === 'profile.update') return '/profile/update';
if (name === 'profile.upload') return '/profile/upload';
console.error('Ziggy route function not found on window and no fallback for:', name);
return '';
};
const form = useForm({
is_public: props.user.is_public || false,
bio: props.user.bio || '',
avatar: props.user.avatar || '',
banner: props.user.banner || '',
});
const isEditingBio = ref(false);
const isEditingAvatar = ref(false);
const isEditingBanner = ref(false);
const isUploading = ref(false);
const save = () => {
const url = getRoute('profile.update');
if (!url) return;
form.post(url, {
preserveScroll: true,
onSuccess: () => {
isEditingBio.value = false;
isEditingAvatar.value = false;
isEditingBanner.value = false;
notify({ type: 'green', title: 'Saved', desc: 'Profile updated successfully.', icon: 'check' });
},
});
};
const togglePublic = () => {
form.is_public = !form.is_public;
save();
};
const handleUpload = async (event: Event, type: 'avatar' | 'banner') => {
const file = (event.target as HTMLInputElement).files?.[0];
if (!file) return;
isUploading.value = true;
const formData = new FormData();
formData.append('file', file);
formData.append('type', type);
try {
const url = getRoute('profile.upload');
if (!url) throw new Error('Route not found');
const res = await fetch(url, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement)?.content || '',
'Accept': 'application/json',
},
body: formData,
});
if (!res.ok) {
let errorMsg = 'Upload failed';
try {
const errorData = await res.json();
errorMsg = errorData.message || errorMsg;
} catch {}
throw new Error(errorMsg);
}
const data = await res.json();
if (type === 'avatar') form.avatar = data.url;
else form.banner = data.url;
notify({ type: 'green', title: 'Uploaded', desc: `${type} updated successfully.`, icon: 'upload' });
// Close popover
if (type === 'avatar') isEditingAvatar.value = false;
if (type === 'banner') isEditingBanner.value = false;
} catch (e: any) {
notify({ type: 'red', title: 'Error', desc: e.message, icon: 'alert-triangle' });
} finally {
isUploading.value = false;
}
};
// Rank Logic
const vipLevelsConfig = [
{ name: 'Newbie', color: '#888888' },
{ name: 'Bronze', color: '#cd7f32' },
{ name: 'Silver', color: '#c0c0c0' },
{ name: 'Gold', color: '#ffd700' },
{ name: 'Platinum', color: '#00f2ff' },
{ name: 'Diamond', color: '#ff007a' },
{ name: 'Obsidian', color: '#ff3e3e' }
];
const currentVipStyle = computed(() => {
const level = props.user.vip_level || 0;
const idx = Math.min(Math.max(level, 0), vipLevelsConfig.length - 1);
return vipLevelsConfig[idx];
});
// Role Logic
const roleConfig = computed(() => {
const role = props.user.role || 'User';
let color = '#888';
let effectClass = '';
if (role === 'Admin') {
color = '#ff3e3e';
effectClass = 'role-admin';
} else if (role === 'Mod' || role === 'Staff') {
color = '#00f2ff';
effectClass = 'role-staff';
} else if (role === 'Streamer') {
color = '#a855f7';
effectClass = 'role-streamer';
}
return { name: role, color, effectClass };
});
// Profile URL Logic
const profileUrl = computed(() => {
return `${window.location.origin}/profile/${props.user.username}`;
});
const copyProfileUrl = () => {
navigator.clipboard.writeText(profileUrl.value);
notify({ type: 'green', title: 'Copied', desc: 'Profile URL copied to clipboard.', icon: 'copy' });
};
// Mock stats for preview
const stats = {
wagered: 12500.50,
wins: 450,
losses: 320,
favorite_game: 'Gates of Olympus',
last_played: 'Sweet Bonanza',
};
</script>
<template>
<UserLayout>
<Head title="Edit Profile" />
<div class="edit-profile-page" :class="roleConfig.effectClass">
<div class="bg-fx"></div>
<!-- Banner Editor -->
<div class="banner-editor group" :style="{ backgroundImage: `url(${form.banner || '/img/default-banner.jpg'})` }">
<div class="banner-overlay"></div>
<div class="edit-trigger" @click="isEditingBanner = !isEditingBanner">
<i data-lucide="camera"></i>
<span>Edit Banner</span>
</div>
<div v-if="isEditingBanner" class="edit-popover banner-pop">
<div class="pop-tabs">
<label class="pop-tab">
<input type="file" class="hidden" accept=".jpg,.jpeg,.png,.gif,.webp,.bmp,image/jpeg,image/png,image/gif,image/webp,image/bmp" @change="handleUpload($event, 'banner')">
<i data-lucide="upload"></i> Upload
</label>
<div class="pop-divider">or</div>
<input type="text" v-model="form.banner" placeholder="Paste URL..." class="edit-input" @keyup.enter="save">
<button @click="save" class="save-btn"><i data-lucide="check"></i></button>
</div>
<p class="upload-hint"> JPG &nbsp; PNG &nbsp; GIF (animiert) &nbsp; WebP &nbsp; BMP &nbsp;&middot;&nbsp; Max. <b style="color:#888">5 MB</b></p>
</div>
</div>
<div class="content-container">
<!-- Header Section -->
<div class="profile-header">
<!-- Avatar Editor -->
<div class="avatar-wrapper group">
<div class="avatar" :style="{ borderColor: roleConfig.name !== 'User' ? roleConfig.color : currentVipStyle.color, boxShadow: `0 0 20px ${roleConfig.name !== 'User' ? roleConfig.color : currentVipStyle.color}40` }">
<img v-if="form.avatar" :src="form.avatar" alt="Avatar">
<span v-else>{{ user.username.charAt(0) }}</span>
<div v-if="isUploading" class="upload-overlay"><span class="spinner"></span></div>
</div>
<div class="avatar-edit-overlay" @click="isEditingAvatar = !isEditingAvatar">
<i data-lucide="camera"></i>
</div>
<div v-if="isEditingAvatar" class="edit-popover avatar-pop">
<div class="pop-tabs">
<label class="pop-tab">
<input type="file" class="hidden" accept=".jpg,.jpeg,.png,.gif,.webp,.bmp,image/jpeg,image/png,image/gif,image/webp,image/bmp" @change="handleUpload($event, 'avatar')">
<i data-lucide="upload"></i>
</label>
<input type="text" v-model="form.avatar" placeholder="URL..." class="edit-input" @keyup.enter="save">
<button @click="save" class="save-btn"><i data-lucide="check"></i></button>
</div>
<p class="upload-hint"> JPG &nbsp; PNG &nbsp; GIF (animiert) &nbsp; WebP &nbsp; BMP &nbsp;&middot;&nbsp; Max. <b style="color:#888">5 MB</b></p>
</div>
</div>
<div class="header-info">
<div class="name-row">
<h1 class="username" :class="roleConfig.effectClass" :style="{ color: roleConfig.name !== 'User' ? roleConfig.color : '#fff' }">
<span v-if="user.clan_tag" class="clan-tag">[{{ user.clan_tag }}]</span>
{{ user.username }}
</h1>
<div class="badges">
<span class="badge vip" :style="{ color: currentVipStyle.color, borderColor: currentVipStyle.color, background: `${currentVipStyle.color}15` }">
VIP {{ user.vip_level || 0 }}
</span>
<span class="badge role" :class="roleConfig.effectClass" :style="{ color: roleConfig.color, borderColor: roleConfig.color, background: `${roleConfig.color}15` }">
{{ roleConfig.name }}
</span>
</div>
</div>
<!-- Bio Editor -->
<div class="bio-editor">
<div v-if="!isEditingBio" class="bio-display" @click="isEditingBio = true">
{{ form.bio || 'Click here to add a bio...' }}
<i data-lucide="edit-2" class="edit-icon"></i>
</div>
<div v-else class="bio-input-wrap">
<textarea v-model="form.bio" rows="2" class="bio-input" placeholder="Tell us about yourself..." maxlength="160"></textarea>
<div class="bio-actions">
<span class="char-count">{{ form.bio.length }}/160</span>
<button @click="save" class="bio-save">Save</button>
</div>
</div>
</div>
</div>
<div class="header-actions">
<div class="public-toggle" @click="togglePublic">
<span class="toggle-label">Public Profile</span>
<div class="toggle-switch" :class="{ active: form.is_public }">
<div class="toggle-knob"></div>
</div>
</div>
<!-- Copy Profile URL -->
<div class="url-copy-box" @click="copyProfileUrl">
<i data-lucide="link"></i>
<span>Copy Link</span>
</div>
</div>
</div>
<!-- Stats Grid (Preview) -->
<div class="stats-grid opacity-50 pointer-events-none grayscale">
<div class="stat-card highlight">
<div class="stat-icon"><i data-lucide="coins"></i></div>
<div class="stat-data">
<div class="stat-label">Total Wagered</div>
<div class="stat-value">${{ stats.wagered.toFixed(2) }}</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon"><i data-lucide="trophy"></i></div>
<div class="stat-data">
<div class="stat-label">Total Wins</div>
<div class="stat-value">{{ stats.wins }}</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon"><i data-lucide="heart"></i></div>
<div class="stat-data">
<div class="stat-label">Favorite Game</div>
<div class="stat-value text-magenta">{{ stats.favorite_game }}</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon"><i data-lucide="clock"></i></div>
<div class="stat-data">
<div class="stat-label">Last Played</div>
<div class="stat-value">{{ stats.last_played }}</div>
</div>
</div>
</div>
<div class="preview-hint">
<i data-lucide="info"></i> Stats are just a preview here.
</div>
</div>
</div>
</UserLayout>
</template>
<style scoped>
.edit-profile-page { min-height: 100vh; background: #050505; padding-bottom: 100px; position: relative; overflow: hidden; }
/* Role Effects & Backgrounds */
.bg-fx { position: absolute; inset: 0; pointer-events: none; z-index: 0; opacity: 0; transition: opacity 0.5s; }
.role-admin .bg-fx {
opacity: 1;
background:
radial-gradient(circle at 20% 30%, rgba(255, 62, 62, 0.08), transparent 50%),
radial-gradient(circle at 80% 70%, rgba(255, 62, 62, 0.05), transparent 50%);
animation: pulse-bg-red 5s infinite alternate;
}
.role-staff .bg-fx {
opacity: 1;
background:
radial-gradient(circle at 20% 30%, rgba(0, 242, 255, 0.08), transparent 50%),
radial-gradient(circle at 80% 70%, rgba(0, 242, 255, 0.05), transparent 50%);
animation: pulse-bg-cyan 5s infinite alternate;
}
@keyframes pulse-bg-red { 0% { opacity: 0.8; } 100% { opacity: 1; } }
@keyframes pulse-bg-cyan { 0% { opacity: 0.8; } 100% { opacity: 1; } }
/* Glitter Text */
.username.role-admin { text-shadow: 0 0 15px rgba(255, 62, 62, 0.6); animation: glitter-red 3s infinite; }
.username.role-staff { text-shadow: 0 0 15px rgba(0, 242, 255, 0.6); animation: glitter-cyan 3s infinite; }
/* Badge Glitter */
.badge.role.role-admin { box-shadow: 0 0 10px rgba(255, 62, 62, 0.4); animation: border-pulse-red 2s infinite; }
.badge.role.role-staff { box-shadow: 0 0 10px rgba(0, 242, 255, 0.4); animation: border-pulse-cyan 2s infinite; }
@keyframes glitter-red { 0%, 100% { filter: brightness(1); } 50% { filter: brightness(1.3); } }
@keyframes glitter-cyan { 0%, 100% { filter: brightness(1); } 50% { filter: brightness(1.3); } }
@keyframes border-pulse-red { 0%, 100% { border-color: rgba(255,62,62,0.3); } 50% { border-color: rgba(255,62,62,0.8); } }
@keyframes border-pulse-cyan { 0%, 100% { border-color: rgba(0,242,255,0.3); } 50% { border-color: rgba(0,242,255,0.8); } }
/* Banner Editor */
.banner-editor {
height: 300px; background-size: cover; background-position: center; position: relative;
border-bottom: 1px solid #222; transition: 0.3s; z-index: 1;
}
.banner-editor:hover .banner-overlay { opacity: 0.4; }
.banner-overlay { position: absolute; inset: 0; background: #000; opacity: 0.2; transition: 0.3s; }
.edit-trigger {
position: absolute; top: 20px; right: 20px; background: rgba(0,0,0,0.6); backdrop-filter: blur(10px);
padding: 10px 16px; border-radius: 12px; color: #fff; font-weight: 700; font-size: 13px;
display: flex; align-items: center; gap: 8px; cursor: pointer; border: 1px solid rgba(255,255,255,0.1);
transition: 0.2s; opacity: 0; transform: translateY(-10px);
}
.banner-editor:hover .edit-trigger { opacity: 1; transform: translateY(0); }
.edit-trigger:hover { background: rgba(255,0,122,0.8); border-color: #ff007a; }
.edit-trigger i { width: 16px; height: 16px; }
/* Edit Popover */
.edit-popover {
position: absolute; background: #111; border: 1px solid #333; padding: 8px; border-radius: 12px;
display: flex; flex-direction: column; gap: 8px; box-shadow: 0 10px 40px rgba(0,0,0,0.5); z-index: 20;
animation: popIn 0.2s cubic-bezier(0.16, 1, 0.3, 1);
}
.banner-pop { top: 70px; right: 20px; width: 320px; }
.avatar-pop { bottom: -60px; left: 50%; transform: translateX(-50%); width: 280px; }
.pop-tabs { display: flex; align-items: center; gap: 8px; }
.pop-tab {
background: #222; color: #ccc; padding: 8px 12px; border-radius: 8px; font-size: 12px; font-weight: 700;
cursor: pointer; display: flex; align-items: center; gap: 6px; transition: 0.2s;
}
.pop-tab:hover { background: #333; color: #fff; }
.pop-tab i { width: 14px; }
.pop-divider { font-size: 10px; color: #555; font-weight: 700; text-transform: uppercase; }
.upload-hint { font-size: 10px; color: #555; margin: 0; text-align: center; }
.edit-input {
flex: 1; background: #000; border: 1px solid #222; color: #fff; padding: 8px 12px; border-radius: 8px; font-size: 13px; width: 100%;
}
.edit-input:focus { border-color: #ff007a; outline: none; }
.save-btn {
background: #ff007a; color: #fff; border: none; width: 36px; height: 36px; border-radius: 8px; cursor: pointer;
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.save-btn:hover { background: #d40065; }
/* Content Container */
.content-container { max-width: 1100px; margin: -80px auto 0; padding: 0 20px; position: relative; z-index: 10; }
/* Header */
.profile-header { display: flex; align-items: flex-end; gap: 30px; margin-bottom: 40px; }
/* Avatar Editor */
.avatar-wrapper { position: relative; cursor: pointer; }
.avatar {
width: 160px; height: 160px; border-radius: 50%; border: 6px solid #050505; background: #111;
overflow: hidden; display: flex; align-items: center; justify-content: center;
font-size: 48px; font-weight: 900; color: #333; box-shadow: 0 10px 30px rgba(0,0,0,0.5);
transition: 0.3s; position: relative;
}
.avatar img { width: 100%; height: 100%; object-fit: cover; }
.upload-overlay { position: absolute; inset: 0; background: rgba(0,0,0,0.7); display: flex; align-items: center; justify-content: center; }
.spinner { width: 24px; height: 24px; border: 3px solid rgba(255,255,255,0.3); border-top-color: #fff; border-radius: 50%; animation: spin 1s linear infinite; }
.avatar-edit-overlay {
position: absolute; inset: 6px; border-radius: 50%; background: rgba(0,0,0,0.6);
display: flex; align-items: center; justify-content: center; color: #fff; opacity: 0;
transition: 0.2s; backdrop-filter: blur(2px);
}
.avatar-wrapper:hover .avatar-edit-overlay { opacity: 1; }
.avatar-edit-overlay i { width: 32px; height: 32px; }
/* Info */
.header-info { flex: 1; padding-bottom: 10px; }
.name-row { display: flex; align-items: center; gap: 16px; margin-bottom: 12px; flex-wrap: wrap; }
.username { font-size: 32px; font-weight: 900; color: #fff; display: flex; align-items: center; gap: 10px; }
.clan-tag { color: inherit; opacity: 0.8; font-size: 0.6em; background: rgba(255,255,255,0.1); padding: 2px 8px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.1); vertical-align: middle; }
.badge.vip { font-size: 11px; font-weight: 900; padding: 4px 10px; border-radius: 6px; text-transform: uppercase; background: rgba(255,0,122,0.1); color: #ff007a; border: 1px solid rgba(255,0,122,0.2); }
.badges { display: flex; gap: 8px; }
.badge { font-size: 11px; font-weight: 900; padding: 4px 10px; border-radius: 6px; text-transform: uppercase; border: 1px solid transparent; letter-spacing: 0.5px; }
/* Bio Editor */
.bio-display {
color: #888; font-size: 15px; max-width: 600px; line-height: 1.5; cursor: pointer;
padding: 8px 12px; border-radius: 8px; border: 1px dashed transparent; transition: 0.2s;
display: inline-flex; align-items: center; gap: 8px;
}
.bio-display:hover { border-color: #333; background: #111; color: #aaa; }
.edit-icon { width: 14px; height: 14px; opacity: 0.5; }
.bio-input-wrap { max-width: 600px; background: #111; border: 1px solid #333; border-radius: 12px; padding: 12px; }
.bio-input { width: 100%; background: transparent; border: none; color: #fff; font-size: 15px; resize: none; font-family: inherit; }
.bio-actions { display: flex; justify-content: space-between; align-items: center; margin-top: 8px; }
.char-count { font-size: 11px; color: #555; }
.bio-save { background: #ff007a; color: #fff; border: none; padding: 6px 16px; border-radius: 6px; font-size: 12px; font-weight: 700; cursor: pointer; }
.bio-save:hover { background: #d40065; }
/* Public Toggle & Copy */
.header-actions { padding-bottom: 20px; display: flex; flex-direction: column; gap: 12px; align-items: flex-end; }
.public-toggle {
display: flex; align-items: center; gap: 12px; background: #111; padding: 10px 16px;
border-radius: 12px; border: 1px solid #222; cursor: pointer; transition: 0.2s;
}
.public-toggle:hover { border-color: #333; }
.toggle-label { font-size: 13px; font-weight: 700; color: #ccc; }
.toggle-switch { width: 44px; height: 24px; background: #333; border-radius: 99px; position: relative; transition: 0.3s; }
.toggle-switch.active { background: #ff007a; }
.toggle-knob {
width: 18px; height: 18px; background: #fff; border-radius: 50%; position: absolute;
top: 3px; left: 3px; transition: 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
.toggle-switch.active .toggle-knob { transform: translateX(20px); }
.url-copy-box {
display: flex; align-items: center; gap: 8px; background: #111; padding: 8px 12px;
border-radius: 8px; border: 1px solid #222; cursor: pointer; transition: 0.2s;
color: #888; font-size: 12px; font-weight: 700;
}
.url-copy-box:hover { color: #fff; border-color: #333; }
.url-copy-box i { width: 14px; height: 14px; }
/* Stats Grid (Preview) */
.stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; margin-bottom: 20px; }
.stat-card { background: #0f0f0f; border: 1px solid #1f1f1f; padding: 20px; border-radius: 16px; display: flex; align-items: center; gap: 16px; }
.stat-card.highlight { background: linear-gradient(135deg, rgba(255,0,122,0.05), transparent); border-color: rgba(255,0,122,0.2); }
.stat-icon { width: 48px; height: 48px; background: #18181b; border-radius: 12px; display: flex; align-items: center; justify-content: center; color: #666; }
.stat-card.highlight .stat-icon { color: #ff007a; background: rgba(255,0,122,0.1); }
.stat-label { font-size: 11px; font-weight: 700; color: #666; text-transform: uppercase; margin-bottom: 4px; }
.stat-value { font-size: 18px; font-weight: 900; color: #fff; }
.text-magenta { color: #ff007a; }
.preview-hint { text-align: center; color: #444; font-size: 12px; display: flex; align-items: center; justify-content: center; gap: 6px; margin-top: 20px; }
.preview-hint i { width: 14px; }
@keyframes popIn { from { opacity: 0; transform: scale(0.9); } to { opacity: 1; transform: scale(1); } }
@keyframes spin { to { transform: rotate(360deg); } }
@media (max-width: 768px) {
.profile-header { flex-direction: column; align-items: center; text-align: center; margin-top: -60px; }
.header-info { padding-bottom: 0; width: 100%; display: flex; flex-direction: column; align-items: center; }
.name-row { justify-content: center; }
.stats-grid { grid-template-columns: 1fr 1fr; }
.banner-editor { height: 200px; }
.avatar { width: 120px; height: 120px; }
}
/* Mobile tweaks */
@media (max-width: 420px) {
.username { font-size: clamp(18px, 7vw, 24px); word-break: break-word; }
.stats-grid { grid-template-columns: 1fr 1fr; }
.banner-pop { width: min(92vw, 320px); right: 12px; }
.avatar-pop { width: min(92vw, 280px); left: 50%; transform: translateX(-50%); }
.edit-trigger { padding: 8px 12px; font-size: 12px; top: 12px; right: 12px; }
.content-container { margin-top: -50px; }
}
</style>

View File

@@ -0,0 +1,610 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { computed } from 'vue';
import UserLayout from '@/layouts/user/userlayout.vue';
interface Achievement {
key: string;
title: string;
description: string;
icon: string;
unlocked: boolean;
unlocked_at: string | null;
}
const props = defineProps<{
achievements: Achievement[];
total: number;
unlocked: number;
profileUser?: { username: string; avatar: string | null };
}>();
// Tier & color config per achievement
const TIER: Record<string, { color: string; light: string; glow: string; shadow: string; tier: string }> = {
first_bet: { tier: 'bronze', color: '#a0522d', light: '#e8883a', glow: 'rgba(205,127,50,0.6)', shadow: '#5c2f10' },
first_win: { tier: 'gold', color: '#b8860b', light: '#ffd700', glow: 'rgba(255,200,0,0.7)', shadow: '#7a5600' },
big_winner: { tier: 'gold', color: '#b8860b', light: '#ffd700', glow: 'rgba(255,200,0,0.7)', shadow: '#7a5600' },
high_roller: { tier: 'silver', color: '#777', light: '#ddd', glow: 'rgba(200,200,200,0.5)', shadow: '#333' },
frequent_player: { tier: 'silver', color: '#777', light: '#ddd', glow: 'rgba(200,200,200,0.5)', shadow: '#333' },
hundred_bets: { tier: 'gold', color: '#b8860b', light: '#ffd700', glow: 'rgba(255,200,0,0.7)', shadow: '#7a5600' },
vault_user: { tier: 'bronze', color: '#a0522d', light: '#e8883a', glow: 'rgba(205,127,50,0.6)', shadow: '#5c2f10' },
vip_level2: { tier: 'silver', color: '#777', light: '#ddd', glow: 'rgba(200,200,200,0.5)', shadow: '#333' },
vip_level5: { tier: 'gold', color: '#b8860b', light: '#ffd700', glow: 'rgba(255,200,0,0.7)', shadow: '#7a5600' },
guild_member: { tier: 'special',color: '#4a0080', light: '#c455ff', glow: 'rgba(180,50,255,0.6)', shadow: '#2a0050' },
promo_user: { tier: 'bronze', color: '#a0522d', light: '#e8883a', glow: 'rgba(205,127,50,0.6)', shadow: '#5c2f10' },
};
function getTier(key: string) {
return TIER[key] ?? { tier: 'bronze', color: '#a0522d', light: '#e8883a', glow: 'rgba(205,127,50,0.6)', shadow: '#5c2f10' };
}
// Split into rows of 4
const rows = computed(() => {
const out = [];
for (let i = 0; i < props.achievements.length; i += 4) {
out.push(props.achievements.slice(i, i + 4));
}
return out;
});
function formatDate(iso: string | null): string {
if (!iso) return '';
return new Date(iso).toLocaleDateString('de-DE', { day: '2-digit', month: 'short', year: 'numeric' });
}
const progressPct = computed(() =>
props.total > 0 ? Math.round((props.unlocked / props.total) * 100) : 0
);
</script>
<template>
<UserLayout>
<Head :title="profileUser ? `${profileUser.username}'s Trophy Room` : 'Trophy Room'" />
<div class="trophy-page">
<!-- Page Header -->
<div class="page-header">
<div class="header-left">
<div v-if="profileUser" class="viewing-badge">
<img v-if="profileUser.avatar" :src="profileUser.avatar" class="viewer-avatar" alt="" />
<span>{{ profileUser.username }}'s Collection</span>
</div>
<h1>Trophy Room</h1>
<p class="header-sub">{{ unlocked }} of {{ total }} achievements unlocked</p>
</div>
<div class="header-progress">
<div class="progress-ring-wrap">
<svg class="progress-ring" viewBox="0 0 80 80">
<circle cx="40" cy="40" r="32" fill="none" stroke="#1a1a1a" stroke-width="6"/>
<circle
cx="40" cy="40" r="32"
fill="none"
stroke="url(#ringGrad)"
stroke-width="6"
stroke-linecap="round"
:stroke-dasharray="`${progressPct * 2.01} 201`"
transform="rotate(-90 40 40)"
/>
<defs>
<linearGradient id="ringGrad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#df006a"/>
<stop offset="100%" stop-color="#ffb700"/>
</linearGradient>
</defs>
</svg>
<div class="ring-label">{{ progressPct }}%</div>
</div>
</div>
</div>
<!-- Trophy Cabinet -->
<div class="cabinet">
<!-- Cabinet top frame -->
<div class="cabinet-header">
<span class="cabinet-label">🏅 Achievement Showcase</span>
<div class="cabinet-bolts">
<span class="bolt"></span>
<span class="bolt"></span>
</div>
</div>
<!-- Glass panel shine -->
<div class="glass-shine"></div>
<!-- Shelves -->
<div class="cabinet-body">
<div v-for="(row, rowIdx) in rows" :key="rowIdx" class="shelf-row">
<!-- Shelf surface -->
<div class="shelf-surface"></div>
<!-- Shelf underside shadow -->
<div class="shelf-shadow"></div>
<!-- Trophies on this shelf -->
<div class="shelf-trophies">
<div
v-for="ach in row"
:key="ach.key"
class="trophy-slot"
:class="{ unlocked: ach.unlocked, locked: !ach.unlocked }"
:title="ach.description"
>
<!-- 3D Trophy -->
<div class="trophy-3d" :data-tier="getTier(ach.key).tier">
<!-- Glow aura (unlocked only) -->
<div v-if="ach.unlocked" class="trophy-aura" :style="{ background: getTier(ach.key).glow }"></div>
<!-- Star on top -->
<div v-if="ach.unlocked" class="trophy-star">★</div>
<!-- Cup body with handles -->
<div class="cup-wrap">
<div
class="cup-body"
:style="ach.unlocked ? {
background: `linear-gradient(135deg, ${getTier(ach.key).light} 0%, ${getTier(ach.key).color} 50%, ${getTier(ach.key).shadow} 100%)`,
boxShadow: `inset -4px -4px 8px ${getTier(ach.key).shadow}, inset 4px 4px 8px ${getTier(ach.key).light}55`
} : {}"
>
<!-- Shine highlight -->
<div class="cup-shine"></div>
<!-- Icon inside cup -->
<div class="cup-icon">{{ ach.icon }}</div>
</div>
<!-- Handles -->
<div
class="handle handle-left"
:style="ach.unlocked ? { borderColor: getTier(ach.key).color } : {}"
></div>
<div
class="handle handle-right"
:style="ach.unlocked ? { borderColor: getTier(ach.key).color } : {}"
></div>
</div>
<!-- Stem -->
<div
class="trophy-stem"
:style="ach.unlocked ? {
background: `linear-gradient(90deg, ${getTier(ach.key).shadow}, ${getTier(ach.key).color}, ${getTier(ach.key).shadow})`
} : {}"
></div>
<!-- Base -->
<div
class="trophy-base"
:style="ach.unlocked ? {
background: `linear-gradient(180deg, ${getTier(ach.key).color} 0%, ${getTier(ach.key).shadow} 100%)`,
boxShadow: `0 4px 12px ${getTier(ach.key).glow}`
} : {}"
></div>
<!-- Locked overlay -->
<div v-if="!ach.unlocked" class="locked-overlay">
<div class="lock-icon">🔒</div>
</div>
</div>
<!-- Name plate below trophy -->
<div class="nameplate" :style="ach.unlocked ? { borderColor: getTier(ach.key).color + '66', color: getTier(ach.key).light } : {}">
{{ ach.title }}
</div>
<div v-if="ach.unlocked_at" class="unlock-date">
{{ formatDate(ach.unlocked_at) }}
</div>
</div>
</div>
</div>
</div>
<!-- Cabinet bottom frame -->
<div class="cabinet-footer"></div>
</div>
<!-- Tier legend -->
<div class="tier-legend">
<div class="legend-item">
<span class="legend-dot" style="background: linear-gradient(135deg, #ffd700, #b8860b);"></span>
Gold
</div>
<div class="legend-item">
<span class="legend-dot" style="background: linear-gradient(135deg, #ddd, #777);"></span>
Silver
</div>
<div class="legend-item">
<span class="legend-dot" style="background: linear-gradient(135deg, #e8883a, #a0522d);"></span>
Bronze
</div>
<div class="legend-item">
<span class="legend-dot" style="background: linear-gradient(135deg, #c455ff, #4a0080);"></span>
Special
</div>
</div>
</div>
</UserLayout>
</template>
<style scoped>
.trophy-page {
padding: 30px;
max-width: 1000px;
margin: 0 auto;
}
/* ── Page Header ─────────────────────────────── */
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 40px;
gap: 20px;
}
.header-left h1 {
font-size: 2.2rem;
font-weight: 900;
color: #fff;
letter-spacing: -1px;
line-height: 1;
margin-bottom: 6px;
}
.header-sub {
font-size: 13px;
color: #666;
font-weight: 600;
}
.viewing-badge {
display: inline-flex;
align-items: center;
gap: 8px;
background: rgba(223,0,106,0.08);
border: 1px solid rgba(223,0,106,0.2);
border-radius: 999px;
padding: 4px 12px 4px 6px;
font-size: 12px;
font-weight: 700;
color: var(--primary, #df006a);
margin-bottom: 10px;
}
.viewer-avatar {
width: 24px;
height: 24px;
border-radius: 50%;
object-fit: cover;
}
.progress-ring-wrap {
position: relative;
width: 80px;
height: 80px;
flex-shrink: 0;
}
.progress-ring { width: 80px; height: 80px; }
.ring-label {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 900;
color: #fff;
}
/* ── Cabinet ─────────────────────────────────── */
.cabinet {
position: relative;
background: linear-gradient(180deg, #0e0a06 0%, #0a0a0a 100%);
border: 2px solid #2a1f0e;
border-radius: 20px;
overflow: hidden;
box-shadow:
0 0 0 1px #1a1208,
0 20px 60px rgba(0,0,0,0.8),
inset 0 1px 0 rgba(255,200,100,0.06);
margin-bottom: 28px;
}
.cabinet-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 24px;
background: linear-gradient(90deg, #1a120a, #120e06, #1a120a);
border-bottom: 1px solid #2a1a08;
}
.cabinet-label {
font-size: 11px;
font-weight: 800;
letter-spacing: 3px;
text-transform: uppercase;
color: #7a5a2a;
}
.cabinet-bolts {
display: flex;
gap: 8px;
}
.bolt {
width: 10px;
height: 10px;
border-radius: 50%;
background: radial-gradient(circle at 35% 35%, #5a4020, #2a1a08);
border: 1px solid #3a2510;
box-shadow: inset 1px 1px 2px rgba(255,200,100,0.15);
}
.glass-shine {
position: absolute;
top: 44px;
left: 0; right: 0;
height: 80px;
background: linear-gradient(180deg, rgba(255,255,255,0.03) 0%, transparent 100%);
pointer-events: none;
z-index: 10;
}
.cabinet-body {
padding: 24px 20px 0;
}
.cabinet-footer {
height: 20px;
background: linear-gradient(90deg, #1a120a, #120e06, #1a120a);
border-top: 1px solid #2a1a08;
margin-top: 8px;
}
/* ── Shelf ───────────────────────────────────── */
.shelf-row {
position: relative;
margin-bottom: 0;
padding-bottom: 28px;
}
.shelf-surface {
position: absolute;
bottom: 0;
left: -20px;
right: -20px;
height: 14px;
background: linear-gradient(180deg, #3a2810 0%, #2a1e0c 50%, #1e1408 100%);
border-top: 1px solid #5a3a18;
box-shadow: 0 2px 0 #0a0806, inset 0 1px 0 rgba(255,200,100,0.1);
z-index: 5;
}
.shelf-shadow {
position: absolute;
bottom: -10px;
left: 0; right: 0;
height: 10px;
background: linear-gradient(180deg, rgba(0,0,0,0.5), transparent);
z-index: 4;
}
.shelf-trophies {
display: flex;
gap: 8px;
justify-content: flex-start;
padding: 16px 8px 6px;
position: relative;
z-index: 6;
}
/* ── Trophy Slot ─────────────────────────────── */
.trophy-slot {
flex: 1;
max-width: 200px;
min-width: 100px;
display: flex;
flex-direction: column;
align-items: center;
cursor: default;
transition: transform 0.2s ease;
}
.trophy-slot:hover {
transform: translateY(-4px);
}
.trophy-slot.locked .trophy-3d {
filter: grayscale(0.9) brightness(0.4);
}
/* ── 3D Trophy ───────────────────────────────── */
.trophy-3d {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
width: 90px;
perspective: 400px;
user-select: none;
}
.trophy-aura {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 70px;
height: 70px;
border-radius: 50%;
opacity: 0.25;
filter: blur(14px);
pointer-events: none;
animation: aura-pulse 2.4s ease-in-out infinite;
}
@keyframes aura-pulse {
0%, 100% { opacity: 0.2; transform: translate(-50%, -50%) scale(1); }
50% { opacity: 0.4; transform: translate(-50%, -50%) scale(1.2); }
}
.trophy-star {
font-size: 14px;
color: #ffd700;
text-shadow: 0 0 8px #ffd70088;
margin-bottom: 2px;
animation: star-spin 6s linear infinite;
display: inline-block;
}
@keyframes star-spin {
0% { transform: rotate(0deg) scale(1); }
50% { transform: rotate(180deg) scale(1.2); }
100% { transform: rotate(360deg) scale(1); }
}
/* Cup */
.cup-wrap {
position: relative;
width: 60px;
height: 52px;
display: flex;
align-items: center;
justify-content: center;
}
.cup-body {
width: 46px;
height: 52px;
border-radius: 8px 8px 18px 18px;
background: linear-gradient(135deg, #555 0%, #333 50%, #111 100%);
position: relative;
clip-path: polygon(8% 0%, 92% 0%, 100% 30%, 88% 100%, 12% 100%, 0% 30%);
overflow: hidden;
box-shadow: inset -4px -4px 8px rgba(0,0,0,0.4), inset 4px 4px 8px rgba(255,255,255,0.08);
transition: background 0.3s, box-shadow 0.3s;
}
.cup-shine {
position: absolute;
top: 4px;
left: 6px;
width: 12px;
height: 24px;
border-radius: 6px;
background: rgba(255,255,255,0.18);
transform: rotate(-15deg);
pointer-events: none;
}
.cup-icon {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
filter: drop-shadow(0 1px 2px rgba(0,0,0,0.5));
padding-top: 6px;
}
/* Handles */
.handle {
position: absolute;
width: 14px;
height: 22px;
border-radius: 50%;
border: 5px solid #333;
top: 8px;
transition: border-color 0.3s;
}
.handle-left {
left: -4px;
border-right: none;
border-radius: 50% 0 0 50%;
}
.handle-right {
right: -4px;
border-left: none;
border-radius: 0 50% 50% 0;
}
/* Stem */
.trophy-stem {
width: 10px;
height: 22px;
background: linear-gradient(90deg, #222, #444, #222);
border-radius: 2px;
transition: background 0.3s;
}
/* Base */
.trophy-base {
width: 52px;
height: 12px;
border-radius: 4px;
background: linear-gradient(180deg, #444 0%, #222 100%);
box-shadow: 0 3px 8px rgba(0,0,0,0.5);
transition: background 0.3s, box-shadow 0.3s;
}
/* Locked overlay */
.locked-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
}
.lock-icon {
font-size: 22px;
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.8));
opacity: 0.7;
}
/* ── Nameplate ───────────────────────────────── */
.nameplate {
margin-top: 10px;
font-size: 10px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #666;
text-align: center;
background: #0d0d0d;
border: 1px solid #222;
border-radius: 4px;
padding: 3px 8px;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: border-color 0.3s, color 0.3s;
line-height: 1.4;
}
.unlock-date {
font-size: 9px;
color: #444;
font-weight: 600;
text-align: center;
margin-top: 3px;
letter-spacing: 0.3px;
}
/* ── Tier Legend ─────────────────────────────── */
.tier-legend {
display: flex;
gap: 20px;
justify-content: center;
flex-wrap: wrap;
}
.legend-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 700;
color: #666;
text-transform: uppercase;
letter-spacing: 1px;
}
.legend-dot {
width: 14px;
height: 14px;
border-radius: 50%;
flex-shrink: 0;
}
/* ── Responsive ─────────────────────────────── */
@media (max-width: 700px) {
.trophy-page { padding: 16px; }
.shelf-trophies { gap: 4px; }
.trophy-3d { width: 72px; }
.cup-body { width: 38px; height: 42px; }
.trophy-base { width: 42px; }
.nameplate { font-size: 9px; padding: 2px 5px; }
.cup-icon { font-size: 16px; }
.page-header { flex-direction: column; align-items: flex-start; }
}
@media (max-width: 480px) {
.shelf-trophies { flex-wrap: wrap; justify-content: center; }
.trophy-slot { min-width: 80px; }
}
</style>

156
resources/js/pages/User.vue Normal file
View File

@@ -0,0 +1,156 @@
<script setup>
import { ref, onMounted, nextTick } from 'vue';
import UserLayout from '../layouts/user/userlayout.vue';
const liveFeed = ref([]);
const generateLiveFeed = () => {
const games = ['Mental', 'San Quentin', 'Flight Mode', 'Sweet Bonanza'];
const users = ['Andri_X', 'CryptoKing', 'Neon_Ripper'];
const items = [];
for(let i=0; i<8; i++) {
const isWin = Math.random() > 0.3;
items.push({
id: i,
user: users[Math.floor(Math.random()*users.length)],
game: games[Math.floor(Math.random()*games.length)],
amount: isWin ? '+' + (Math.random()).toFixed(3) + ' BTC' : '—',
isWin
});
}
liveFeed.value = items;
nextTick(() => {
if (window.lucide) window.lucide.createIcons();
});
};
onMounted(() => {
generateLiveFeed();
// Slot hover effect
const slots = document.querySelectorAll('.slot');
slots.forEach(card => {
card.addEventListener('mousemove', e => {
const rect = card.getBoundingClientRect();
card.style.setProperty('--mouse-x', `${((e.clientX - rect.left) / rect.width) * 100}%`);
card.style.setProperty('--mouse-y', `${((e.clientY - rect.top) / rect.height) * 100}%`);
});
});
});
</script>
<template>
<UserLayout>
<section class="content">
<div class="wrap">
<div class="panel">
<div class="toolbar">
<div class="search">
<i data-lucide="search" style="width:14px; color:#333;"></i>
<input type="text" placeholder="Suche nach Slots...">
</div>
<div style="display:flex;">
<div class="chip active">Alle</div>
<div class="chip">Top</div>
<div class="chip">Neu</div>
</div>
</div>
<div class="section-head">
<h2>Top Slots</h2>
<div class="view-all">Alle anzeigen </div>
</div>
<div class="grid">
<article class="slot c1"><div class="slot-provider">Nolimit City</div><div class="thumb"><img src="https://netcontent.cc/bitkingz/i/s4/nolimit/FlightModeDX2.webp"></div><div class="slot-overlay"><button class="btn-s btn-play">Play</button><button class="btn-s btn-demo">Demo</button></div></article>
<article class="slot c2"><div class="slot-provider">Nolimit City</div><div class="thumb"><img src="https://netcontent.cc/bitkingz/i/s6/nolimit/SanQuentin.webp"></div><div class="slot-overlay"><button class="btn-s btn-play">Play</button><button class="btn-s btn-demo">Demo</button></div></article>
<article class="slot c3"><div class="slot-provider">Nolimit City</div><div class="thumb"><img src="https://netcontent.cc/bitkingz/i/s4/nolimit/MentalDX1.webp"></div><div class="slot-overlay"><button class="btn-s btn-play">Play</button><button class="btn-s btn-demo">Demo</button></div></article>
<article class="slot c4"><div class="slot-provider">Pragmatic Play</div><div class="thumb"><img src="https://netcontent.cc/bitkingz/i/s4/pragmatic/SweetBonanza.webp"></div><div class="slot-overlay"><button class="btn-s btn-play">Play</button><button class="btn-s btn-demo">Demo</button></div></article>
</div>
</div>
<div class="panel live-card">
<div class="live-head">
<div class="live-title"><span class="dot"></span> Live Feed</div>
<div class="live-meta" style="font-size: 10px; color: #444; font-weight: 800;">Realtime Protocol v2.0</div>
</div>
<div class="live-body" id="live-feed">
<div v-for="item in liveFeed" :key="item.id" class="live-item">
<div class="li-avatar"><i data-lucide="user" style="width:14px; color:#333;"></i></div>
<div class="li-info"><span class="li-user">{{ item.user }}</span><span class="li-game">{{ item.game }}</span></div>
<div class="li-val"><span :class="['li-amount', { loss: !item.isWin }]">{{ item.amount }}</span></div>
</div>
</div>
</div>
</div>
</section>
</UserLayout>
</template>
<style scoped>
.content { padding: 30px; padding-bottom: 30px; }
.wrap { max-width: 1400px; margin: 0 auto; display: flex; flex-direction: column; gap: 30px; }
.panel { background: var(--bg-card); border: 1px solid var(--border); border-radius: 16px; overflow: hidden; animation: fade-in 0.8s cubic-bezier(0.2, 0, 0, 1); }
@keyframes fade-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
.toolbar { padding: 15px 20px; display: flex; gap: 20px; align-items: center; border-bottom: 1px solid var(--border); }
.search { flex: 1; background: #000; border: 1px solid var(--border); display: flex; align-items: center; padding: 0 15px; border-radius: 10px; transition: 0.3s; }
.search:focus-within { border-color: var(--cyan); }
.search input { background: transparent; border: none; color: #fff; padding: 10px; width: 100%; font-size: 12px; }
.chip { padding: 8px 16px; font-size: 10px; font-weight: 900; color: #444; cursor: pointer; text-transform: uppercase; border-radius: 8px; transition: 0.2s; }
.chip:hover { color: #fff; }
.chip.active { color: var(--magenta); background: rgba(255,0,122,0.05); }
.section-head { padding: 25px 20px 10px; display: flex; justify-content: space-between; align-items: flex-end; }
.section-head h2 { margin: 0; font-size: 11px; font-weight: 900; text-transform: uppercase; letter-spacing: 2px; color: #444; }
.view-all { font-size: 10px; color: var(--cyan); font-weight: 900; cursor: pointer; text-transform: uppercase; }
.grid { padding: 20px; display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 20px; }
.slot { background: #000; border: 1px solid var(--border); border-radius: 18px; position: relative; overflow: hidden; aspect-ratio: 16/11; transition: 0.4s cubic-bezier(0.2, 0, 0, 1); --mouse-x: 50%; --mouse-y: 50%; }
.slot::after { content: ""; position: absolute; inset: -10px; z-index: 3; pointer-events: none; background: radial-gradient(240px circle at var(--mouse-x) var(--mouse-y), var(--glow), transparent 70%); opacity: 0; transition: opacity 0.22s cubic-bezier(0.2, 0, 0, 1); filter: blur(10px); }
.slot:hover::after { opacity: 1; }
.slot.c1 { --glow: var(--cyan); }
.slot.c2 { --glow: var(--magenta); }
.slot.c3 { --glow: var(--green); }
.slot.c4 { --glow: var(--gold); }
.slot:hover { transform: translateY(-8px) scale(1.02); box-shadow: 0 15px 40px rgba(0,0,0,0.9); }
.slot-provider { position: absolute; top: 12px; left: 12px; z-index: 4; font-size: 8px; font-weight: 900; background: rgba(0,0,0,0.85); padding: 5px 10px; border-radius: 6px; text-transform: uppercase; letter-spacing: 1px; color: #fff; border: 1px solid #222; opacity: 1; transition: 0.3s; }
.slot:hover .slot-provider { opacity: 0; }
.thumb { width: 100%; height: 100%; }
.thumb img { width: 100%; height: 100%; object-fit: cover; opacity: 0.4; transition: 0.6s cubic-bezier(0.2, 0, 0, 1); filter: grayscale(1) brightness(0.6); }
.slot:hover img { opacity: 1; filter: grayscale(0) brightness(1); transform: scale(1.08); }
.slot-overlay { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; background: linear-gradient(to top, rgba(0,0,0,0.8), transparent); opacity: 0; transition: 0.3s; z-index: 5; }
.slot:hover .slot-overlay { opacity: 1; }
.btn-s { width: 110px; padding: 10px; border-radius: 50px; font-size: 10px; font-weight: 900; text-transform: uppercase; cursor: pointer; transition: 0.2s; border: none; }
.btn-play { background: var(--glow); color: #000; box-shadow: 0 0 15px var(--glow); }
.btn-demo { background: transparent; border: 1px solid #fff; color: #fff; margin-top: 4px; }
.live-card { background: #000; border: 1px solid var(--border); border-radius: 16px; overflow: hidden; }
.live-head { padding: 16px 20px; display:flex; align-items:center; justify-content:space-between; border-bottom: 1px solid var(--border); }
.live-title { display:flex; align-items:center; gap:10px; font-size: 11px; font-weight: 900; letter-spacing: 2px; text-transform: uppercase; color: #fff; }
.live-title .dot { width:8px; height:8px; border-radius:99px; background: var(--green); box-shadow: 0 0 18px rgba(0,255,157,.6); animation: pulse 1.2s infinite; }
@keyframes pulse { 0%,100%{ transform:scale(1); opacity:1 } 50%{ transform:scale(1.25); opacity:.65 } }
.live-body { height: 280px; overflow-y: auto; padding: 15px; display: flex; flex-direction: column; gap: 8px; }
.live-item { display:grid; grid-template-columns: auto 1fr auto; align-items:center; gap: 15px; padding: 10px 15px; border-radius: 12px; border: 1px solid #111; background: #050505; transition: .3s cubic-bezier(0.2, 0, 0, 1); }
.li-avatar { width: 32px; height: 32px; border-radius: 8px; background: #111; display: flex; align-items: center; justify-content: center; border: 1px solid #222; }
.li-info { display: flex; flex-direction: column; }
.li-user { font-size: 12px; font-weight: 800; color: #fff; }
.li-game { font-size: 10px; color: #555; font-weight: 700; text-transform: uppercase; }
.li-val { text-align: right; }
.li-amount { font-size: 12px; font-weight: 900; color: var(--green); display: block; }
.li-amount.loss { color: #444; }
:global(:root) {
--bg-card: #0a0a0a;
--border: #151515;
--magenta: #ff007a;
--cyan: #00f2ff;
--green: #00ff9d;
--gold: #f7931a;
}
</style>

View File

@@ -0,0 +1,520 @@
<script setup lang="ts">
import { Head, usePage, router } from '@inertiajs/vue3';
import { computed, onMounted, nextTick, ref, watch } from 'vue';
import { ChevronLeft, ChevronRight, Lock, Check, Star, Gift } from 'lucide-vue-next';
import UserLayout from '../layouts/user/userlayout.vue';
import { useNotifications } from '@/composables/useNotifications';
const page = usePage();
const { notify } = useNotifications();
const user = computed(() => page.props.auth.user);
const stats = computed(() => user.value?.stats || { vip_points: 0, vip_level: 0 });
// Props from Controller
const props = defineProps<{
claimedLevels: number[];
cashRewards: Record<number, number>;
}>();
// Level Definitions
const levels = [
{
level: 0,
name: 'Newbie',
xp: 0,
color: '#888888',
gradient: 'linear-gradient(135deg, #2a2a2a, #444)',
shadow: 'rgba(100,100,100,0.5)',
icon: 'egg',
rewards: ['Access to Global Chat', 'Daily Free Spin']
},
{
level: 1,
name: 'Bronze',
xp: 1000,
color: '#cd7f32',
gradient: 'linear-gradient(135deg, #5c3618, #cd7f32)',
shadow: 'rgba(205,127,50,0.6)',
icon: 'shield',
rewards: ['10 Free Spins', '5% Rakeback', 'Bronze Badge', 'Weekly Reload']
},
{
level: 2,
name: 'Silver',
xp: 5000,
color: '#c0c0c0',
gradient: 'linear-gradient(135deg, #555, #c0c0c0)',
shadow: 'rgba(192,192,192,0.6)',
icon: 'sword',
rewards: ['25 Free Spins', '7% Rakeback', 'Silver Badge', 'Priority Support', 'Monthly Bonus']
},
{
level: 3,
name: 'Gold',
xp: 20000,
color: '#ffd700',
gradient: 'linear-gradient(135deg, #8a6e06, #ffd700)',
shadow: 'rgba(255,215,0,0.6)',
icon: 'crown',
rewards: ['50€ Cash Bonus', '10% Rakeback', 'Gold Badge', 'Instant Withdrawals', 'Birthday Gift']
},
{
level: 4,
name: 'Platinum',
xp: 50000,
color: '#00f2ff',
gradient: 'linear-gradient(135deg, #005f6b, #00f2ff)',
shadow: 'rgba(0,242,255,0.6)',
icon: 'gem',
rewards: ['200€ Cash Bonus', '12% Rakeback', 'Personal VIP Host', 'Exclusive Events', 'Higher Limits']
},
{
level: 5,
name: 'Diamond',
xp: 150000,
color: '#ff007a',
gradient: 'linear-gradient(135deg, #75003a, #ff007a)',
shadow: 'rgba(255,0,122,0.6)',
icon: 'diamond',
rewards: ['1000€ Cash Bonus', '15% Rakeback', 'Luxury Gifts', 'Concierge Service', 'Offline Events']
},
{
level: 6,
name: 'Obsidian',
xp: 500000,
color: '#ff3e3e',
gradient: 'linear-gradient(135deg, #520000, #ff3e3e)',
shadow: 'rgba(255,62,62,0.6)',
icon: 'flame',
rewards: ['Custom Supercar', '20% Rakeback', 'Share of Casino Profits', 'The "God" Badge', 'Private Jet Transfer']
}
];
// User Progress Logic
const currentXP = computed(() => parseFloat(stats.value.vip_points || 0));
// Explicitly check user.vip_level first, then stats.vip_level
const currentLevelIdx = computed(() => {
// 1. Check direct user property (from users table)
if (user.value?.vip_level !== undefined && user.value?.vip_level !== null) {
const lvl = parseInt(user.value.vip_level);
if (!isNaN(lvl) && lvl >= 0) {
return Math.min(lvl, levels.length - 1);
}
}
// 2. Check stats relation
if (stats.value.vip_level !== undefined && stats.value.vip_level !== null) {
const lvl = parseInt(stats.value.vip_level);
if (!isNaN(lvl) && lvl >= 0) {
return Math.min(lvl, levels.length - 1);
}
}
// 3. Fallback: Calculate from XP
let idx = 0;
for (let i = 0; i < levels.length; i++) {
if (currentXP.value >= levels[i].xp) idx = i;
else break;
}
return idx;
});
const nextLevel = computed(() => levels[currentLevelIdx.value + 1] || null);
const progressPercent = computed(() => {
if (!nextLevel.value) return 100;
const currentLvlXP = levels[currentLevelIdx.value].xp;
const nextLvlXP = nextLevel.value.xp;
// If user has level but not enough XP (manual set), show 0% or calculate relative
if (currentXP.value < currentLvlXP) return 0;
const p = ((currentXP.value - currentLvlXP) / (nextLvlXP - currentLvlXP)) * 100;
return Math.min(Math.max(p, 0), 100);
});
// Slider Logic
const activeIndex = ref(0);
// Watch for level changes to update slider initially
watch(currentLevelIdx, (newVal) => {
activeIndex.value = newVal;
}, { immediate: true });
const nextSlide = () => {
if (activeIndex.value < levels.length - 1) activeIndex.value++;
};
const prevSlide = () => {
if (activeIndex.value > 0) activeIndex.value--;
};
const setSlide = (index: number) => {
activeIndex.value = index;
};
// Claim Logic
const claiming = ref(false);
async function claimReward(level: number) {
if (claiming.value) return;
claiming.value = true;
try {
await router.post('/vip-levels/claim', { level }, {
onSuccess: () => {
notify({ type: 'green', title: 'REWARD CLAIMED', desc: `You received your level ${level} bonus!`, icon: 'gift' });
},
onError: (err) => {
notify({ type: 'magenta', title: 'ERROR', desc: err.message || 'Failed to claim.', icon: 'alert-triangle' });
}
});
} finally {
claiming.value = false;
}
}
// Helper to check if reward is claimable
const isClaimable = (levelIdx: number) => {
const lvl = levels[levelIdx];
if (levelIdx === 0) return false; // No cash for newbie
if (levelIdx > currentLevelIdx.value) return false; // Not reached yet
if (props.claimedLevels.includes(lvl.level)) return false; // Already claimed
return true;
};
const isClaimed = (levelIdx: number) => {
return props.claimedLevels.includes(levels[levelIdx].level);
};
// Calculate styles for 3D effect
const getCardStyle = (index: number) => {
const offset = index - activeIndex.value;
const absOffset = Math.abs(offset);
const isActive = offset === 0;
// Config
const xDist = 60; // % overlap
const scale = 1 - (absOffset * 0.15);
const opacity = 1 - (absOffset * 0.3);
const zIndex = 100 - absOffset;
const rotateY = offset * -25; // Rotation angle
return {
transform: `translateX(${offset * xDist}%) scale(${scale}) perspective(1000px) rotateY(${rotateY}deg)`,
opacity: Math.max(opacity, 0),
zIndex: zIndex,
pointerEvents: isActive ? 'auto' : 'none', // Only active card is interactive
filter: isActive ? 'none' : 'brightness(0.5) blur(2px)'
};
};
onMounted(() => {
nextTick(() => { if ((window as any).lucide) (window as any).lucide.createIcons(); });
});
// Re-init icons when slide changes (for the details section)
watch(activeIndex, () => {
nextTick(() => { if ((window as any).lucide) (window as any).lucide.createIcons(); });
});
</script>
<template>
<UserLayout>
<Head title="VIP Club" />
<div class="vip-container">
<!-- Header / Progress -->
<div class="vip-header">
<div class="header-content">
<div class="user-rank">
<span class="label">{{ $t('vip.your_rank') }}</span>
<h1 class="rank-title" :style="{ color: levels[currentLevelIdx].color }">
{{ levels[currentLevelIdx].name }}
</h1>
</div>
<div class="xp-bar-container">
<div class="xp-info">
<span>{{ currentXP.toLocaleString() }} XP</span>
<span v-if="nextLevel">{{ nextLevel.xp.toLocaleString() }} XP</span>
<span v-else>{{ $t('vip.max') }}</span>
</div>
<div class="xp-track">
<div class="xp-fill" :style="{ width: `${progressPercent}%`, background: levels[currentLevelIdx].color, boxShadow: `0 0 15px ${levels[currentLevelIdx].color}` }"></div>
</div>
<div class="xp-next" v-if="nextLevel">
{{ (nextLevel.xp - currentXP).toLocaleString() }} XP to {{ nextLevel.name }}
</div>
</div>
</div>
</div>
<!-- 3D Slider -->
<div class="slider-section">
<button class="nav-btn prev" @click="prevSlide" :disabled="activeIndex === 0">
<ChevronLeft class="w-8 h-8" />
</button>
<div class="cards-wrapper">
<div
v-for="(lvl, i) in levels"
:key="lvl.level"
class="vip-card"
:class="{ 'is-active': i === activeIndex }"
:style="getCardStyle(i)"
@click="setSlide(i)"
>
<div class="card-inner" :style="{ background: lvl.gradient }">
<div class="card-glow" :style="{ background: lvl.color }"></div>
<div class="card-top">
<div class="level-badge">LVL {{ lvl.level }}</div>
<i :data-lucide="lvl.icon" class="level-icon"></i>
</div>
<div class="card-mid">
<div class="card-name">{{ lvl.name }}</div>
<div class="card-xp">{{ lvl.xp.toLocaleString() }} XP</div>
</div>
<div class="card-status">
<div v-if="i < currentLevelIdx" class="status passed">
<Check class="w-4 h-4" /> {{ $t('vip.unlocked') }}
</div>
<div v-else-if="i === currentLevelIdx" class="status current">
<div class="pulse"></div> {{ $t('vip.current') }}
</div>
<div v-else class="status locked">
<Lock class="w-4 h-4" /> {{ $t('vip.locked') }}
</div>
</div>
</div>
</div>
</div>
<button class="nav-btn next" @click="nextSlide" :disabled="activeIndex === levels.length - 1">
<ChevronRight class="w-8 h-8" />
</button>
</div>
<!-- Details Panel (Dynamic based on active slide) -->
<transition name="fade-up" mode="out-in">
<div :key="activeIndex" class="details-panel">
<div class="details-head" :style="{ borderColor: levels[activeIndex].color }">
<div class="head-left">
<h2 :style="{ color: levels[activeIndex].color }">
<i :data-lucide="levels[activeIndex].icon"></i>
{{ levels[activeIndex].name }} {{ $t('vip.benefits') }}
</h2>
<div class="details-xp">{{ $t('vip.requires') }} {{ levels[activeIndex].xp.toLocaleString() }} XP</div>
</div>
<!-- Claim Button -->
<div class="head-right">
<button
v-if="isClaimable(activeIndex)"
class="btn-claim"
:style="{ background: levels[activeIndex].color, boxShadow: `0 0 20px ${levels[activeIndex].color}60` }"
@click="claimReward(levels[activeIndex].level)"
:disabled="claiming"
>
<Gift class="w-4 h-4 mr-2" />
Claim {{ props.cashRewards[levels[activeIndex].level] }}
</button>
<div v-else-if="isClaimed(activeIndex)" class="claimed-badge">
<Check class="w-4 h-4 mr-1" /> {{ $t('vip.reward_claimed') }}
</div>
</div>
</div>
<div class="rewards-grid">
<div v-for="(reward, r) in levels[activeIndex].rewards" :key="r" class="reward-item">
<div class="reward-icon" :style="{ background: `${levels[activeIndex].color}20`, color: levels[activeIndex].color }">
<Star class="w-5 h-5" />
</div>
<span class="reward-text">{{ reward }}</span>
</div>
</div>
</div>
</transition>
</div>
</UserLayout>
</template>
<style scoped>
.vip-container {
min-height: 100vh;
padding: 40px 20px;
display: flex;
flex-direction: column;
align-items: center;
overflow-x: hidden;
background: radial-gradient(circle at top, #1a1a1a 0%, #020202 100%);
}
/* Header */
.vip-header {
width: 100%;
max-width: 800px;
margin-bottom: 60px;
text-align: center;
}
.label { font-size: 12px; font-weight: 900; color: #666; letter-spacing: 3px; }
.rank-title { font-size: 48px; font-weight: 900; text-transform: uppercase; margin: 5px 0 20px; text-shadow: 0 0 30px currentColor; }
.xp-bar-container { background: #0a0a0a; border: 1px solid #222; padding: 20px; border-radius: 20px; position: relative; }
.xp-info { display: flex; justify-content: space-between; font-size: 12px; font-weight: 700; color: #ccc; margin-bottom: 8px; }
.xp-track { height: 8px; background: #1a1a1a; border-radius: 10px; overflow: hidden; }
.xp-fill { height: 100%; transition: width 1s cubic-bezier(0.2, 0, 0, 1); }
.xp-next { margin-top: 8px; font-size: 11px; color: #666; text-align: right; }
/* Slider Section */
.slider-section {
width: 100%;
max-width: 1000px;
height: 450px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
margin-bottom: 40px;
}
.cards-wrapper {
position: relative;
width: 320px; /* Card Width */
height: 420px; /* Card Height */
display: flex;
justify-content: center;
perspective: 1000px;
}
.vip-card {
position: absolute;
width: 100%;
height: 100%;
transition: all 0.5s cubic-bezier(0.2, 0, 0, 1);
cursor: pointer;
}
.card-inner {
width: 100%;
height: 100%;
border-radius: 24px;
padding: 30px;
display: flex;
flex-direction: column;
justify-content: space-between;
position: relative;
overflow: hidden;
border: 1px solid rgba(255,255,255,0.1);
box-shadow: 0 20px 50px rgba(0,0,0,0.5);
}
.card-glow {
position: absolute;
top: -50%; left: -50%; width: 200%; height: 200%;
opacity: 0.15;
filter: blur(60px);
pointer-events: none;
}
.card-top { display: flex; justify-content: space-between; align-items: flex-start; position: relative; z-index: 2; }
.level-badge { font-size: 12px; font-weight: 900; background: rgba(0,0,0,0.3); padding: 5px 12px; border-radius: 50px; border: 1px solid rgba(255,255,255,0.2); color: #fff; }
.level-icon { width: 40px; height: 40px; color: #fff; filter: drop-shadow(0 0 10px rgba(255,255,255,0.5)); }
.card-mid { text-align: center; position: relative; z-index: 2; }
.card-name { font-size: 36px; font-weight: 900; color: #fff; text-transform: uppercase; letter-spacing: 2px; text-shadow: 0 5px 15px rgba(0,0,0,0.3); }
.card-xp { font-size: 14px; font-weight: 700; color: rgba(255,255,255,0.8); margin-top: 5px; }
.card-status { display: flex; justify-content: center; position: relative; z-index: 2; }
.status { display: flex; align-items: center; gap: 8px; font-size: 12px; font-weight: 800; padding: 8px 16px; border-radius: 12px; text-transform: uppercase; }
.status.passed { background: rgba(0,255,157,0.2); color: #00ff9d; border: 1px solid rgba(0,255,157,0.3); }
.status.locked { background: rgba(0,0,0,0.3); color: #888; border: 1px solid rgba(255,255,255,0.1); }
.status.current { background: rgba(255,255,255,0.2); color: #fff; border: 1px solid #fff; }
.pulse { width: 8px; height: 8px; background: #fff; border-radius: 50%; animation: pulse 1.5s infinite; }
/* Navigation Buttons */
.nav-btn {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.1);
color: #fff;
width: 50px;
height: 50px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: 0.2s;
z-index: 20;
}
.nav-btn:hover:not(:disabled) { background: #fff; color: #000; box-shadow: 0 0 20px rgba(255,255,255,0.3); }
.nav-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.nav-btn.prev { left: 0; }
.nav-btn.next { right: 0; }
/* Details Panel */
.details-panel {
width: 100%;
max-width: 800px;
background: #0a0a0a;
border: 1px solid #222;
border-radius: 24px;
padding: 30px;
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
}
.details-head { display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #222; padding-bottom: 20px; margin-bottom: 20px; border-left: 4px solid transparent; padding-left: 20px; transition: 0.3s; }
.details-head h2 { font-size: 24px; font-weight: 900; margin: 0; display: flex; align-items: center; gap: 12px; text-transform: uppercase; }
.details-xp { font-size: 12px; font-weight: 700; color: #666; text-transform: uppercase; letter-spacing: 1px; }
.btn-claim {
display: flex; align-items: center;
padding: 10px 20px;
border-radius: 12px;
border: none;
color: #000;
font-weight: 900;
text-transform: uppercase;
cursor: pointer;
transition: 0.3s;
font-size: 12px;
}
.btn-claim:hover:not(:disabled) { transform: translateY(-2px); filter: brightness(1.1); }
.btn-claim:disabled { opacity: 0.6; cursor: not-allowed; }
.claimed-badge {
display: flex; align-items: center;
color: #00ff9d;
font-weight: 800;
font-size: 12px;
text-transform: uppercase;
background: rgba(0,255,157,0.1);
padding: 8px 16px;
border-radius: 12px;
border: 1px solid rgba(0,255,157,0.2);
}
.rewards-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 15px; }
.reward-item { background: #111; padding: 15px; border-radius: 12px; display: flex; align-items: center; gap: 15px; border: 1px solid #1a1a1a; transition: 0.2s; }
.reward-item:hover { border-color: #333; transform: translateY(-2px); }
.reward-icon { width: 36px; height: 36px; border-radius: 10px; display: flex; align-items: center; justify-content: center; }
.reward-text { font-size: 13px; font-weight: 600; color: #ddd; }
/* Animations */
@keyframes pulse { 0% { opacity: 1; transform: scale(1); } 50% { opacity: 0.5; transform: scale(1.2); } 100% { opacity: 1; transform: scale(1); } }
.fade-up-enter-active, .fade-up-leave-active { transition: all 0.3s ease; }
.fade-up-enter-from { opacity: 0; transform: translateY(20px); }
.fade-up-leave-to { opacity: 0; transform: translateY(-20px); }
@media (max-width: 768px) {
.slider-section { height: 400px; }
.cards-wrapper { width: 260px; height: 360px; }
.nav-btn { width: 40px; height: 40px; }
.rank-title { font-size: 32px; }
.details-head { flex-direction: column; align-items: flex-start; gap: 10px; }
.head-right { width: 100%; display: flex; justify-content: flex-start; }
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,133 @@
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3';
import Button from '@/components/ui/button.vue';
import SupportChat from '@/components/support/SupportChat.vue';
defineProps<{
canLogin?: boolean;
canRegister?: boolean;
}>();
</script>
<template>
<Head title="Welcome" />
<div class="welcome-container">
<!-- Background -->
<div class="bg-grid"></div>
<div class="glow-orb orb-1"></div>
<div class="glow-orb orb-2"></div>
<div class="content">
<h1 class="title">BETI<span class="highlight">X</span></h1>
<p class="subtitle">THE ULTIMATE CRYPTO PROTOCOL</p>
<p> pimmel</p>
<div class="actions">
<Link href="/login" v-if="canLogin">
<Button class="w-40 h-12 text-base font-bold neon-button">LOG IN</Button>
</Link>
<Link href="/register" v-if="canRegister">
<Button variant="secondary" class="w-40 h-12 text-base font-bold">SIGN UP</Button>
</Link>
</div>
</div>
</div>
<SupportChat />
</template>
<style scoped>
.welcome-container {
min-height: 100vh;
background: #020202;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
color: white;
}
.content {
text-align: center;
z-index: 10;
}
.title {
font-size: 6rem;
font-weight: 900;
letter-spacing: 10px;
margin: 0;
line-height: 1;
}
.highlight {
color: #ff007a;
text-shadow: 0 0 40px rgba(255, 0, 122, 0.6);
}
.subtitle {
font-size: 1.2rem;
color: #888;
letter-spacing: 4px;
margin-top: 10px;
margin-bottom: 50px;
}
.actions {
display: flex;
gap: 20px;
justify-content: center;
}
/* Background Effects */
.bg-grid {
position: absolute;
inset: 0;
background-image: linear-gradient(rgba(255, 255, 255, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.03) 1px, transparent 1px);
background-size: 50px 50px;
mask-image: radial-gradient(circle at center, black 40%, transparent 80%);
}
.glow-orb {
position: absolute;
border-radius: 50%;
filter: blur(100px);
opacity: 0.4;
animation: float 10s infinite ease-in-out;
}
.orb-1 {
width: 400px;
height: 400px;
background: #ff007a;
top: -100px;
left: -100px;
}
.orb-2 {
width: 500px;
height: 500px;
background: #00f2ff;
bottom: -100px;
right: -100px;
animation-delay: -5s;
}
@keyframes float {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(30px, 50px); }
}
/* Neon Button */
.neon-button {
background: linear-gradient(90deg, #ff007a, #be005b);
border: none;
box-shadow: 0 0 20px rgba(255, 0, 122, 0.4);
transition: all 0.3s ease;
}
.neon-button:hover {
transform: translateY(-2px);
box-shadow: 0 0 40px rgba(255, 0, 122, 0.7);
}
</style>

View File

@@ -0,0 +1,108 @@
<script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3';
import { onMounted, nextTick } from 'vue';
import InputError from '@/components/InputError.vue';
import AuthLayout from '@/layouts/AuthLayout.vue';
import { store } from '@/routes/password/confirm';
onMounted(() => {
nextTick(() => { if ((window as any).lucide) (window as any).lucide.createIcons(); });
});
</script>
<template>
<AuthLayout>
<Head title="Passwort bestätigen" />
<div class="wrap">
<div class="main-panel">
<header class="page-head">
<div class="head-flex">
<div class="title-group">
<div class="title">Bestätigung erforderlich</div>
<p class="subtitle">Bitte bestätige dein Passwort, um fortzufahren</p>
</div>
<div class="security-badge">
<i data-lucide="lock"></i>
<span>Secure</span>
</div>
</div>
</header>
<Form v-bind="store.form()" reset-on-success v-slot="{ errors, processing }">
<div class="form-body">
<label class="field" for="password">
<span class="lbl">Passwort</span>
<div class="input-wrapper">
<i data-lucide="key-round"></i>
<input
id="password"
name="password"
type="password"
class="inp"
required
autocomplete="current-password"
autofocus
placeholder="••••••••"
/>
</div>
<InputError :message="errors.password" />
</label>
<div class="actions">
<button class="btn" type="submit" :disabled="processing" data-test="confirm-password-button">
<span v-if="processing" class="spinner" />
<span>{{ processing ? 'Wird bestätigt…' : 'Passwort bestätigen' }}</span>
<i v-if="!processing" data-lucide="arrow-right" style="width:14px"></i>
</button>
</div>
<div class="muted hint">
<i data-lucide="shield-check"></i>
<span>Sicherer Bereich. Deine Eingabe ist verschlüsselt.</span>
</div>
</div>
</Form>
</div>
</div>
</AuthLayout>
</template>
<style scoped>
:global(:root) { --bg-card:#0a0a0a; --border:#151515; --cyan:#00f2ff; --magenta:#ff007a; --green:#00ff9d; }
.wrap { width: 100%; }
.main-panel { background: var(--bg-card); border: 1px solid var(--border); border-radius: 20px; overflow: hidden; box-shadow: 0 20px 50px rgba(0,0,0,0.6); animation: fade-in 0.6s cubic-bezier(0.2,0,0,1); }
.page-head { padding: 22px 26px; border-bottom: 1px solid var(--border); background: linear-gradient(to right, rgba(0,242,255,0.03), transparent); }
.head-flex { display: flex; justify-content: space-between; align-items: center; gap: 10px; }
.title { font-size: 13px; font-weight: 900; color: #fff; letter-spacing: 2px; text-transform: uppercase; }
.subtitle { color: #555; font-size: 11px; margin-top: 4px; font-weight: 600; }
.security-badge { display:flex; align-items:center; gap:6px; color: var(--green); background: rgba(0,255,157,0.05); padding:5px 12px; border-radius:50px; border:1px solid rgba(0,255,157,0.1); font-size:9px; font-weight:900; text-transform:uppercase; letter-spacing:1px; }
.security-badge i { width: 12px; height: 12px; }
.form-body { padding: 24px 26px; display: grid; gap: 20px; }
.field { display: grid; gap: 8px; }
.lbl { display:block; font-size:10px; font-weight:900; color:#555; text-transform:uppercase; letter-spacing:1px; }
.input-wrapper { position: relative; display:flex; align-items:center; }
.input-wrapper i { position: absolute; left: 14px; width: 16px; color: #444; pointer-events: none; transition: .3s; }
.inp { width: 100%; background:#000; border:1px solid var(--border); color:#fff; padding:14px 14px 14px 44px; border-radius:12px; font-size:13px; transition:.3s cubic-bezier(0.2,0,0,1); }
.inp:focus { border-color: var(--cyan); outline: none; box-shadow: 0 0 15px rgba(0,242,255,0.1); background:#050505; }
.inp:focus + i { color: var(--cyan); }
.actions { display:flex; gap:10px; margin-top: 5px; }
.btn { width: 100%; background: var(--cyan); color:#000; border:none; border-radius:12px; padding:14px 16px; font-size:12px; font-weight:900; text-transform:uppercase; letter-spacing:1px; cursor:pointer; transition:.3s; box-shadow: 0 0 20px rgba(0,242,255,0.2); display:flex; align-items:center; justify-content:center; gap:10px; }
.btn:hover:not(:disabled) { transform: translateY(-1px); box-shadow: 0 6px 22px rgba(0,242,255,0.35); }
.btn:disabled { opacity: .6; cursor: not-allowed; }
.spinner { width: 16px; height: 16px; border: 2px solid rgba(0,0,0,0.1); border-top-color:#000; border-radius: 50%; animation: spin .8s linear infinite; }
.muted { color:#9aa0a6; font-size:11px; display:flex; align-items:center; gap:8px; justify-content: center; opacity: 0.7; }
.hint i { width: 14px; color:#444; }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes fade-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
@media (max-width: 560px) {
.page-head { padding: 18px 20px; }
.form-body { padding: 18px 20px; }
}
</style>

View File

@@ -0,0 +1,198 @@
<script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3';
import { ChevronLeft } from 'lucide-vue-next';
import InputError from '@/components/InputError.vue';
import TextLink from '@/components/TextLink.vue';
import Button from '@/components/ui/button.vue';
import Input from '@/components/ui/input.vue';
import Label from '@/components/ui/label.vue';
import Spinner from '@/components/ui/spinner.vue';
import UserLayout from '@/layouts/user/userlayout.vue';
defineProps<{
status?: string;
}>();
const form = useForm({
email: '',
});
const submit = () => {
form.post('/forgot-password');
};
</script>
<template>
<UserLayout>
<Head title="Forgot Password" />
<div class="login-container">
<!-- Animated Background -->
<div class="glow-orb orb-1"></div>
<div class="glow-orb orb-2"></div>
<div class="login-card">
<div class="card-header">
<h1 class="title">RESET <span class="highlight">PASSWORD</span></h1>
<p class="subtitle">Enter your email to recover access</p>
</div>
<div v-if="status" class="mb-4 text-center text-sm font-medium text-green-500 bg-green-500/10 p-2 rounded border border-green-500/20">
{{ status }}
</div>
<form @submit.prevent="submit" class="form-content">
<div class="input-group">
<Label for="email">Email Address</Label>
<Input
id="email"
type="email"
v-model="form.email"
required
autofocus
placeholder="you@example.com"
/>
<InputError :message="form.errors.email" />
</div>
<div class="mt-6">
<Button
type="submit"
class="w-full h-12 text-base font-bold tracking-widest uppercase neon-button relative overflow-hidden"
:disabled="form.processing"
>
<div v-if="form.processing" class="absolute inset-0 bg-black/20 flex items-center justify-center">
<Spinner class="w-5 h-5" />
</div>
<span :class="{ 'opacity-0': form.processing }">Send Reset Link</span>
</Button>
</div>
<div class="text-center mt-6">
<TextLink href="/login" class="text-[#888] hover:text-white transition-colors flex items-center justify-center gap-2">
<ChevronLeft class="w-4 h-4" /> Back to Login
</TextLink>
</div>
</form>
</div>
</div>
</UserLayout>
</template>
<style scoped>
/* Container & Layout */
.login-container {
display: flex;
align-items: center;
justify-content: center;
min-height: calc(100vh - 100px);
padding: 20px;
position: relative;
overflow: hidden;
}
.login-card {
width: 100%;
max-width: 450px;
background: rgba(10, 10, 10, 0.8);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 24px;
padding: 40px;
position: relative;
z-index: 10;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5);
animation: slideUp 0.8s cubic-bezier(0.16, 1, 0.3, 1);
}
/* Header */
.card-header {
text-align: center;
margin-bottom: 30px;
}
.title {
font-size: 1.8rem;
font-weight: 900;
color: white;
letter-spacing: 2px;
margin: 0;
}
.highlight {
color: #ff007a;
text-shadow: 0 0 20px rgba(255, 0, 122, 0.5);
}
.subtitle {
color: #888;
font-size: 0.9rem;
margin-top: 5px;
text-transform: uppercase;
letter-spacing: 1px;
}
/* Form Content */
.form-content {
display: flex;
flex-direction: column;
gap: 20px;
}
.input-group {
display: flex;
flex-direction: column;
gap: 8px;
}
/* Neon Button */
.neon-button {
background: linear-gradient(90deg, #ff007a, #be005b);
border: none;
position: relative;
overflow: hidden;
transition: all 0.3s ease;
}
.neon-button:hover {
transform: translateY(-2px);
box-shadow: 0 0 30px rgba(255, 0, 122, 0.6);
filter: brightness(1.1);
}
.neon-button:disabled {
opacity: 0.5;
cursor: not-allowed;
filter: grayscale(1);
}
/* Background Orbs */
.glow-orb {
position: absolute;
border-radius: 50%;
filter: blur(80px);
opacity: 0.4;
z-index: 0;
animation: float 10s infinite ease-in-out;
}
.orb-1 {
width: 300px;
height: 300px;
background: #ff007a;
top: -50px;
left: -100px;
}
.orb-2 {
width: 400px;
height: 400px;
background: #00f2ff;
bottom: -100px;
right: -100px;
animation-delay: -5s;
}
/* Animations */
@keyframes slideUp {
from { opacity: 0; transform: translateY(40px) scale(0.95); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes float {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(20px, 30px); }
}
</style>

View File

@@ -0,0 +1,227 @@
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3';
import LoginForm from '@/components/auth/LoginForm.vue';
import UserLayout from '@/layouts/user/userlayout.vue';
defineProps<{
status?: string;
canResetPassword: boolean;
canRegister: boolean;
}>();
</script>
<template>
<UserLayout>
<Head title="Login" />
<div class="lp-wrap">
<!-- Ambient background -->
<div class="lp-bg">
<div class="orb orb-a"></div>
<div class="orb orb-b"></div>
<div class="orb orb-c"></div>
<div class="grid-overlay"></div>
</div>
<div class="lp-card">
<!-- Glowing top bar -->
<div class="card-glow-bar"></div>
<!-- Brand / Logo area -->
<div class="lp-brand">
<div class="brand-diamond">
<span class="diamond-icon"></span>
</div>
<h1 class="brand-title">WELCOME <span class="brand-accent">BACK</span></h1>
<p class="brand-sub">Sign in to continue playing</p>
</div>
<!-- Divider -->
<div class="lp-divider">
<span class="divider-dot"></span>
<span class="divider-line"></span>
<span class="divider-text">Your Account</span>
<span class="divider-line"></span>
<span class="divider-dot"></span>
</div>
<!-- Form -->
<LoginForm :status="status">
<template #forgot-password>
<Link v-if="canResetPassword" href="/forgot-password" class="forgot-link">
Forgot password?
</Link>
</template>
</LoginForm>
<!-- Footer -->
<div class="lp-footer" v-if="canRegister">
<span class="footer-text">New here?</span>
<button
class="reg-link"
@click="() => window.dispatchEvent(new Event('require-register'))"
>
Create account
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
</button>
</div>
<!-- Trust badges -->
<div class="trust-row">
<div class="trust-badge"><span class="trust-icon">🔒</span> SSL Encrypted</div>
<div class="trust-badge"><span class="trust-icon"></span> Instant Access</div>
<div class="trust-badge"><span class="trust-icon">🛡</span> Secure</div>
</div>
</div>
</div>
</UserLayout>
</template>
<style scoped>
/* ── Wrapper ─────────────────────────────────────────────── */
.lp-wrap {
min-height: calc(100vh - 80px);
display: flex;
align-items: center;
justify-content: center;
padding: 40px 20px;
position: relative;
overflow: hidden;
}
/* ── Ambient background ──────────────────────────────────── */
.lp-bg { position: absolute; inset: 0; pointer-events: none; }
.orb {
position: absolute;
border-radius: 50%;
filter: blur(90px);
animation: orb-float 12s ease-in-out infinite;
}
.orb-a { width: 360px; height: 360px; background: var(--primary, #df006a); opacity: .18; top: -80px; left: -80px; }
.orb-b { width: 440px; height: 440px; background: #7c3aed; opacity: .12; bottom: -120px; right: -100px; animation-delay: -5s; }
.orb-c { width: 220px; height: 220px; background: #06b6d4; opacity: .10; top: 40%; left: 60%; animation-delay: -9s; }
@keyframes orb-float {
0%, 100% { transform: translate(0, 0) scale(1); }
33% { transform: translate(20px, 30px) scale(1.05); }
66% { transform: translate(-15px, -20px) scale(.97); }
}
.grid-overlay {
position: absolute; inset: 0;
background-image:
linear-gradient(rgba(255,255,255,.025) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,.025) 1px, transparent 1px);
background-size: 40px 40px;
mask-image: radial-gradient(ellipse 80% 80% at 50% 50%, black 30%, transparent 100%);
}
/* ── Card ────────────────────────────────────────────────── */
.lp-card {
width: 100%;
max-width: 440px;
background: rgba(8, 8, 12, 0.85);
backdrop-filter: blur(24px);
border: 1px solid rgba(255,255,255,.08);
border-radius: 28px;
padding: 0 0 28px;
position: relative;
z-index: 10;
box-shadow:
0 0 0 1px rgba(255,255,255,.04),
0 25px 60px rgba(0,0,0,.6),
0 0 80px rgba(223,0,106,.06);
animation: card-in .7s cubic-bezier(.16,1,.3,1) both;
}
@keyframes card-in {
from { opacity: 0; transform: translateY(32px) scale(.96); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
/* ── Top glow bar ────────────────────────────────────────── */
.card-glow-bar {
height: 3px;
border-radius: 28px 28px 0 0;
background: linear-gradient(90deg, transparent, var(--primary, #df006a) 30%, #a855f7 70%, transparent);
margin-bottom: 32px;
}
/* ── Brand ───────────────────────────────────────────────── */
.lp-brand { text-align: center; padding: 0 32px 8px; }
.brand-diamond {
width: 60px; height: 60px;
background: rgba(223,0,106,.1);
border: 1px solid rgba(223,0,106,.25);
border-radius: 16px;
display: flex; align-items: center; justify-content: center;
margin: 0 auto 18px;
box-shadow: 0 0 30px rgba(223,0,106,.15);
}
.diamond-icon { font-size: 28px; color: var(--primary, #df006a); filter: drop-shadow(0 0 8px var(--primary, #df006a)); }
.brand-title {
font-size: 1.9rem;
font-weight: 900;
color: #fff;
letter-spacing: 3px;
margin: 0 0 6px;
line-height: 1.1;
}
.brand-accent {
color: var(--primary, #df006a);
text-shadow: 0 0 24px rgba(223,0,106,.5);
}
.brand-sub { color: #555; font-size: .8rem; letter-spacing: 1.5px; text-transform: uppercase; margin: 0; }
/* ── Divider ─────────────────────────────────────────────── */
.lp-divider {
display: flex; align-items: center; gap: 10px;
padding: 20px 32px 24px;
}
.divider-line { flex: 1; height: 1px; background: rgba(255,255,255,.06); }
.divider-dot { width: 4px; height: 4px; border-radius: 50%; background: rgba(223,0,106,.4); }
.divider-text { font-size: 10px; color: #444; letter-spacing: 2px; text-transform: uppercase; white-space: nowrap; }
/* ── Form wrapper ────────────────────────────────────────── */
:deep(.login-form-content) { padding: 0 32px; }
/* ── Footer ──────────────────────────────────────────────── */
.lp-footer {
display: flex; align-items: center; justify-content: center; gap: 10px;
padding: 24px 32px 8px;
border-top: 1px solid rgba(255,255,255,.05);
margin-top: 24px;
}
.footer-text { color: #555; font-size: .82rem; }
.reg-link {
display: flex; align-items: center; gap: 5px;
background: none; border: none; cursor: pointer;
color: var(--primary, #df006a); font-size: .82rem; font-weight: 700;
letter-spacing: .5px; transition: .2s; padding: 0;
}
.reg-link:hover { gap: 8px; filter: brightness(1.2); }
/* ── Trust badges ────────────────────────────────────────── */
.trust-row {
display: flex; align-items: center; justify-content: center; gap: 12px;
padding: 16px 32px 0;
flex-wrap: wrap;
}
.trust-badge {
display: flex; align-items: center; gap: 5px;
font-size: 10px; color: #444; letter-spacing: .5px;
}
.trust-icon { font-size: 11px; }
/* ── Forgot link (rendered inside LoginForm slot) ────────── */
:deep(.forgot-link) {
font-size: .75rem;
color: #555;
text-decoration: none;
transition: .2s;
letter-spacing: .3px;
}
:deep(.forgot-link:hover) { color: var(--primary, #df006a); }
</style>

View File

@@ -0,0 +1,77 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import RegisterForm from '@/components/auth/RegisterForm.vue';
import UserLayout from '@/layouts/user/userlayout.vue';
</script>
<template>
<UserLayout>
<Head title="Register" />
<div class="register-container">
<div class="glow-orb orb-1"></div>
<div class="glow-orb orb-2"></div>
<div class="register-card">
<div class="card-header">
<h1 class="title">CREATE <span class="highlight">ACCOUNT</span></h1>
<p class="subtitle">Join the ultimate crypto protocol</p>
</div>
<div class="form-content">
<RegisterForm />
</div>
<div class="card-footer">
<p class="footer-text">
Already have an account?
<button @click="() => window.dispatchEvent(new Event('require-login'))" class="link-btn">Log in here</button>
</p>
</div>
</div>
</div>
</UserLayout>
</template>
<style scoped>
/* Styles remain largely the same, but are now more robust due to API-first approach */
.register-container { display: flex; align-items: center; justify-content: center; min-height: calc(100vh - 100px); padding: 20px; position: relative; overflow: hidden; }
.register-card { width: 100%; max-width: 500px; background: rgba(10, 10, 10, 0.8); backdrop-filter: blur(20px); border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 24px; padding: 40px; position: relative; z-index: 10; box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5); animation: slideUp 0.8s cubic-bezier(0.16, 1, 0.3, 1); }
.progress-container { margin-bottom: 30px; }
.progress-bar { height: 4px; background: #1a1a1a; border-radius: 2px; overflow: hidden; margin-bottom: 10px; }
.progress-fill { height: 100%; background: #00f2ff; box-shadow: 0 0 10px #00f2ff; transition: width 0.4s ease; }
.steps-indicator { display: flex; justify-content: space-between; font-size: 10px; text-transform: uppercase; color: #444; font-weight: 700; }
.steps-indicator span.active { color: #00f2ff; text-shadow: 0 0 5px rgba(0, 242, 255, 0.5); }
.card-header { text-align: center; margin-bottom: 30px; }
.title { font-size: 1.8rem; font-weight: 900; color: white; letter-spacing: 2px; margin: 0; }
.highlight { color: #ff007a; text-shadow: 0 0 20px rgba(255, 0, 122, 0.5); }
.subtitle { color: #888; font-size: 0.8rem; margin-top: 5px; text-transform: uppercase; letter-spacing: 1px; }
.step-content { display: flex; flex-direction: column; gap: 20px; }
.input-group { display: flex; flex-direction: column; gap: 8px; position: relative; }
.input-wrapper { position: relative; }
.status-icon { position: absolute; right: 10px; top: 50%; transform: translateY(-50%); z-index: 10; }
.input-group.invalid :deep(input) { border-color: #ef4444; animation: shake 0.4s ease-in-out; }
.input-group.valid :deep(input) { border-color: #22c55e; }
.error-msg { font-size: 11px; color: #ef4444; font-weight: 600; margin-top: -4px; }
@keyframes shake { 0%, 100% { transform: translateX(0); } 25% { transform: translateX(-5px); } 75% { transform: translateX(5px); } }
.cyber-checkbox-wrapper { display: flex; align-items: center; gap: 15px; background: rgba(0, 0, 0, 0.4); border: 1px solid #222; padding: 15px; border-radius: 12px; cursor: pointer; transition: all 0.3s ease; }
.cyber-checkbox-wrapper:hover { border-color: #333; background: rgba(255, 255, 255, 0.02); }
.cyber-checkbox-wrapper.checked { border-color: #00f2ff; background: rgba(0, 242, 255, 0.05); box-shadow: 0 0 20px rgba(0, 242, 255, 0.1); }
.cyber-box { width: 24px; height: 24px; background: #000; border: 2px solid #333; border-radius: 6px; display: flex; align-items: center; justify-content: center; transition: 0.3s; }
.cyber-checkbox-wrapper.checked .cyber-box { border-color: #00f2ff; box-shadow: 0 0 10px #00f2ff; }
.cyber-tick { width: 12px; height: 12px; background: #00f2ff; border-radius: 2px; box-shadow: 0 0 10px #00f2ff; animation: popIn 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275); }
.cyber-label { display: flex; flex-direction: column; }
.neon-button { background: linear-gradient(90deg, #ff007a, #be005b); border: none; position: relative; overflow: hidden; transition: all 0.3s ease; }
.neon-button:hover { transform: translateY(-2px); box-shadow: 0 0 30px rgba(255, 0, 122, 0.6); filter: brightness(1.1); }
.neon-button:disabled { opacity: 0.5; cursor: not-allowed; filter: grayscale(1); }
.glow-orb { position: absolute; border-radius: 50%; filter: blur(80px); opacity: 0.4; z-index: 0; animation: float 10s infinite ease-in-out; }
.orb-1 { width: 300px; height: 300px; background: #ff007a; top: -50px; left: -100px; }
.orb-2 { width: 400px; height: 400px; background: #00f2ff; bottom: -100px; right: -100px; animation-delay: -5s; }
@keyframes slideUp { from { opacity: 0; transform: translateY(40px) scale(0.95); } to { opacity: 1; transform: translateY(0) scale(1); } }
@keyframes popIn { from { transform: scale(0); } to { transform: scale(1); } }
@keyframes float { 0%, 100% { transform: translate(0, 0); } 50% { transform: translate(20px, 30px); } }
.fade-slide-enter-active, .fade-slide-leave-active { transition: all 0.3s ease; }
.fade-slide-enter-from { opacity: 0; transform: translateX(20px); }
.fade-slide-leave-to { opacity: 0; transform: translateX(-20px); }
:deep(input[type="date"]::-webkit-calendar-picker-indicator) { filter: invert(1); cursor: pointer; opacity: 0.6; transition: 0.2s; }
:deep(input[type="date"]::-webkit-calendar-picker-indicator:hover) { opacity: 1; }
</style>

View File

@@ -0,0 +1,281 @@
<script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3';
import { Eye, EyeOff } from 'lucide-vue-next';
import { computed, ref } from 'vue';
import InputError from '@/components/InputError.vue';
import Button from '@/components/ui/button.vue';
import Input from '@/components/ui/input.vue';
import Label from '@/components/ui/label.vue';
import Spinner from '@/components/ui/spinner.vue';
import UserLayout from '@/layouts/user/userlayout.vue';
const props = defineProps<{
token: string;
email: string;
}>();
const form = useForm({
token: props.token,
email: props.email,
password: '',
password_confirmation: '',
});
const showPassword = ref(false);
const showConfirmPassword = ref(false);
const submit = () => {
form.post('/reset-password', {
onFinish: () => form.reset('password', 'password_confirmation'),
});
};
// Password Strength Logic
const passwordStrength = computed(() => {
const pwd = form.password;
let score = 0;
if (!pwd) return 0;
if (pwd.length > 6) score += 20;
if (pwd.length > 10) score += 20;
if (/[A-Z]/.test(pwd)) score += 20;
if (/[0-9]/.test(pwd)) score += 20;
if (/[^A-Za-z0-9]/.test(pwd)) score += 20;
return score;
});
const strengthColor = computed(() => {
const s = passwordStrength.value;
if (s < 40) return 'bg-red-500';
if (s < 80) return 'bg-yellow-500';
return 'bg-green-500';
});
const strengthLabel = computed(() => {
const s = passwordStrength.value;
if (s < 40) return 'Weak';
if (s < 80) return 'Medium';
return 'Strong';
});
</script>
<template>
<UserLayout>
<Head title="Reset Password" />
<div class="login-container">
<!-- Animated Background -->
<div class="glow-orb orb-1"></div>
<div class="glow-orb orb-2"></div>
<div class="login-card">
<div class="card-header">
<h1 class="title">NEW <span class="highlight">PASSWORD</span></h1>
<p class="subtitle">Secure your account</p>
</div>
<form @submit.prevent="submit" class="form-content">
<div class="input-group">
<Label for="email">Email Address</Label>
<Input
id="email"
type="email"
v-model="form.email"
required
readonly
class="opacity-50 cursor-not-allowed"
/>
<InputError :message="form.errors.email" />
</div>
<div class="input-group">
<Label for="password">New Password</Label>
<div class="input-wrapper">
<Input
id="password"
:type="showPassword ? 'text' : 'password'"
v-model="form.password"
required
autofocus
autocomplete="new-password"
placeholder="••••••••"
/>
<div class="status-icon cursor-pointer pointer-events-auto hover:text-white text-[#666] transition-colors" @click="showPassword = !showPassword">
<Eye v-if="!showPassword" class="w-4 h-4" />
<EyeOff v-else class="w-4 h-4" />
</div>
</div>
<!-- Password Strength Indicator -->
<div class="h-1 w-full bg-gray-800 rounded-full mt-2 overflow-hidden">
<div class="h-full transition-all duration-500" :class="strengthColor" :style="{ width: `${passwordStrength}%` }"></div>
</div>
<p class="text-xs text-right mt-1" :class="{'text-red-500': passwordStrength < 40, 'text-yellow-500': passwordStrength >= 40 && passwordStrength < 80, 'text-green-500': passwordStrength >= 80}">{{ strengthLabel }}</p>
<InputError :message="form.errors.password" />
</div>
<div class="input-group">
<Label for="password_confirmation">Confirm Password</Label>
<div class="input-wrapper">
<Input
id="password_confirmation"
:type="showConfirmPassword ? 'text' : 'password'"
v-model="form.password_confirmation"
required
autocomplete="new-password"
placeholder="••••••••"
/>
<div class="status-icon cursor-pointer pointer-events-auto hover:text-white text-[#666] transition-colors" @click="showConfirmPassword = !showConfirmPassword">
<Eye v-if="!showConfirmPassword" class="w-4 h-4" />
<EyeOff v-else class="w-4 h-4" />
</div>
</div>
<InputError :message="form.errors.password_confirmation" />
</div>
<div class="mt-6">
<Button
type="submit"
class="w-full h-12 text-base font-bold tracking-widest uppercase neon-button relative overflow-hidden"
:disabled="form.processing"
>
<div v-if="form.processing" class="absolute inset-0 bg-black/20 flex items-center justify-center">
<Spinner class="w-5 h-5" />
</div>
<span :class="{ 'opacity-0': form.processing }">Reset Password</span>
</Button>
</div>
</form>
</div>
</div>
</UserLayout>
</template>
<style scoped>
/* Container & Layout */
.login-container {
display: flex;
align-items: center;
justify-content: center;
min-height: calc(100vh - 100px);
padding: 20px;
position: relative;
overflow: hidden;
}
.login-card {
width: 100%;
max-width: 450px;
background: rgba(10, 10, 10, 0.8);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 24px;
padding: 40px;
position: relative;
z-index: 10;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5);
animation: slideUp 0.8s cubic-bezier(0.16, 1, 0.3, 1);
}
/* Header */
.card-header {
text-align: center;
margin-bottom: 30px;
}
.title {
font-size: 1.8rem;
font-weight: 900;
color: white;
letter-spacing: 2px;
margin: 0;
}
.highlight {
color: #ff007a;
text-shadow: 0 0 20px rgba(255, 0, 122, 0.5);
}
.subtitle {
color: #888;
font-size: 0.9rem;
margin-top: 5px;
text-transform: uppercase;
letter-spacing: 1px;
}
/* Form Content */
.form-content {
display: flex;
flex-direction: column;
gap: 20px;
}
.input-group {
display: flex;
flex-direction: column;
gap: 8px;
position: relative;
}
/* Input Wrapper for Icons */
.input-wrapper {
position: relative;
}
.status-icon {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
z-index: 10;
}
/* Neon Button */
.neon-button {
background: linear-gradient(90deg, #ff007a, #be005b);
border: none;
position: relative;
overflow: hidden;
transition: all 0.3s ease;
}
.neon-button:hover {
transform: translateY(-2px);
box-shadow: 0 0 30px rgba(255, 0, 122, 0.6);
filter: brightness(1.1);
}
.neon-button:disabled {
opacity: 0.5;
cursor: not-allowed;
filter: grayscale(1);
}
/* Background Orbs */
.glow-orb {
position: absolute;
border-radius: 50%;
filter: blur(80px);
opacity: 0.4;
z-index: 0;
animation: float 10s infinite ease-in-out;
}
.orb-1 {
width: 300px;
height: 300px;
background: #ff007a;
top: -50px;
left: -100px;
}
.orb-2 {
width: 400px;
height: 400px;
background: #00f2ff;
bottom: -100px;
right: -100px;
animation-delay: -5s;
}
/* Animations */
@keyframes slideUp {
from { opacity: 0; transform: translateY(40px) scale(0.95); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes float {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(20px, 30px); }
}
</style>

View File

@@ -0,0 +1,133 @@
<script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3';
import { computed, ref } from 'vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from '@/components/ui/input-otp';
import AuthLayout from '@/layouts/AuthLayout.vue';
import type { TwoFactorConfigContent } from '@/types';
import { store } from '@/routes/two-factor/login';
const authConfigContent = computed<TwoFactorConfigContent>(() => {
if (showRecoveryInput.value) {
return {
title: 'Recovery Code',
description:
'Please confirm access to your account by entering one of your emergency recovery codes.',
buttonText: 'login using an authentication code',
};
}
return {
title: 'Authentication Code',
description:
'Enter the authentication code provided by your authenticator application.',
buttonText: 'login using a recovery code',
};
});
const showRecoveryInput = ref<boolean>(false);
const toggleRecoveryMode = (clearErrors: () => void): void => {
showRecoveryInput.value = !showRecoveryInput.value;
clearErrors();
code.value = '';
};
const code = ref<string>('');
</script>
<template>
<AuthLayout
:title="authConfigContent.title"
:description="authConfigContent.description"
>
<Head title="Two-Factor Authentication" />
<div class="space-y-6">
<template v-if="!showRecoveryInput">
<Form
v-bind="store.form()"
class="space-y-4"
reset-on-error
@error="code = ''"
#default="{ errors, processing, clearErrors }"
>
<input type="hidden" name="code" :value="code" />
<div
class="flex flex-col items-center justify-center space-y-3 text-center"
>
<div class="flex w-full items-center justify-center">
<InputOTP
id="otp"
v-model="code"
:maxlength="6"
:disabled="processing"
autofocus
>
<InputOTPGroup>
<InputOTPSlot
v-for="index in 6"
:key="index"
:index="index - 1"
/>
</InputOTPGroup>
</InputOTP>
</div>
<InputError :message="errors.code" />
</div>
<Button type="submit" class="w-full" :disabled="processing"
>Continue</Button
>
<div class="text-center text-sm text-muted-foreground">
<span>or you can </span>
<button
type="button"
class="text-foreground underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out hover:decoration-current! dark:decoration-neutral-500"
@click="() => toggleRecoveryMode(clearErrors)"
>
{{ authConfigContent.buttonText }}
</button>
</div>
</Form>
</template>
<template v-else>
<Form
v-bind="store.form()"
class="space-y-4"
reset-on-error
#default="{ errors, processing, clearErrors }"
>
<Input
name="recovery_code"
type="text"
placeholder="Enter recovery code"
:autofocus="showRecoveryInput"
required
/>
<InputError :message="errors.recovery_code" />
<Button type="submit" class="w-full" :disabled="processing"
>Continue</Button
>
<div class="text-center text-sm text-muted-foreground">
<span>or you can </span>
<button
type="button"
class="text-foreground underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out hover:decoration-current! dark:decoration-neutral-500"
@click="() => toggleRecoveryMode(clearErrors)"
>
{{ authConfigContent.buttonText }}
</button>
</div>
</Form>
</template>
</div>
</AuthLayout>
</template>

View File

@@ -0,0 +1,267 @@
<script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3';
import { Mail, LogOut, ArrowRight } from 'lucide-vue-next';
import Button from '@/components/ui/button.vue';
import Spinner from '@/components/ui/spinner.vue';
import UserLayout from '@/layouts/user/userlayout.vue';
defineProps<{
status?: string;
verificationUrl?: string | null;
hasCode?: boolean;
}>();
const form = useForm({});
const codeForm = useForm({ code: '' });
const submit = () => {
form.post('/email/verification-notification');
};
const submitCode = () => {
// Normalize to digits-only and limit to 6 characters before submitting
const digits = (codeForm.code || '').toString().replace(/\D+/g, '').slice(0, 6);
// @ts-expect-error inertia form model is mutable
codeForm.code = digits;
codeForm.post('/email/verify/code');
};
const logout = () => {
useForm({}).post('/logout');
};
</script>
<template>
<UserLayout>
<Head title="Verify Email" />
<div class="login-container">
<!-- Animated Background -->
<div class="glow-orb orb-1"></div>
<div class="glow-orb orb-2"></div>
<div class="login-card">
<div class="card-header">
<div class="icon-wrapper mb-6">
<Mail class="w-10 h-10 text-[#ff007a] animate-pulse-slow" />
</div>
<h1 class="title">UNLOCK <span class="highlight">ACCESS</span></h1>
<p class="subtitle">Verify your email to start playing</p>
</div>
<div v-if="status === 'verification-link-sent'" class="mb-6 text-center text-sm font-bold text-green-400 bg-green-500/10 p-4 rounded-xl border border-green-500/20 shadow-[0_0_15px_rgba(74,222,128,0.1)]">
<div class="flex items-center justify-center gap-2">
<span class="w-2 h-2 bg-green-400 rounded-full animate-ping"></span>
Link sent! Check your inbox.
</div>
</div>
<div class="text-center text-[#888] text-sm mb-8 leading-relaxed px-4">
We've sent a verification link to your email address. <br>
Please click it to activate your account and claim your welcome bonus.
</div>
<form @submit.prevent="submit" class="form-content">
<Button
type="submit"
class="w-full h-14 text-base font-black tracking-widest uppercase neon-button group"
:disabled="form.processing"
>
<Spinner v-if="form.processing" class="mr-2" />
<span v-else class="flex items-center justify-center gap-2">
Resend Email <ArrowRight class="w-4 h-4 group-hover:translate-x-1 transition-transform" />
</span>
</Button>
<!-- Code entry (fallback #3) -->
<div v-if="hasCode" class="mt-6">
<div class="text-xs text-[#888] text-center mb-2">Oder gib den VerifizierungsCode aus der Email ein:</div>
<div class="flex items-center gap-2">
<input
type="text"
inputmode="numeric"
autocomplete="one-time-code"
maxlength="6"
placeholder="XXXXXX"
class="flex-1 bg-[#0b0b0b] border border-[#222] rounded-lg px-3 py-3 text-sm tracking-[0.4em] text-center text-[#ddd]"
v-model="codeForm.code"
@input="codeForm.code = String(($event.target as HTMLInputElement).value).replace(/\D+/g,'').slice(0,6)"
@keydown.enter.prevent="submitCode"
/>
<Button type="button" class="px-4 py-3" :disabled="codeForm.processing" @click="submitCode">
<Spinner v-if="codeForm.processing" class="mr-2" />
<span v-else>Verify</span>
</Button>
</div>
<div v-if="codeForm.errors.code" class="mt-2 text-xs text-red-400 text-center">{{ codeForm.errors.code }}</div>
</div>
<div class="text-center mt-8">
<button
type="button"
@click="logout"
class="text-[#444] hover:text-white transition-colors flex items-center justify-center gap-2 text-xs font-bold uppercase tracking-widest border border-transparent hover:border-[#333] px-4 py-2 rounded-lg"
>
<LogOut class="w-3 h-3" /> Log Out
</button>
</div>
</form>
</div>
</div>
</UserLayout>
</template>
<style scoped>
/* Container & Layout */
.login-container {
display: flex;
align-items: center;
justify-content: center;
min-height: calc(100vh - 100px);
padding: 20px;
position: relative;
overflow: hidden;
}
.login-card {
width: 100%;
max-width: 480px;
background: rgba(10, 10, 10, 0.8);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 24px;
padding: 50px 40px;
position: relative;
z-index: 10;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5);
animation: slideUp 0.8s cubic-bezier(0.16, 1, 0.3, 1);
}
/* Header */
.card-header {
text-align: center;
margin-bottom: 30px;
display: flex;
flex-direction: column;
align-items: center;
}
.icon-wrapper {
background: rgba(255, 0, 122, 0.05);
padding: 20px;
border-radius: 50%;
border: 1px solid rgba(255, 0, 122, 0.2);
box-shadow: 0 0 30px rgba(255, 0, 122, 0.1);
position: relative;
}
.icon-wrapper::after {
content: '';
position: absolute;
inset: -5px;
border-radius: 50%;
border: 1px solid rgba(255, 0, 122, 0.1);
animation: spin 10s linear infinite;
border-top-color: transparent;
border-bottom-color: transparent;
}
.title {
font-size: 2rem;
font-weight: 900;
color: white;
letter-spacing: 2px;
margin: 0;
line-height: 1;
}
.highlight {
color: #ff007a;
text-shadow: 0 0 20px rgba(255, 0, 122, 0.5);
}
.subtitle {
color: #666;
font-size: 0.85rem;
margin-top: 8px;
text-transform: uppercase;
letter-spacing: 1.5px;
font-weight: 700;
}
/* Neon Button */
.neon-button {
background: linear-gradient(90deg, #ff007a, #be005b);
border: none;
position: relative;
overflow: hidden;
transition: all 0.3s ease;
}
.neon-button:hover {
transform: translateY(-2px);
box-shadow: 0 0 30px rgba(255, 0, 122, 0.6);
filter: brightness(1.1);
}
.neon-button:disabled {
opacity: 0.5;
cursor: not-allowed;
filter: grayscale(1);
}
/* Background Orbs */
.glow-orb {
position: absolute;
border-radius: 50%;
filter: blur(80px);
opacity: 0.4;
z-index: 0;
animation: float 10s infinite ease-in-out;
}
.orb-1 {
width: 300px;
height: 300px;
background: #ff007a;
top: -50px;
left: -100px;
}
.orb-2 {
width: 400px;
height: 400px;
background: #00f2ff;
bottom: -100px;
right: -100px;
animation-delay: -5s;
}
/* Animations */
@keyframes slideUp {
from { opacity: 0; transform: translateY(40px) scale(0.95); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes float {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(20px, 30px); }
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.animate-pulse-slow {
animation: pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: .7; }
}
/* Mobile tweaks */
@media (max-width: 480px) {
.login-container { padding: calc(env(safe-area-inset-top, 0) + 10px) 12px 12px; }
.login-card { padding: 28px 16px; border-radius: 18px; }
.title { font-size: clamp(20px, 6vw, 28px); letter-spacing: 1px; }
.subtitle { font-size: 12px; }
.icon-wrapper { padding: 14px; }
.glow-orb { filter: blur(60px); }
.orb-1 { width: 180px; height: 180px; top: -40px; left: -60px; }
.orb-2 { width: 220px; height: 220px; bottom: -60px; right: -60px; }
/* Prevent iOS zoom on input */
input[type="text"] { font-size: 16px; }
}
</style>

View File

@@ -0,0 +1,521 @@
<script setup lang="ts">
import { Head, usePage, router } from '@inertiajs/vue3';
import { ref, computed, onMounted, nextTick } from 'vue';
import UserLayout from '../../layouts/user/userlayout.vue';
import { useNotifications } from '@/composables/useNotifications';
import ConfirmModal from '@/components/ui/ConfirmModal.vue';
const page = usePage();
const { notify } = useNotifications();
// Props from controller
const props = defineProps<{
guild: null | {
id: number;
name: string;
tag: string;
logo_url?: string | null;
description?: string | null;
points: number;
members_count: number;
owner: { id: number; username?: string; name?: string; avatar_url?: string | null };
members: { id: number; username?: string; name?: string; avatar_url?: string | null; role: string; joined_at?: string | null; wagered?: number }[];
};
myRole: string | null;
canManage: boolean;
invite: string | null;
}>();
const me = computed(() => page.props.auth?.user);
const hasGuild = computed(() => !!props.guild);
// Sort members by wagered amount (descending)
const sortedMembers = computed(() => {
if (!props.guild?.members) return [];
return [...props.guild.members].sort((a, b) => (b.wagered || 0) - (a.wagered || 0));
});
// Create form
const cName = ref('');
const cTag = ref('');
const cLogoFile = ref<File | null>(null);
const cLogoPreview = ref<string | null>(null);
const cDesc = ref('');
const cErrors = ref<Record<string,string>>({});
const cBusy = ref(false);
function onCLogoChange(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0] ?? null;
cLogoFile.value = file;
cLogoPreview.value = file ? URL.createObjectURL(file) : null;
}
async function createGuild() {
cBusy.value = true; cErrors.value = {};
try {
const fd = new FormData();
fd.append('name', cName.value);
fd.append('tag', cTag.value);
if (cDesc.value) fd.append('description', cDesc.value);
if (cLogoFile.value) fd.append('logo', cLogoFile.value);
const res = await fetch('/guilds', {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-TOKEN': (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement)?.content || '',
},
body: fd,
});
if (!res.ok) {
const j = await res.json().catch(() => ({}));
if (j?.errors) cErrors.value = Object.fromEntries(Object.entries(j.errors).map(([k,v]: any) => [k, (v as string[])[0]]));
throw new Error(j.message || 'Failed to create guild');
}
notify({ type: 'green', title: 'GUILD CREATED', desc: 'Welcome to your new guild!', icon: 'check-circle' });
router.visit('/guilds', { preserveScroll: true, preserveState: false });
} catch (e: any) {
notify({ type: 'magenta', title: 'ERROR', desc: e.message, icon: 'alert-triangle' });
} finally { cBusy.value = false; }
}
// Join form
const jCode = ref('');
const jBusy = ref(false);
async function joinGuild() {
jBusy.value = true;
try {
const res = await fetch('/guilds/join', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-TOKEN': (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement)?.content || '',
},
body: JSON.stringify({ invite_code: jCode.value })
});
const j = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(j.message || 'Failed to join guild');
notify({ type: 'green', title: 'JOINED GUILD', desc: 'You are now a member.', icon: 'users' });
router.visit('/guilds', { preserveScroll: true, preserveState: false });
} catch (e: any) {
notify({ type: 'magenta', title: 'ERROR', desc: e.message, icon: 'alert-triangle' });
} finally { jBusy.value = false; }
}
// Manage actions
const uLogoFile = ref<File | null>(null);
const uLogoPreview = ref<string | null>(props.guild?.logo_url || null);
const uDesc = ref(props.guild?.description || '');
const mBusy = ref(false);
function onULogoChange(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0] ?? null;
uLogoFile.value = file;
uLogoPreview.value = file ? URL.createObjectURL(file) : (props.guild?.logo_url || null);
}
async function saveGuild() {
mBusy.value = true;
try {
const fd = new FormData();
fd.append('_method', 'PATCH');
fd.append('description', uDesc.value || '');
if (uLogoFile.value) fd.append('logo', uLogoFile.value);
const res = await fetch('/guilds/update', {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-TOKEN': (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement)?.content || '',
},
body: fd,
});
const j = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(j.message || 'Failed to update guild');
notify({ type: 'green', title: 'SAVED', desc: 'Guild settings updated.', icon: 'save' });
} catch (e: any) {
notify({ type: 'magenta', title: 'ERROR', desc: e.message, icon: 'alert-triangle' });
} finally { mBusy.value = false; }
}
// Kick Logic
const kickBusy = ref<number|null>(null);
const showKickModal = ref(false);
const userToKick = ref<number|null>(null);
function confirmKick(id: number) {
userToKick.value = id;
showKickModal.value = true;
}
async function kickUser() {
if (!userToKick.value) return;
showKickModal.value = false;
kickBusy.value = userToKick.value;
try {
const res = await fetch('/guilds/kick', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-TOKEN': (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement)?.content || '',
},
body: JSON.stringify({ user_id: userToKick.value })
});
if (!res.ok) {
const j = await res.json().catch(() => ({}));
throw new Error(j.message || 'Failed to remove member');
}
notify({ type: 'green', title: 'KICKED', desc: 'Member removed.', icon: 'user-minus' });
router.visit('/guilds', { preserveScroll: true, preserveState: false });
} catch (e: any) {
notify({ type: 'magenta', title: 'ERROR', desc: e.message, icon: 'alert-triangle' });
} finally { kickBusy.value = null; userToKick.value = null; }
}
// Leave Logic
const leaveBusy = ref(false);
const showLeaveModal = ref(false);
async function leaveGuild() {
showLeaveModal.value = false;
leaveBusy.value = true;
try {
const res = await fetch('/guilds/leave', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-TOKEN': (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement)?.content || '',
},
body: JSON.stringify({})
});
const j = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(j.message || 'Failed to leave guild');
notify({ type: 'green', title: 'LEFT GUILD', desc: 'You have left the guild.', icon: 'log-out' });
router.visit('/guilds', { preserveScroll: true, preserveState: false });
} catch (e: any) {
notify({ type: 'magenta', title: 'ERROR', desc: e.message, icon: 'alert-triangle' });
} finally { leaveBusy.value = false; }
}
const inviteCode = ref(props.invite || '');
const regenBusy = ref(false);
async function regenerateInvite() {
regenBusy.value = true;
try {
const res = await fetch('/guilds/invite/regenerate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-TOKEN': (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement)?.content || '',
},
body: JSON.stringify({})
});
const j = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(j.message || 'Failed to regenerate invite');
inviteCode.value = j.invite_code;
notify({ type: 'green', title: 'REGENERATED', desc: 'New invite code created.', icon: 'refresh-cw' });
} catch (e: any) {
notify({ type: 'magenta', title: 'ERROR', desc: e.message, icon: 'alert-triangle' });
} finally { regenBusy.value = false; }
}
function copyInvite() {
if (!inviteCode.value) return;
navigator.clipboard.writeText(inviteCode.value);
notify({ type: 'green', title: 'COPIED', desc: 'Invite code copied to clipboard.', icon: 'copy' });
}
onMounted(() => nextTick(() => { if ((window as any).lucide) (window as any).lucide.createIcons(); }));
</script>
<template>
<UserLayout>
<Head title="Guilds" />
<!-- Modals -->
<ConfirmModal
:isOpen="showKickModal"
:title="$t('guilds.kick')"
message="Are you sure you want to remove this member from the guild? They will need a new invite to rejoin."
confirmText="Kick"
@close="showKickModal = false"
@confirm="kickUser"
/>
<ConfirmModal
:isOpen="showLeaveModal"
:title="$t('guilds.leave')"
message="WARNING: Leaving the guild will reset your guild contribution. You must hold the button to confirm."
:confirmText="$t('guilds.leave').toUpperCase()"
:requireHold="true"
@close="showLeaveModal = false"
@confirm="leaveGuild"
/>
<section class="content">
<div class="wrap">
<div class="panel main-panel">
<header class="page-head">
<div class="head-flex">
<div class="title-group">
<div class="title">{{ $t('guilds.title') }}</div>
<p class="subtitle" v-if="!hasGuild">{{ $t('guilds.subtitle_new') }}</p>
<p class="subtitle" v-else>{{ $t('guilds.subtitle_member') }}</p>
</div>
<div class="security-badge" v-if="hasGuild">
<i data-lucide="shield-check"></i>
<span>{{ $t('guilds.tab_active') }}</span>
</div>
</div>
</header>
<!-- No guild: show Create / Join -->
<div v-if="!hasGuild" class="grid two">
<div class="glass-card">
<div class="card-head"><i data-lucide="plus-circle"></i> {{ $t('guilds.create') }}</div>
<div class="grid-form">
<label class="field">
<span class="lbl">{{ $t('guilds.name') }}</span>
<input class="inp" v-model="cName" :placeholder="$t('guilds.name')" />
</label>
<label class="field">
<span class="lbl">{{ $t('guilds.tag') }}</span>
<input class="inp" v-model="cTag" placeholder="TAG (26)" />
</label>
<label class="field">
<span class="lbl">{{ $t('guilds.logo_url') }}</span>
<div class="logo-upload-wrap">
<div class="logo-preview" :style="cLogoPreview ? { backgroundImage: `url(${cLogoPreview})` } : {}">
<span v-if="!cLogoPreview">{{ cTag?.[0]?.toUpperCase() || '?' }}</span>
</div>
<label class="logo-pick-btn">
<i data-lucide="upload"></i> Choose Logo
<input type="file" accept="image/*" class="sr-only" @change="onCLogoChange" />
</label>
</div>
</label>
<label class="field col-span-2">
<span class="lbl">{{ $t('guilds.description') }}</span>
<textarea class="inp" v-model="cDesc" rows="3" placeholder="Short description (max 500)"></textarea>
</label>
</div>
<div class="errors" v-if="Object.keys(cErrors).length">
<div v-for="(v,k) in cErrors" :key="k" class="err">{{ v }}</div>
</div>
<button class="btn-primary mt-4" :disabled="cBusy" @click="createGuild">
<span v-if="cBusy" class="spinner"></span>
<span>{{ cBusy ? 'Creating…' : 'Create Guild' }}</span>
</button>
</div>
<div class="glass-card">
<div class="card-head"><i data-lucide="log-in"></i> {{ $t('guilds.join') }}</div>
<div class="grid-form">
<label class="field col-span-2">
<span class="lbl">{{ $t('guilds.invite_code') }}</span>
<input class="inp" v-model="jCode" :placeholder="$t('guilds.invite_code')" />
</label>
</div>
<button class="btn-secondary mt-4" :disabled="jBusy || !jCode.trim()" @click="joinGuild">
<span v-if="jBusy" class="spinner"></span>
<span>{{ jBusy ? 'Joining…' : 'Join' }}</span>
</button>
</div>
</div>
<!-- Has guild: dashboard -->
<div v-else class="guild-dashboard">
<div class="g-head-card">
<div class="logo-wrap">
<div class="logo" :style="{ backgroundImage: props.guild?.logo_url ? `url(${props.guild.logo_url})` : '' }">
<span v-if="!props.guild?.logo_url">{{ props.guild?.tag?.[0] || 'G' }}</span>
</div>
</div>
<div class="meta">
<div class="g-name">{{ props.guild?.name }} <span class="tag">[{{ props.guild?.tag }}]</span></div>
<div class="g-desc">{{ props.guild?.description || 'No description yet.' }}</div>
<div class="g-stats">
<div class="stat-pill"><i data-lucide="trophy"></i> {{ props.guild?.points }} Points</div>
<div class="stat-pill"><i data-lucide="users"></i> {{ props.guild?.members_count }} Members</div>
</div>
</div>
</div>
<div class="controls-grid" v-if="props.canManage">
<div class="glass-card">
<div class="card-head"><i data-lucide="link"></i> {{ $t('guilds.invite_code') }}</div>
<div class="inv-row">
<input class="inp" :value="inviteCode" readonly />
<button class="btn-icon" @click="copyInvite" :disabled="!inviteCode" title="Copy"><i data-lucide="copy"></i></button>
<button class="btn-icon" @click="regenerateInvite" :disabled="regenBusy" title="Regenerate"><i data-lucide="refresh-cw"></i></button>
</div>
</div>
<div class="glass-card">
<div class="card-head"><i data-lucide="settings"></i> {{ $t('guilds.appearance') }}</div>
<div class="grid-form">
<label class="field">
<span class="lbl">{{ $t('guilds.logo_url') }}</span>
<div class="logo-upload-wrap">
<div class="logo-preview" :style="uLogoPreview ? { backgroundImage: `url(${uLogoPreview})` } : {}">
<span v-if="!uLogoPreview">{{ props.guild?.tag?.[0] || 'G' }}</span>
</div>
<label class="logo-pick-btn">
<i data-lucide="upload"></i> Change Logo
<input type="file" accept="image/*" class="sr-only" @change="onULogoChange" />
</label>
</div>
</label>
<label class="field col-span-2">
<span class="lbl">{{ $t('guilds.description') }}</span>
<textarea class="inp" v-model="uDesc" rows="2" />
</label>
</div>
<button class="btn-primary mt-3" :disabled="mBusy" @click="saveGuild">{{ mBusy ? 'Saving' : 'Save Changes' }}</button>
</div>
</div>
<div class="members-section">
<div class="section-head">
<h3>{{ $t('guilds.members') }}</h3>
<span class="sub">{{ $t('guilds.sorted_by_wager') }}</span>
</div>
<div class="m-list">
<div v-for="m in sortedMembers" :key="m.id" class="m-row">
<div class="m-avatar"><img v-if="m.avatar_url" :src="m.avatar_url" alt="" /><span v-else>{{ (m.username || m.name || 'U')?.[0]?.toUpperCase?.() }}</span></div>
<div class="m-info">
<div class="m-name">{{ m.username || m.name || 'User' }} <span v-if="m.id === me?.id" class="you-badge">(You)</span></div>
<div class="m-wager">{{ $t('guilds.wagered') }}: {{ m.wagered ? m.wagered.toFixed(2) : '0.00' }}</div>
</div>
<div class="m-role" :data-role="m.role">{{ m.role }}</div>
<div class="m-actions">
<button v-if="props.canManage && m.role !== 'owner' && m.id !== me?.id" class="btn-danger-sm" :disabled="kickBusy===m.id" @click="confirmKick(m.id)">
<i data-lucide="user-minus"></i>
</button>
</div>
</div>
</div>
</div>
<div class="footer-actions">
<button class="btn-leave" :disabled="leaveBusy" @click="showLeaveModal = true">
<i data-lucide="log-out"></i> {{ $t('guilds.leave') }}
</button>
</div>
</div>
</div>
</div>
</section>
</UserLayout>
</template>
<style scoped>
:global(:root) { --bg-card:#0a0a0a; --border:#151515; --cyan:#00f2ff; --magenta:#ff007a; --green:#00ff9d; }
.content { padding: 30px; animation: fade-in 0.8s cubic-bezier(0.2, 0, 0, 1); }
.wrap { max-width: 1100px; margin: 0 auto; }
.main-panel { background: var(--bg-card); border: 1px solid var(--border); border-radius: 20px; overflow: hidden; box-shadow: 0 20px 50px rgba(0,0,0,0.6); }
/* Header */
.page-head { padding: 25px 30px; border-bottom: 1px solid var(--border); background: linear-gradient(to right, rgba(0,242,255,0.03), transparent); }
.head-flex { display: flex; justify-content: space-between; align-items: center; }
.title { font-size: 14px; font-weight: 900; color: #fff; letter-spacing: 3px; text-transform: uppercase; }
.subtitle { color: #555; font-size: 12px; margin-top: 4px; font-weight: 600; }
.security-badge { display:flex; align-items:center; gap:8px; color: var(--green); background: rgba(0,255,157,0.05); padding:6px 14px; border-radius:50px; border:1px solid rgba(0,255,157,0.1); font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:1px; }
/* Cards */
.grid.two { padding: 30px; display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
.glass-card { background: #050505; border: 1px solid var(--border); border-radius: 16px; padding: 20px; transition: 0.3s; }
.glass-card:hover { border-color: #222; box-shadow: 0 10px 30px rgba(0,0,0,0.3); }
.card-head { font-size: 11px; font-weight: 900; text-transform: uppercase; letter-spacing: 1px; color: #fff; margin-bottom: 15px; display: flex; align-items: center; gap: 8px; }
.card-head i { width: 14px; color: var(--cyan); }
/* Forms */
.grid-form { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; }
.field { display: flex; flex-direction: column; gap: 6px; }
.lbl { font-size: 10px; font-weight: 900; color: #555; text-transform: uppercase; letter-spacing: 1px; }
.inp { background: #000; border: 1px solid var(--border); color: #fff; border-radius: 10px; padding: 12px 14px; font-size: 13px; transition: 0.3s; width: 100%; }
.inp:focus { border-color: var(--cyan); outline: none; box-shadow: 0 0 15px rgba(0,242,255,0.1); }
.col-span-2 { grid-column: span 2; }
/* Buttons */
.btn-primary { background: var(--cyan); color: #000; border: none; border-radius: 10px; padding: 12px 20px; font-size: 12px; font-weight: 900; text-transform: uppercase; letter-spacing: 1px; cursor: pointer; transition: 0.3s; display: flex; align-items: center; justify-content: center; gap: 10px; }
.btn-primary:hover:not(:disabled) { transform: translateY(-2px); box-shadow: 0 5px 20px rgba(0,242,255,0.3); }
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
.btn-secondary { background: transparent; border: 1px solid #333; color: #fff; border-radius: 10px; padding: 12px 20px; font-size: 12px; font-weight: 900; text-transform: uppercase; letter-spacing: 1px; cursor: pointer; transition: 0.3s; display: flex; align-items: center; justify-content: center; gap: 10px; }
.btn-secondary:hover:not(:disabled) { border-color: #fff; background: #111; }
.btn-icon { width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; background: #111; border: 1px solid var(--border); color: #888; border-radius: 8px; cursor: pointer; transition: 0.2s; }
.btn-icon:hover { color: #fff; border-color: #444; }
.btn-danger-sm { width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; background: rgba(255,62,62,0.1); border: 1px solid rgba(255,62,62,0.2); color: #ff3e3e; border-radius: 8px; cursor: pointer; transition: 0.2s; }
.btn-danger-sm:hover { background: rgba(255,62,62,0.2); transform: scale(1.1); }
.btn-leave { background: transparent; border: 1px solid #333; color: #666; border-radius: 10px; padding: 10px 16px; font-size: 11px; font-weight: 800; text-transform: uppercase; cursor: pointer; transition: 0.3s; display: flex; align-items: center; gap: 8px; margin-left: auto; }
.btn-leave:hover { color: #ff3e3e; border-color: #ff3e3e; background: rgba(255,62,62,0.05); }
/* Guild Dashboard */
.guild-dashboard { padding: 30px; display: flex; flex-direction: column; gap: 30px; }
.g-head-card { display: flex; gap: 24px; align-items: center; background: #050505; border: 1px solid var(--border); padding: 24px; border-radius: 16px; position: relative; overflow: hidden; }
.g-head-card::after { content: ''; position: absolute; top: 0; right: 0; width: 200px; height: 100%; background: linear-gradient(90deg, transparent, rgba(0,242,255,0.03)); pointer-events: none; }
.logo { width: 80px; height: 80px; border-radius: 20px; border: 1px solid var(--border); background: #000 center/cover no-repeat; display: grid; place-items: center; font-size: 24px; font-weight: 900; color: #333; box-shadow: 0 10px 30px rgba(0,0,0,0.5); }
.meta { display: flex; flex-direction: column; gap: 8px; }
.g-name { color: #fff; font-size: 20px; font-weight: 900; letter-spacing: 1px; }
.tag { color: var(--cyan); font-size: 12px; margin-left: 8px; background: rgba(0,242,255,0.1); padding: 2px 6px; border-radius: 4px; }
.g-desc { color: #888; font-size: 13px; max-width: 600px; line-height: 1.5; }
.g-stats { display: flex; gap: 12px; margin-top: 5px; }
.stat-pill { display: flex; align-items: center; gap: 6px; font-size: 11px; font-weight: 800; color: #bbb; background: #111; padding: 4px 10px; border-radius: 50px; border: 1px solid #222; }
.stat-pill i { width: 12px; color: var(--gold); }
.controls-grid { display: grid; grid-template-columns: 1fr 1.5fr; gap: 24px; }
.inv-row { display: flex; gap: 10px; }
/* Members */
.members-section { background: #050505; border: 1px solid var(--border); border-radius: 16px; padding: 24px; }
.section-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; border-bottom: 1px solid #151515; padding-bottom: 15px; }
.section-head h3 { font-size: 12px; font-weight: 900; text-transform: uppercase; letter-spacing: 1px; color: #fff; margin: 0; }
.section-head .sub { font-size: 10px; color: #666; font-weight: 700; text-transform: uppercase; }
.m-list { display: flex; flex-direction: column; gap: 10px; }
.m-row { display: grid; grid-template-columns: 40px 1fr auto auto; gap: 15px; align-items: center; background: #0a0a0a; border: 1px solid #151515; border-radius: 12px; padding: 12px 16px; transition: 0.2s; }
.m-row:hover { border-color: #333; background: #0e0e0e; }
.m-avatar { width: 40px; height: 40px; border: 1px solid #222; background: #000; color: #444; display: grid; place-items: center; border-radius: 10px; overflow: hidden; font-weight: 800; }
.m-avatar img { width: 100%; height: 100%; object-fit: cover; }
.m-info { display: flex; flex-direction: column; gap: 2px; }
.m-name { color: #fff; font-weight: 800; font-size: 13px; }
.you-badge { font-size: 9px; color: var(--cyan); margin-left: 5px; }
.m-wager { font-size: 10px; color: #666; font-weight: 700; }
.m-role { font-size: 10px; font-weight: 900; text-transform: uppercase; padding: 4px 10px; border-radius: 6px; background: #111; color: #888; border: 1px solid #222; }
.m-role[data-role="owner"] { color: var(--gold); border-color: rgba(247,147,26,0.3); background: rgba(247,147,26,0.05); }
/* Logo upload */
.logo-upload-wrap { display: flex; align-items: center; gap: 12px; }
.logo-preview { width: 52px; height: 52px; border-radius: 12px; border: 1px solid var(--border); background: #000 center/cover no-repeat; display: grid; place-items: center; font-size: 18px; font-weight: 900; color: #444; flex-shrink: 0; }
.logo-pick-btn { display: flex; align-items: center; gap: 6px; background: #111; border: 1px solid #333; color: #aaa; border-radius: 8px; padding: 8px 14px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; cursor: pointer; transition: 0.2s; }
.logo-pick-btn:hover { border-color: var(--cyan); color: var(--cyan); }
.logo-pick-btn i { width: 13px; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0); }
.spinner { width: 16px; height: 16px; border: 2px solid rgba(0,0,0,0.1); border-top-color: currentColor; border-radius: 50%; animation: spin 0.8s linear infinite; }
.err { color: #ff3e3e; font-size: 11px; font-weight: 700; }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes fade-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
@media (max-width: 900px) {
.grid.two { grid-template-columns: 1fr; }
.controls-grid { grid-template-columns: 1fr; }
.g-head-card { flex-direction: column; text-align: center; }
.g-stats { justify-content: center; }
}
</style>

View File

@@ -0,0 +1,152 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { onMounted, nextTick } from 'vue';
import UserLayout from '../../layouts/user/userlayout.vue';
type Guild = { id:number; name:string; tag:string; logo_url?:string|null; points:number; members_count:number };
const props = defineProps<{ guilds: Guild[] }>();
onMounted(() => nextTick(() => { if (window.lucide) window.lucide.createIcons(); }));
</script>
<template>
<UserLayout>
<Head :title="$t('guilds.top_title')" />
<section class="content">
<div class="wrap">
<div class="panel main-panel">
<header class="page-head">
<div class="head-flex">
<div class="title-group">
<div class="title">{{ $t('guilds.top_title') }}</div>
<p class="subtitle">{{ $t('guilds.top_desc') }}</p>
</div>
<div class="trophy-icon">
<i data-lucide="trophy"></i>
</div>
</div>
</header>
<div class="list-container">
<div class="list-header-row">
<div class="col rank">{{ $t('guilds.rank') }}</div>
<div class="col guild">{{ $t('guilds.guild') }}</div>
<div class="col spacer"></div>
<div class="col stat">{{ $t('guilds.points') }}</div>
<div class="col stat">{{ $t('guilds.members_col') }}</div>
</div>
<div class="list">
<div v-for="(g, i) in props.guilds" :key="g.id" class="row" :class="`rank-${i+1}`">
<div class="rank-cell">
<span v-if="i < 3" class="medal">
<i data-lucide="medal" v-if="i===0" class="gold"></i>
<i data-lucide="medal" v-if="i===1" class="silver"></i>
<i data-lucide="medal" v-if="i===2" class="bronze"></i>
</span>
<span v-else class="num">#{{ i+1 }}</span>
</div>
<div class="logo-cell">
<div class="logo" :style="{ backgroundImage: g.logo_url ? `url(${g.logo_url})` : '' }">
<span v-if="!g.logo_url">{{ g.tag?.[0] || 'G' }}</span>
</div>
</div>
<div class="name-cell">
<div class="g-name">{{ g.name }}</div>
<div class="g-tag">[{{ g.tag }}]</div>
</div>
<div class="spacer"></div>
<div class="stat-cell points">
<i data-lucide="zap" class="stat-icon"></i>
<span>{{ g.points.toLocaleString() }}</span>
</div>
<div class="stat-cell members">
<i data-lucide="users" class="stat-icon"></i>
<span>{{ g.members_count }}</span>
</div>
</div>
<div v-if="!props.guilds?.length" class="empty-state">
<i data-lucide="shield-off"></i>
<p>{{ $t('guilds.no_guilds') }}</p>
</div>
</div>
</div>
</div>
</div>
</section>
</UserLayout>
</template>
<style scoped>
:global(:root) { --bg-card:#0a0a0a; --border:#151515; --cyan:#00f2ff; --magenta:#ff007a; --green:#00ff9d; --gold:#f7931a; --silver:#c0c0c0; --bronze:#cd7f32; }
.content { padding: 30px; animation: fade-in 0.8s cubic-bezier(0.2, 0, 0, 1); }
.wrap { max-width: 1000px; margin: 0 auto; }
.main-panel { background: var(--bg-card); border: 1px solid var(--border); border-radius: 20px; overflow: hidden; box-shadow: 0 20px 50px rgba(0,0,0,0.6); }
/* Header */
.page-head { padding: 25px 30px; border-bottom: 1px solid var(--border); background: linear-gradient(to right, rgba(247,147,26,0.05), transparent); }
.head-flex { display: flex; justify-content: space-between; align-items: center; }
.title { font-size: 14px; font-weight: 900; color: #fff; letter-spacing: 3px; text-transform: uppercase; }
.subtitle { color: #555; font-size: 12px; margin-top: 4px; font-weight: 600; }
.trophy-icon { width: 40px; height: 40px; background: rgba(247,147,26,0.1); border-radius: 50%; display: flex; align-items: center; justify-content: center; color: var(--gold); border: 1px solid rgba(247,147,26,0.2); }
/* List Header */
.list-container { padding: 20px; }
.list-header-row { display: flex; padding: 0 20px 10px; border-bottom: 1px solid #151515; margin-bottom: 10px; font-size: 10px; font-weight: 900; color: #555; text-transform: uppercase; letter-spacing: 1px; }
.col.rank { width: 60px; text-align: center; }
.col.guild { width: 200px; }
.col.spacer { flex: 1; }
.col.stat { width: 100px; text-align: right; }
/* List Items */
.list { display: flex; flex-direction: column; gap: 8px; }
.row { display: flex; align-items: center; background: #050505; border: 1px solid var(--border); border-radius: 14px; padding: 12px 20px; transition: 0.2s; }
.row:hover { transform: translateX(5px); border-color: #333; background: #0a0a0a; }
/* Rank Styling */
.rank-cell { width: 60px; display: flex; justify-content: center; align-items: center; font-weight: 900; font-size: 14px; color: #444; }
.medal i { width: 20px; height: 20px; }
.gold { color: var(--gold); filter: drop-shadow(0 0 5px rgba(247,147,26,0.5)); }
.silver { color: var(--silver); filter: drop-shadow(0 0 5px rgba(192,192,192,0.5)); }
.bronze { color: var(--bronze); filter: drop-shadow(0 0 5px rgba(205,127,50,0.5)); }
/* Top 3 Highlights */
.rank-1 { border-color: rgba(247,147,26,0.3); background: linear-gradient(90deg, rgba(247,147,26,0.05), transparent); }
.rank-2 { border-color: rgba(192,192,192,0.2); }
.rank-3 { border-color: rgba(205,127,50,0.2); }
.logo-cell { margin-right: 15px; }
.logo { width: 42px; height: 42px; border-radius: 12px; background: #000 center/cover no-repeat; border: 1px solid #222; display: grid; place-items: center; font-weight: 900; color: #666; }
.name-cell { display: flex; flex-direction: column; }
.g-name { color: #fff; font-weight: 800; font-size: 13px; }
.g-tag { color: var(--cyan); font-size: 10px; font-weight: 700; }
.spacer { flex: 1; }
.stat-cell { width: 100px; text-align: right; font-weight: 800; font-size: 13px; color: #ccc; display: flex; align-items: center; justify-content: flex-end; gap: 6px; }
.stat-icon { width: 14px; color: #444; }
.points { color: var(--gold); }
.points .stat-icon { color: var(--gold); opacity: 0.5; }
.empty-state { padding: 40px; text-align: center; color: #444; display: flex; flex-direction: column; align-items: center; gap: 10px; }
.empty-state i { width: 32px; height: 32px; opacity: 0.5; }
@keyframes fade-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
@media (max-width: 700px) {
.list-header-row { display: none; }
.row { display: grid; grid-template-columns: auto auto 1fr auto; gap: 10px; padding: 15px; }
.rank-cell { width: 30px; }
.stat-cell { width: auto; font-size: 11px; }
.stat-cell.members { display: none; } /* Hide members count on very small screens */
}
</style>

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
import PolicyLayout from './_PolicyLayout.vue';
const updated = '2026-03-01T20:20:00Z';
const intro = 'Our AML/CTF approach: KYC verification, transaction monitoring, reporting obligations, and record-keeping.';
const metaDescription = 'BetiX AML Policy: KYC, monitoring for suspicious activity, reporting to authorities, record-keeping, and prohibited behavior.';
</script>
<template>
<PolicyLayout title="AML Policy" :updated="updated" :intro="intro" :metaDescription="metaDescription">
<p>This AntiMoney Laundering (AML) Policy outlines the measures BetiX takes to prevent money laundering and terrorist financing.</p>
<h2>1. Verification (KYC)</h2>
<p>We may request identity and address verification documents before allowing certain transactions.</p>
<h2>2. Monitoring</h2>
<p>Transactions are monitored for suspicious activity. We may suspend accounts while reviews are conducted.</p>
<h2>3. Reporting</h2>
<p>Where legally required, we report suspicious activity to competent authorities and cooperate with investigations.</p>
<h2>4. Prohibited activity</h2>
<p>Use of stolen funds, chargeback abuse, or attempts to obfuscate source of funds is strictly prohibited.</p>
</PolicyLayout>
</template>

View File

@@ -0,0 +1,30 @@
<script setup lang="ts">
import PolicyLayout from './_PolicyLayout.vue';
const updated = '2026-03-01T20:20:00Z';
const intro = 'Rules for bonuses, free spins, and promotions: eligibility, wagering, abuse prevention, and payout conditions.';
const metaDescription = 'BetiX Bonus Policy: eligibility, wagering requirements, bonus abuse rules, payout conditions, and bonus types.';
</script>
<template>
<PolicyLayout title="Bonus Policy" :updated="updated" :intro="intro" :metaDescription="metaDescription">
<p>This Bonus Policy sets out the rules for bonuses, free spins, and promotional offers on BetiX.</p>
<h2>1. Eligibility</h2>
<ul>
<li>Bonuses are limited to one per person, household, IP, device, or payment method unless stated otherwise.</li>
<li>We may require KYC verification before or after awarding a bonus.</li>
</ul>
<h2>2. Wagering</h2>
<ul>
<li>Unless otherwise stated, wagering requirements apply and must be completed within the specified time window.</li>
<li>Different games may contribute differently towards wagering.</li>
</ul>
<h2>3. Abuse</h2>
<p>Bonus abuse (e.g., hedging, multiple accounts, riskfree bets) may lead to confiscation and account action.</p>
<h2>4. Changes</h2>
<p>We reserve the right to modify, suspend, or withdraw any offer at our discretion.</p>
</PolicyLayout>
</template>

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import PolicyLayout from './_PolicyLayout.vue';
const updated = '2026-03-01T20:20:00Z';
const intro = 'How and why we use cookies and similar technologies, how you can control them, and the impact on your experience.';
const metaDescription = 'BetiX Cookie Policy: types of cookies, consent management, providers, and how to control cookies in your browser.';
</script>
<template>
<PolicyLayout title="Cookie Policy" :updated="updated" :intro="intro" :metaDescription="metaDescription">
<p>This Cookie Policy explains how BetiX uses cookies and similar technologies to recognize you when you visit our site and use our services.</p>
<h2>1. What are cookies?</h2>
<p>Cookies are small text files stored on your device to help websites function and improve your experience.</p>
<h2>2. How we use cookies</h2>
<ul>
<li>Strictly necessary cookies for authentication and security (e.g., CSRF, session).</li>
<li>Performance cookies to measure site usage and improve features.</li>
<li>Preference cookies to remember your settings (e.g., theme, language).</li>
</ul>
<h2>3. Managing cookies</h2>
<p>You can control cookies through your browser settings. Disabling some cookies may affect site functionality.</p>
<div class="callout">For questions about cookies, contact Support.</div>
</PolicyLayout>
</template>

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
import PolicyLayout from './_PolicyLayout.vue';
const updated = '2026-03-01T20:20:00Z';
const intro = 'How to raise complaints, how we handle them, escalation routes, and timelines.';
const metaDescription = 'BetiX Dispute Resolution Policy: internal complaints process, escalation to ADR, timelines, and evidence requirements.';
</script>
<template>
<PolicyLayout title="Dispute Resolution Policy" :updated="updated" :intro="intro" :metaDescription="metaDescription">
<p>This policy explains how to raise and resolve disputes with BetiX fairly and efficiently.</p>
<h2>1. Contact Support first</h2>
<p>Please contact our Support team with full details (time, game, bet ID, screenshots if possible). Most issues are resolved quickly.</p>
<h2>2. Escalation</h2>
<p>If you are not satisfied with the initial response, request an escalation. A senior specialist will review your case.</p>
<h2>3. External ADR</h2>
<p>Where required by local regulation, you may refer your complaint to an approved Alternative Dispute Resolution (ADR) provider. We will cooperate in good faith.</p>
<h2>4. Time limits</h2>
<p>Submit disputes within 30 days of the event. Delayed submissions may be harder to investigate.</p>
</PolicyLayout>
</template>

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
import PolicyLayout from './_PolicyLayout.vue';
const updated = '2026-03-01T20:20:00Z';
const intro = 'How we collect, use, share, and protect your personal data, and what rights you have over it.';
const metaDescription = 'BetiX Privacy Policy: data we collect, purposes, legal bases, recipients, retention, your rights, cookies, and security.';
</script>
<template>
<PolicyLayout title="Privacy Policy" :updated="updated" :intro="intro" :metaDescription="metaDescription">
<p>This Privacy Policy describes how BetiX collects, uses, and protects your personal data in accordance with applicable data protection laws.</p>
<h2>1. Data we collect</h2>
<ul>
<li>Account data: email/username, profile details and preferences.</li>
<li>Usage data: device, IP, user agent, and activity logs for security.</li>
<li>Compliance data: KYC/AML documents where required by law.</li>
</ul>
<h2>2. How we use data</h2>
<ul>
<li>To provide and improve services, ensure security, and meet legal obligations.</li>
<li>To communicate with you about service updates, security, and promotions (where permitted).</li>
</ul>
<h2>3. Your rights</h2>
<p>You may have rights to access, correct, or delete your data, and to object or restrict certain processing, subject to legal exceptions.</p>
<h2>4. Security</h2>
<p>We use technical and organizational measures, including encryption at rest for sensitive fields, to protect your data.</p>
<div class="callout">Contact Support for privacy requests or questions.</div>
</PolicyLayout>
</template>

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import PolicyLayout from './_PolicyLayout.vue';
const updated = '2026-03-01T20:20:00Z';
const intro = 'Our commitment to safe play: tools, guidance, and resources to help you stay in control.';
const metaDescription = 'BetiX Responsible Gaming: tools like limits/timeouts/self-exclusion, support resources, and protection of minors.';
</script>
<template>
<PolicyLayout title="Responsible Gaming" :updated="updated" :intro="intro" :metaDescription="metaDescription">
<p>BetiX is committed to responsible gaming. We provide tools and guidance to help you stay in control.</p>
<h2>1. Tools</h2>
<ul>
<li>Deposit, loss, and session limits.</li>
<li>Timeouts and selfexclusion.</li>
<li>Reality checks and session reminders.</li>
</ul>
<h2>2. Selfassessment</h2>
<p>If you feel your gaming is getting out of control, consider taking a break and seeking help from independent organizations.</p>
<h2>3. Support</h2>
<p>Contact our Support to enable limits or selfexclusion. We may require ID verification to process certain requests.</p>
<div class="callout">Help resources: BeGambleAware.org, GamCare.org.uk, and local hotlines in your country.</div>
</PolicyLayout>
</template>

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
import PolicyLayout from './_PolicyLayout.vue';
const updated = '2026-03-01T20:20:00Z';
const intro = 'Important information about risks when participating in online gaming and using crypto assets.';
const metaDescription = 'BetiX Risk Warnings: financial loss risk, volatility, responsible play guidance, and legal considerations.';
</script>
<template>
<PolicyLayout title="Risk Warnings" :updated="updated" :intro="intro" :metaDescription="metaDescription">
<p>Online gaming involves financial risk. Please read and understand the following warnings before participating.</p>
<h2>1. Loss risk</h2>
<p>You can lose money. Do not gamble funds you cannot afford to lose.</p>
<h2>2. Volatility</h2>
<p>Outcomes are random; shortterm results can vary widely. Past results do not predict future performance.</p>
<h2>3. Responsible play</h2>
<p>Set limits, take breaks, and seek help if needed. See Responsible Gaming for resources.</p>
<h2>4. Legal</h2>
<p>Make sure participation is legal in your jurisdiction. You are responsible for compliance with local laws.</p>
</PolicyLayout>
</template>

View File

@@ -0,0 +1,47 @@
<script setup lang="ts">
import PolicyLayout from './_PolicyLayout.vue';
const updated = '2026-03-01T20:20:00Z';
const intro = 'The rules that govern your use of BetiX, including eligibility, accounts, payments, bonuses, and more.';
const metaDescription = 'BetiX Terms and Conditions: eligibility, accounts, deposits/withdrawals, bonuses, fair play, liability, termination, and governing law.';
</script>
<template>
<PolicyLayout title="Terms and Conditions" :updated="updated" :intro="intro" :metaDescription="metaDescription">
<p>These Terms and Conditions ("Terms") govern your access to and use of the BetiX platform. By creating an account or using the services, you agree to be bound by these Terms.</p>
<h2>1. Eligibility</h2>
<ul>
<li>You must be at least 18 years old (or the legal age in your jurisdiction) to use our services.</li>
<li>You are solely responsible for ensuring that online gaming is legal in your jurisdiction.</li>
</ul>
<h2>2. Account</h2>
<ul>
<li>You agree to provide accurate information and keep it updated.</li>
<li>You are responsible for safeguarding your credentials and account activity.</li>
</ul>
<h2>3. Fair Play</h2>
<p>Any attempt to cheat, exploit bugs, use bots, multi-accounting, or otherwise gain unfair advantage may result in suspension, confiscation of funds, or permanent ban.</p>
<h2>4. Deposits and Withdrawals</h2>
<ul>
<li>All transactions are subject to AML/KYC checks and risk reviews.</li>
<li>We reserve the right to request additional verification before processing withdrawals.</li>
</ul>
<h2>5. Bonuses</h2>
<p>Bonuses are governed by our separate Bonus Policy. In case of conflict, the Bonus Policy prevails for bonusrelated matters.</p>
<h2>6. Responsible Gaming</h2>
<p>We encourage safe play. Tools for limits, timeouts, and selfexclusion are available. See Responsible Gaming.</p>
<h2>7. Suspension and Termination</h2>
<p>We may suspend or terminate accounts that violate these Terms, regulatory requirements, or present AML/abuse risks.</p>
<h2>8. Changes</h2>
<p>We may update these Terms. Material changes will be announced; continued use constitutes acceptance.</p>
<div class="callout">If you have questions about these Terms, contact Support.</div>
</PolicyLayout>
</template>

View File

@@ -0,0 +1,181 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue';
import UserLayout from '@/layouts/user/userlayout.vue';
const props = withDefaults(defineProps<{
title: string;
updated?: string;
intro?: string;
metaDescription?: string;
}>(), {
intro: '',
metaDescription: ''
});
const contentRef = ref<HTMLElement | null>(null);
const toc = ref<{ id: string; text: string; level: number }[]>([]);
const activeId = ref<string>('');
const canonical = ref<string>('');
let observer: IntersectionObserver | null = null;
function slugify(text: string): string {
return text
.toLowerCase()
.normalize('NFD').replace(/\p{Diacritic}/gu, '')
.replace(/[^a-z0-9\s-]/g, '')
.trim().replace(/\s+/g, '-').replace(/-+/g, '-');
}
function buildToc() {
if (!contentRef.value) return;
toc.value = [];
const headings = Array.from(contentRef.value.querySelectorAll('h2, h3')) as HTMLElement[];
for (const h of headings) {
if (!h.id) {
h.id = slugify(h.innerText || h.textContent || 'section');
}
// Ensure scroll margin for sticky headers
h.style.scrollMarginTop = '90px';
toc.value.push({ id: h.id, text: h.innerText || h.textContent || '', level: h.tagName === 'H2' ? 2 : 3 });
}
}
function setupObserver() {
if (!contentRef.value) return;
cleanupObserver();
observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
const id = (entry.target as HTMLElement).id;
activeId.value = id;
break;
}
}
}, { root: null, rootMargin: '0px 0px -70% 0px', threshold: [0, 1.0] });
contentRef.value.querySelectorAll('h2, h3').forEach(el => observer!.observe(el));
}
function cleanupObserver() {
if (observer) { observer.disconnect(); observer = null; }
}
function scrollToId(id: string) {
const el = document.getElementById(id);
if (!el) return;
const top = el.getBoundingClientRect().top + window.scrollY - 80; // offset for sticky header
window.scrollTo({ top, behavior: 'smooth' });
}
onMounted(async () => {
await nextTick();
buildToc();
setupObserver();
// compute canonical on client only to avoid SSR/window issues
try {
if (typeof window !== 'undefined' && window?.location) {
canonical.value = window.location.origin + window.location.pathname;
}
} catch {}
// Rebuild if icons/fonts change layout slightly
setTimeout(buildToc, 50);
});
onBeforeUnmount(() => cleanupObserver());
</script>
<template>
<UserLayout>
<Head :title="props.title">
<template #default>
<meta v-if="props.metaDescription" name="description" :content="props.metaDescription" />
<link v-if="canonical" rel="canonical" :href="canonical" />
</template>
</Head>
<section class="policy-wrap">
<div class="policy-grid">
<!-- Desktop Sticky ToC -->
<nav class="toc" :aria-label="$t('policy.toc')">
<div class="toc-title">{{ $t('policy.toc') }}</div>
<ul>
<li v-for="item in toc" :key="item.id" :class="['lvl'+item.level, { active: activeId===item.id }]">
<a :href="`#${item.id}`" @click.prevent="scrollToId(item.id)">{{ item.text }}</a>
</li>
</ul>
</nav>
<div class="policy-card">
<header class="p-head">
<h1 class="p-title">{{ props.title }}</h1>
<div class="p-updated" v-if="props.updated">{{ $t('policy.lastUpdated') }}: {{ new Date(props.updated).toLocaleDateString() }}</div>
<p v-if="props.intro" class="p-intro">{{ props.intro }}</p>
<!-- Mobile ToC toggle -->
<details class="toc-mobile">
<summary>{{ $t('policy.toc') }}</summary>
<ul>
<li v-for="item in toc" :key="item.id" :class="['lvl'+item.level, { active: activeId===item.id }]">
<a :href="`#${item.id}`" @click.prevent="scrollToId(item.id)">{{ item.text }}</a>
</li>
</ul>
</details>
</header>
<div class="p-body" ref="contentRef">
<slot />
</div>
</div>
</div>
</section>
</UserLayout>
</template>
<style scoped>
.policy-wrap { padding: 30px; display: flex; justify-content: center; }
.policy-grid { width: 100%; max-width: 1100px; display: grid; grid-template-columns: 260px 1fr; gap: 20px; }
/* ToC Desktop */
.toc { position: sticky; top: 90px; align-self: start; background: #0a0a0a; border: 1px solid #151515; border-radius: 12px; padding: 14px; height: fit-content; display: none; }
.toc-title { font-size: 12px; font-weight: 900; color: #fff; margin-bottom: 8px; text-transform: uppercase; letter-spacing: 1px; }
.toc ul { list-style: none; padding: 0; margin: 0; display: grid; gap: 6px; }
.toc li a { color: #aaa; text-decoration: none; font-size: 12px; font-weight: 700; display: block; padding: 6px 8px; border-radius: 8px; }
.toc li a:hover, .toc li.active a { color: #fff; background: rgba(0,242,255,0.06); }
.toc li.lvl3 a { padding-left: 18px; font-weight: 600; }
/* Show ToC on desktop */
@media (min-width: 1000px) { .toc { display: block; } }
/* Card */
.policy-card { width: 100%; background: #0a0a0a; border: 1px solid #151515; border-radius: 16px; box-shadow: 0 20px 60px rgba(0,0,0,0.6); overflow:hidden; }
.p-head { padding: 22px 26px; border-bottom: 1px solid #151515; background: linear-gradient(90deg, rgba(0,242,255,0.05), transparent); }
.p-title { margin: 0; font-size: 24px; font-weight: 900; letter-spacing: 1px; text-transform: uppercase; }
.p-updated { margin-top: 6px; font-size: 12px; color: #777; }
.p-intro { margin-top: 10px; font-size: 13px; color: #bbb; }
/* Mobile ToC */
.toc-mobile { display: block; margin-top: 12px; }
.toc-mobile > summary { cursor: pointer; list-style: none; background: #0a0a0a; border: 1px solid #1a1a1a; color: #fff; padding: 8px 10px; border-radius: 8px; font-size: 12px; font-weight: 900; }
.toc-mobile[open] > summary { background: #121212; }
.toc-mobile ul { list-style: none; padding: 8px; margin: 6px 0 0; display: grid; gap: 6px; border: 1px solid #1a1a1a; border-radius: 10px; }
.toc-mobile li a { color: #aaa; text-decoration: none; font-size: 12px; font-weight: 700; display: block; padding: 6px 8px; border-radius: 8px; }
.toc-mobile li a:hover, .toc-mobile li.active a { color: #fff; background: rgba(0,242,255,0.06); }
.toc-mobile li.lvl3 a { padding-left: 18px; font-weight: 600; }
/* Body */
.p-body { padding: 24px 26px; display: grid; gap: 18px; color: #bbb; line-height: 1.8; }
.p-body h2 { color: #fff; font-size: 18px; font-weight: 900; margin: 24px 0 6px; }
.p-body h3 { color: #ddd; font-size: 15px; font-weight: 800; margin: 14px 0 4px; }
.p-body p, .p-body li { font-size: 13px; }
.p-body ul { padding-left: 18px; list-style: disc; }
.callout { background:#0e0e0e; border:1px solid #1f1f1f; padding:12px 14px; border-radius:10px; color:#9bd; }
/* Print */
@media print {
:host, .policy-wrap, .policy-grid, .policy-card, .p-body { all: unset; display: block; }
.toc, .toc-mobile, .p-intro { display: none !important; }
body { color: #000; }
a[href]::after { content: " (" attr(href) ")"; font-size: 10px; }
}
</style>

View File

@@ -0,0 +1,181 @@
<script setup lang="ts">
import { Head, router } from '@inertiajs/vue3'
import { AlertTriangle } from 'lucide-vue-next'
import { ref } from 'vue'
import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue'
import Label from '@/components/ui/label.vue'
import Select from '@/components/ui/select.vue'
import UserLayout from '@/layouts/user/userlayout.vue'
// Local state for forms (no persistence yet — posts to placeholder endpoint)
const lossLimitDaily = ref<string>('')
const lossLimitWeekly = ref<string>('')
const lossLimitMonthly = ref<string>('')
const wagerLimitDaily = ref<string>('')
const sessionLimitMinutes = ref<string>('')
const timeoutDuration = ref<string>('')
function submitLimits() {
router.post('/responsible/limits', {
lossLimitDaily: lossLimitDaily.value || null,
lossLimitWeekly: lossLimitWeekly.value || null,
lossLimitMonthly: lossLimitMonthly.value || null,
wagerLimitDaily: wagerLimitDaily.value || null,
sessionLimitMinutes: sessionLimitMinutes.value || null,
timeoutDuration: timeoutDuration.value || null,
})
}
</script>
<template>
<UserLayout>
<Head title="Self Exclusion" />
<section class="content">
<div class="wrap">
<!-- Intro panel -->
<div class="panel">
<div class="section-head">
<h2>Responsible Gaming</h2>
<div class="view-all">Self Exclusion</div>
</div>
<div class="panel-body">
<p class="muted">Setze persönliche Limits und nimm bei Bedarf eine Spielpause. Behalte jederzeit die Kontrolle über dein Spielverhalten.</p>
</div>
</div>
<!-- Verlustlimits -->
<div class="panel">
<div class="section-head">
<h2>Verlustlimits</h2>
</div>
<div class="panel-body">
<p class="muted">Begrenze, wie viel du in einem bestimmten Zeitraum maximal verlieren möchtest.</p>
<div class="form-row three">
<div class="field">
<Label for="lossDaily">Tageslimit</Label>
<Input id="lossDaily" v-model="lossLimitDaily" type="number" min="0" placeholder="z. B. 100" />
</div>
<div class="field">
<Label for="lossWeekly">Wochenlimit</Label>
<Input id="lossWeekly" v-model="lossLimitWeekly" type="number" min="0" placeholder="z. B. 300" />
</div>
<div class="field">
<Label for="lossMonthly">Monatslimit</Label>
<Input id="lossMonthly" v-model="lossLimitMonthly" type="number" min="0" placeholder="z. B. 1000" />
</div>
</div>
</div>
</div>
<!-- Einsatzlimits -->
<div class="panel">
<div class="section-head">
<h2>Einsatzlimits</h2>
</div>
<div class="panel-body">
<p class="muted">Lege fest, wie viel du pro Tag maximal einsetzen möchtest.</p>
<div class="form-row one">
<div class="field">
<Label for="wagerDaily">Tägliches Einsatzlimit</Label>
<Input id="wagerDaily" v-model="wagerLimitDaily" type="number" min="0" placeholder="z. B. 200" />
</div>
</div>
</div>
</div>
<!-- Spielpause -->
<div class="panel">
<div class="section-head">
<h2>Spielpause</h2>
</div>
<div class="panel-body">
<p class="muted">Lege eine Auszeit fest. Während der gewählten Dauer kannst du dich nicht einloggen und nicht spielen.</p>
<div class="form-row one">
<div class="field">
<Label for="timeout">Dauer wählen</Label>
<Select id="timeout" v-model="timeoutDuration" :options="[
{ label: 'Keine', value: '' },
{ label: '1 Tag', value: '1d' },
{ label: '3 Tage', value: '3d' },
{ label: '7 Tage', value: '7d' },
{ label: '14 Tage', value: '14d' },
{ label: '1 Monat', value: '1m' },
{ label: '3 Monate', value: '3m' },
{ label: '6 Monate', value: '6m' }
]" placeholder="Dauer wählen" />
</div>
</div>
<div class="warning">
<AlertTriangle class="w-4 h-4" />
<span>Hinweis: Eine Spielpause kann nicht rückgängig gemacht werden und gilt sofort.</span>
</div>
</div>
</div>
<!-- Sitzungslimits -->
<div class="panel">
<div class="section-head">
<h2>Sitzungslimits</h2>
</div>
<div class="panel-body">
<p class="muted">Lege fest, wie lange eine einzelne Spielsitzung maximal dauern darf.</p>
<div class="form-row one">
<div class="field">
<Label for="sessionLimit">Minuten pro Sitzung</Label>
<Input id="sessionLimit" v-model="sessionLimitMinutes" type="number" min="0" placeholder="z. B. 60" />
</div>
</div>
</div>
</div>
<!-- Aktionen -->
<div class="panel">
<div class="panel-body actions">
<Button class="btn-primary" @click="submitLimits">Speichern</Button>
<Button variant="outline" @click="router.visit('/wallet')">Abbrechen</Button>
</div>
</div>
</div>
</section>
</UserLayout>
</template>
<style scoped>
/* Align with User.vue layout */
.content { padding: 30px; padding-bottom: 30px; }
.wrap { max-width: 1400px; margin: 0 auto; display: flex; flex-direction: column; gap: 30px; }
.panel { background: var(--bg-card); border: 1px solid var(--border); border-radius: 16px; overflow: hidden; animation: fade-in 0.8s cubic-bezier(0.2, 0, 0, 1); }
@keyframes fade-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
.section-head { padding: 25px 20px 10px; display: flex; justify-content: space-between; align-items: flex-end; border-bottom: 1px solid var(--border); }
.section-head h2 { margin: 0; font-size: 11px; font-weight: 900; text-transform: uppercase; letter-spacing: 2px; color: #444; }
.view-all { font-size: 10px; color: var(--cyan); font-weight: 900; text-transform: uppercase; }
.panel-body { padding: 20px; }
.muted { color: #777; font-size: 12px; font-weight: 700; margin: 0 0 12px; }
.form-row { display: grid; gap: 12px; }
.form-row.three { grid-template-columns: repeat(3, minmax(0,1fr)); }
.form-row.one { grid-template-columns: 1fr; }
.field { display: flex; flex-direction: column; gap: 8px; }
.warning { display: flex; align-items: center; gap: 10px; border: 1px solid #111; background: #050505; padding: 12px 14px; border-radius: 12px; color: #facc15; font-weight: 800; }
.actions { display: flex; gap: 12px; align-items: center; }
.btn-primary { background: var(--cyan); color: #000; border: none; }
:global(:root) {
--bg-card: #0a0a0a;
--border: #151515;
--magenta: #ff007a;
--cyan: #00f2ff;
--green: #00ff9d;
--gold: #f7931a;
}
</style>

View File

@@ -0,0 +1,35 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import AppearanceTabs from '@/components/AppearanceTabs.vue';
import Heading from '@/components/Heading.vue';
import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/settings/Layout.vue';
import { type BreadcrumbItem } from '@/types';
import { edit } from '@/routes/appearance';
const breadcrumbItems: BreadcrumbItem[] = [
{
title: 'Appearance settings',
href: edit().url,
},
];
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbItems">
<Head title="Appearance settings" />
<h1 class="sr-only">Appearance Settings</h1>
<SettingsLayout>
<div class="space-y-6">
<Heading
variant="small"
title="Appearance settings"
description="Update your account's appearance settings"
/>
<AppearanceTabs />
</div>
</SettingsLayout>
</AppLayout>
</template>

View File

@@ -0,0 +1,373 @@
<script setup lang="ts">
import { Head, router } from '@inertiajs/vue3';
import { ref, onMounted, nextTick } from 'vue';
import UserLayout from '../../layouts/user/userlayout.vue';
type Doc = {
id: number
category: string
type: string
status: string
rejection_reason?: string | null
mime: string
size: number
created_at: string
}
defineProps<{
documents: Doc[]
accepted: Record<string, string[]>
maxUploadMb: number
}>()
const uploading = ref(false);
const error = ref<string | null>(null);
const success = ref<string | null>(null);
const category = ref<'identity'|'address'|'payment'>('identity');
const type = ref<string>('passport');
const fileInput = ref<HTMLInputElement | null>(null);
function onFileChosen(e: Event) {
const input = e.target as HTMLInputElement;
if (!input.files || input.files.length === 0) return;
upload(input.files[0]);
}
async function upload(file: File) {
uploading.value = true;
error.value = null;
success.value = null;
const form = new FormData();
form.append('category', category.value);
form.append('type', type.value);
form.append('file', file);
try {
const res = await fetch('/settings/kyc', {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-TOKEN': (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement)?.content || ''
},
body: form,
});
if (!res.ok) {
const j = await res.json().catch(() => ({}));
throw new Error(j.message || 'Upload failed');
}
success.value = 'Document uploaded successfully';
router.reload({ only: ['documents'] });
} catch (e: any) {
error.value = e?.message || 'Upload failed';
} finally {
uploading.value = false;
if (fileInput.value) fileInput.value.value = '';
}
}
async function removeDoc(id: number) {
if (!confirm('Delete this document? Only pending documents can be deleted.')) return;
const res = await fetch(`/settings/kyc/${id}`, {
method: 'DELETE',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-TOKEN': (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement)?.content || ''
},
});
if (res.ok) {
router.reload({ only: ['documents'] });
}
}
function formatSize(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
const kb = bytes/1024; if (kb < 1024) return `${kb.toFixed(1)} KB`;
const mb = kb/1024; return `${mb.toFixed(2)} MB`;
}
onMounted(() => {
nextTick(() => { if (window.lucide) window.lucide.createIcons(); });
});
</script>
<template>
<UserLayout>
<Head title="KYC Verification" />
<section class="content">
<div class="wrap">
<div class="panel main-panel">
<header class="page-head">
<div class="head-flex">
<div class="title-group">
<div class="title">KYC Protocol</div>
<p class="subtitle">Secure identity verification & document management</p>
</div>
<div class="status-indicator">
<span class="dot"></span>
<span class="status-text">System Active</span>
</div>
</div>
</header>
<div class="settings-nav">
<a href="/settings/profile" class="nav-item"><i data-lucide="user"></i> Profile</a>
<a href="/settings/kyc" class="nav-item active"><i data-lucide="file-check"></i> KYC</a>
<a href="/settings/security" class="nav-item"><i data-lucide="lock"></i> Security</a>
<a href="/settings/two-factor" class="nav-item"><i data-lucide="shield"></i> 2FA</a>
</div>
<div class="grid-layout">
<div class="left-col">
<div class="glass-card uploader-box">
<div class="form-grid">
<div class="input-group">
<label class="lbl">Category</label>
<div class="select-wrapper">
<select v-model="category">
<option value="identity">Identity</option>
<option value="address">Address</option>
<option value="payment">Payment</option>
</select>
</div>
</div>
<div class="input-group">
<label class="lbl">Document Type</label>
<div class="select-wrapper">
<select v-model="type">
<option value="passport">Passport</option>
<option value="driver_license">Driver license</option>
<option value="id_card">ID card</option>
<option value="bank_statement">Bank statement</option>
<option value="utility_bill">Utility bill</option>
<option value="online_banking">Online banking</option>
<option value="other">Other</option>
</select>
</div>
</div>
</div>
<div class="drop-zone" @click="fileInput?.click()" :class="{ 'is-uploading': uploading }">
<div class="drop-content">
<i data-lucide="shield-check" class="drop-icon"></i>
<div class="drop-text">Click or drag-and-drop file</div>
<small class="drop-info">JPG, PNG, WEBP, PDF Max {{ maxUploadMb }} MB</small>
</div>
<div v-if="uploading" class="upload-overlay">
<div class="spinner"></div>
</div>
<input ref="fileInput" type="file" class="hidden" @change="onFileChosen" :disabled="uploading" />
</div>
<div class="feedback-area">
<p v-if="error" class="msg err"><i data-lucide="alert-circle"></i> {{ error }}</p>
<p v-if="success" class="msg ok"><i data-lucide="check-circle"></i> {{ success }}</p>
</div>
</div>
<div class="document-list-container">
<div class="list-header">
<h3>Your Documents</h3>
<span class="count">{{ documents.length }} Files</span>
</div>
<div v-if="!documents.length" class="empty-state">
<i data-lucide="folder-open"></i>
<p>No documents uploaded yet</p>
</div>
<div class="docs-grid">
<div v-for="d in documents" :key="d.id" class="doc-card" :data-status="d.status">
<div class="doc-main">
<div class="doc-info">
<div class="doc-badges">
<span class="badge-cat">{{ d.category }}</span>
<span class="badge-type">{{ d.type }}</span>
</div>
<div class="doc-status">
<span class="status-dot"></span>
<span class="status-label">{{ d.status }}</span>
</div>
</div>
<div class="doc-meta">
<span>{{ d.mime.split('/')[1]?.toUpperCase() }}</span>
<span>{{ formatSize(d.size) }}</span>
<span>{{ new Date(d.created_at).toLocaleDateString() }}</span>
</div>
<div v-if="d.rejection_reason" class="rejection-box">
Reason: {{ d.rejection_reason }}
</div>
</div>
<div class="doc-actions">
<a :href="`/settings/kyc/${d.id}/download`" target="_blank" class="act-btn view">
<i data-lucide="eye"></i>
</a>
<button v-if="d.status==='pending'" @click="removeDoc(d.id)" class="act-btn delete">
<i data-lucide="trash-2"></i>
</button>
</div>
</div>
</div>
</div>
</div>
<aside class="right-col">
<div class="glass-card side-info">
<div class="side-head">Guidelines</div>
<div class="guide-sections">
<div class="guide-item">
<div class="g-title"><i data-lucide="user"></i> Identity</div>
<ul>
<li>Passport (Full page)</li>
<li>Drivers license (Both sides)</li>
<li>ID Card (Both sides)</li>
</ul>
</div>
<div class="guide-item">
<div class="g-title"><i data-lucide="map-pin"></i> Address</div>
<ul>
<li>Bank statement (Last 3m)</li>
<li>Utility bill (Electricity/Water)</li>
</ul>
</div>
<div class="guide-item">
<div class="g-title"><i data-lucide="credit-card"></i> Payment</div>
<ul>
<li>Online banking screenshot</li>
</ul>
</div>
</div>
<div class="secure-footer">
<i data-lucide="lock" style="width:12px"></i> End-to-end Encrypted
</div>
</div>
</aside>
</div>
</div>
</div>
</section>
</UserLayout>
</template>
<style scoped>
:global(:root) {
--bg-card: #0a0a0a;
--border: #151515;
--cyan: #00f2ff;
--magenta: #ff007a;
--green: #00ff9d;
--gold: #f7931a;
--red: #ff3e3e;
}
.content { padding: 30px; animation: fade-in 0.8s cubic-bezier(0.2, 0, 0, 1); }
.wrap { max-width: 1200px; margin: 0 auto; }
.main-panel { background: var(--bg-card); border: 1px solid var(--border); border-radius: 20px; overflow: hidden; box-shadow: 0 20px 50px rgba(0,0,0,0.5); }
/* Header Styles */
.page-head { padding: 25px 30px; border-bottom: 1px solid var(--border); background: linear-gradient(to right, rgba(0,242,255,0.03), transparent); }
.head-flex { display: flex; justify-content: space-between; align-items: center; }
.title { font-size: 14px; font-weight: 900; color: #fff; letter-spacing: 3px; text-transform: uppercase; }
.subtitle { color: #555; font-size: 12px; margin-top: 4px; font-weight: 600; }
.status-indicator { display: flex; align-items: center; gap: 8px; background: rgba(0,0,0,0.3); padding: 6px 12px; border-radius: 50px; border: 1px solid #111; }
.status-indicator .dot { width: 6px; height: 6px; background: var(--green); border-radius: 50%; box-shadow: 0 0 10px var(--green); animation: pulse 2s infinite; }
.status-text { font-size: 10px; font-weight: 900; color: #666; text-transform: uppercase; letter-spacing: 1px; }
/* Navigation */
.settings-nav { display: flex; gap: 5px; padding: 15px 30px; border-bottom: 1px solid var(--border); background: rgba(0,0,0,0.2); overflow-x: auto; }
.nav-item { display: flex; align-items: center; gap: 8px; padding: 10px 16px; border-radius: 8px; font-size: 11px; font-weight: 800; color: #666; text-transform: uppercase; letter-spacing: 1px; transition: 0.2s; text-decoration: none; }
.nav-item:hover { color: #fff; background: rgba(255,255,255,0.05); }
.nav-item.active { color: var(--cyan); background: rgba(0,242,255,0.1); }
.nav-item i { width: 14px; }
/* Grid Layout */
.grid-layout { display: grid; grid-template-columns: 1fr 320px; gap: 30px; padding: 30px; }
/* Left Column / Uploader */
.left-col { display: flex; flex-direction: column; gap: 30px; animation: slide-up 0.6s cubic-bezier(0.2, 0, 0, 1) backwards; animation-delay: 0.1s; }
.glass-card { background: #050505; border: 1px solid var(--border); border-radius: 16px; padding: 20px; transition: 0.3s; }
.glass-card:hover { border-color: #222; box-shadow: 0 10px 30px rgba(0,0,0,0.3); }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-bottom: 20px; }
.lbl { display: block; font-size: 10px; font-weight: 900; color: #444; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 8px; }
.select-wrapper { position: relative; }
select { width: 100%; background: #000; border: 1px solid var(--border); color: #fff; padding: 12px; border-radius: 12px; font-size: 13px; appearance: none; transition: 0.3s; }
select:focus { border-color: var(--cyan); outline: none; box-shadow: 0 0 15px rgba(0,242,255,0.1); }
/* Drop Zone */
.drop-zone { border: 2px dashed #1a1a1a; border-radius: 16px; padding: 40px 20px; text-align: center; cursor: pointer; transition: 0.3s; position: relative; overflow: hidden; }
.drop-zone:hover { border-color: var(--cyan); background: rgba(0,242,255,0.02); transform: scale(1.01); }
.drop-icon { width: 32px; height: 32px; color: #222; margin: 0 auto 15px; transition: 0.3s; }
.drop-zone:hover .drop-icon { color: var(--cyan); transform: translateY(-3px); }
.drop-text { font-weight: 800; color: #eee; font-size: 14px; margin-bottom: 5px; }
.drop-info { color: #444; font-size: 11px; }
.upload-overlay { position: absolute; inset: 0; background: rgba(0,0,0,0.8); display: flex; align-items: center; justify-content: center; z-index: 10; }
.spinner { width: 24px; height: 24px; border: 3px solid rgba(0,242,255,0.1); border-top-color: var(--cyan); border-radius: 50%; animation: spin 0.8s linear infinite; }
/* Documents List */
.list-header { display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 15px; }
.list-header h3 { font-size: 11px; font-weight: 900; text-transform: uppercase; letter-spacing: 2px; color: #444; margin: 0; }
.list-header .count { font-size: 10px; color: #666; font-weight: 800; }
.docs-grid { display: grid; gap: 12px; }
.doc-card { display: flex; justify-content: space-between; align-items: center; background: #070707; border: 1px solid var(--border); padding: 16px; border-radius: 14px; transition: 0.3s; animation: fade-in 0.5s backwards; }
.doc-card:hover { border-color: #222; transform: translateX(5px); }
.doc-info { display: flex; align-items: center; gap: 15px; margin-bottom: 8px; }
.doc-badges { display: flex; gap: 6px; }
.badge-cat { font-size: 9px; font-weight: 900; color: var(--cyan); text-transform: uppercase; background: rgba(0,242,255,0.05); padding: 2px 8px; border-radius: 4px; }
.badge-type { font-size: 9px; font-weight: 900; color: #666; text-transform: uppercase; background: #111; padding: 2px 8px; border-radius: 4px; }
.doc-status { display: flex; align-items: center; gap: 6px; }
.status-dot { width: 6px; height: 6px; border-radius: 50%; background: #444; }
.status-label { font-size: 10px; font-weight: 900; text-transform: uppercase; color: #444; }
.doc-card[data-status="approved"] .status-dot { background: var(--green); box-shadow: 0 0 8px var(--green); }
.doc-card[data-status="approved"] .status-label { color: var(--green); }
.doc-card[data-status="pending"] .status-dot { background: var(--gold); }
.doc-card[data-status="rejected"] .status-dot { background: var(--red); }
.doc-meta { display: flex; gap: 15px; font-size: 11px; color: #333; font-weight: 700; }
.rejection-box { margin-top: 8px; font-size: 11px; color: var(--red); background: rgba(255,62,62,0.05); padding: 6px 10px; border-radius: 6px; }
.doc-actions { display: flex; gap: 8px; }
.act-btn { width: 36px; height: 36px; border-radius: 10px; display: flex; align-items: center; justify-content: center; cursor: pointer; border: 1px solid #151515; background: #000; color: #444; transition: 0.2s; }
.act-btn:hover { color: #fff; border-color: #333; }
.act-btn.delete:hover { color: var(--red); border-color: var(--red); background: rgba(255,62,62,0.05); }
/* Right Column */
.right-col { animation: slide-up 0.6s cubic-bezier(0.2, 0, 0, 1) backwards; animation-delay: 0.2s; }
.side-info { position: sticky; top: 30px; }
.side-head { font-size: 11px; font-weight: 900; text-transform: uppercase; letter-spacing: 2px; color: #fff; margin-bottom: 20px; border-left: 3px solid var(--cyan); padding-left: 12px; }
.guide-sections { display: flex; flex-direction: column; gap: 20px; }
.g-title { font-size: 10px; font-weight: 900; text-transform: uppercase; color: #555; display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
.g-title i { width: 12px; }
.guide-item ul { padding-left: 20px; margin: 0; }
.guide-item li { font-size: 12px; color: #888; margin-bottom: 5px; }
.secure-footer { margin-top: 25px; padding-top: 15px; border-top: 1px solid #111; font-size: 10px; font-weight: 800; color: #333; text-transform: uppercase; display: flex; align-items: center; gap: 6px; }
/* Utility */
.empty-state { padding: 40px; text-align: center; color: #222; }
.empty-state i { width: 40px; height: 40px; margin-bottom: 10px; opacity: 0.5; }
.msg { font-size: 12px; font-weight: 800; display: flex; align-items: center; gap: 8px; margin-top: 15px; }
.msg i { width: 14px; }
.msg.err { color: var(--red); }
.msg.ok { color: var(--green); }
.hidden { display: none; }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes pulse { 0% { opacity: 1; } 50% { opacity: 0.4; } 100% { opacity: 1; } }
@keyframes fade-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
@keyframes slide-up { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
@media (max-width: 1000px) {
.grid-layout { grid-template-columns: 1fr; }
.side-info { position: static; }
.form-grid { grid-template-columns: 1fr; }
.settings-nav { padding: 10px 15px; }
}
</style>

View File

@@ -0,0 +1,115 @@
<script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3';
import { type BreadcrumbItem } from '@/types';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/settings/Layout.vue';
import PasswordController from '@/actions/App/Http/Controllers/Settings/PasswordController';
import { edit } from '@/routes/user-password';
const breadcrumbItems: BreadcrumbItem[] = [
{
title: 'Password settings',
href: edit().url,
},
];
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbItems">
<Head title="Password settings" />
<h1 class="sr-only">Password Settings</h1>
<SettingsLayout>
<div class="space-y-6">
<Heading
variant="small"
title="Update password"
description="Ensure your account is using a long, random password to stay secure"
/>
<Form
v-bind="PasswordController.update.form()"
:options="{
preserveScroll: true,
}"
reset-on-success
:reset-on-error="[
'password',
'password_confirmation',
'current_password',
]"
class="space-y-6"
v-slot="{ errors, processing, recentlySuccessful }"
>
<div class="grid gap-2">
<Label for="current_password">Current password</Label>
<Input
id="current_password"
name="current_password"
type="password"
class="mt-1 block w-full"
autocomplete="current-password"
placeholder="Current password"
/>
<InputError :message="errors.current_password" />
</div>
<div class="grid gap-2">
<Label for="password">New password</Label>
<Input
id="password"
name="password"
type="password"
class="mt-1 block w-full"
autocomplete="new-password"
placeholder="New password"
/>
<InputError :message="errors.password" />
</div>
<div class="grid gap-2">
<Label for="password_confirmation"
>Confirm password</Label
>
<Input
id="password_confirmation"
name="password_confirmation"
type="password"
class="mt-1 block w-full"
autocomplete="new-password"
placeholder="Confirm password"
/>
<InputError :message="errors.password_confirmation" />
</div>
<div class="flex items-center gap-4">
<Button
:disabled="processing"
data-test="update-password-button"
>Save password</Button
>
<Transition
enter-active-class="transition ease-in-out"
enter-from-class="opacity-0"
leave-active-class="transition ease-in-out"
leave-to-class="opacity-0"
>
<p
v-show="recentlySuccessful"
class="text-sm text-neutral-600"
>
Saved.
</p>
</Transition>
</div>
</Form>
</div>
</SettingsLayout>
</AppLayout>
</template>

View File

@@ -0,0 +1,407 @@
<script setup lang="ts">
import { Head, usePage } from '@inertiajs/vue3';
import { ref, onMounted, nextTick, computed, watch } from 'vue';
import Select from '@/components/ui/Select.vue';
import { usePrimaryColor } from '@/composables/usePrimaryColor';
import UserLayout from '../../layouts/user/userlayout.vue';
type Props = { mustVerifyEmail: boolean; status?: string };
defineProps<Props>();
const page = usePage();
const user = computed(() => (page.props.auth as any).user || {});
const form = ref({
name: '',
email: '',
first_name: '',
last_name: '',
birthdate: '',
gender: '',
country: '',
address_line1: '',
address_line2: '',
city: '',
state: '',
postal_code: '',
phone: '',
});
const locked = ref<Record<string, boolean>>({});
const isSaving = ref(false);
const showSuccess = ref(false);
// Accent color controls for quick access on Profile page
const { primaryColor, updatePrimaryColor } = usePrimaryColor();
const hexInput = ref<string>(primaryColor.value || '#ff007a');
watch(primaryColor, (val) => {
if (val && val.toLowerCase() !== hexInput.value.toLowerCase()) {
hexInput.value = val;
}
});
function onPickColor(e: Event) {
const value = (e.target as HTMLInputElement).value;
hexInput.value = value;
updatePrimaryColor(value);
}
function onHexInput(e: Event) {
const value = (e.target as HTMLInputElement).value.trim();
hexInput.value = value;
}
function applyHex() {
let v = hexInput.value.trim();
if (!v.startsWith('#')) v = `#${v}`;
const isValid = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(v);
if (isValid) {
hexInput.value = v.toLowerCase();
updatePrimaryColor(hexInput.value);
}
}
function resetToDefault() {
const def = '#ff007a';
hexInput.value = def;
updatePrimaryColor(def);
}
onMounted(() => {
const u: any = user.value || {};
// Map fields
form.value = {
name: u.name || '',
email: u.email || '',
first_name: u.first_name || '',
last_name: u.last_name || '',
birthdate: u.birthdate ? String(u.birthdate).slice(0, 10) : '',
gender: u.gender || '',
country: (u.country || '').toString().toUpperCase(),
address_line1: u.address_line1 || '',
address_line2: u.address_line2 || '',
city: u.city || '',
state: u.state || '',
postal_code: u.postal_code || '',
phone: u.phone || '',
};
// Determine locks: If data exists in DB, it is locked.
const fields = Object.keys(form.value);
fields.forEach(f => {
if (u[f] && String(u[f]).trim() !== '') {
locked.value[f] = true;
}
});
nextTick(() => { if ((window as any).lucide) (window as any).lucide.createIcons(); });
});
async function submit() {
isSaving.value = true;
const res = await fetch('/settings/profile', {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-TOKEN': (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement)?.content || '',
'Content-Type': 'application/json',
},
body: JSON.stringify({ ...form.value, _method: 'PATCH' }),
});
if (res.ok) {
showSuccess.value = true;
setTimeout(() => { window.location.reload(); }, 1000);
} else {
isSaving.value = false;
}
}
</script>
<template>
<UserLayout>
<Head title="Profile Settings" />
<section class="content">
<div class="wrap">
<div class="panel main-panel">
<header class="page-head">
<div class="head-flex">
<div class="title-group">
<div class="title">User Profile</div>
<p class="subtitle">Manage your personal identity and contact details</p>
</div>
<div class="security-badge">
<i data-lucide="shield-check"></i>
<span>Verified Account</span>
</div>
</div>
</header>
<div class="settings-nav">
<a href="/settings/profile" class="nav-item active"><i data-lucide="user"></i> Profile</a>
<a href="/settings" class="nav-item"><i data-lucide="globe"></i> Public Profile</a>
<a href="/settings/kyc" class="nav-item"><i data-lucide="file-check"></i> KYC</a>
<a href="/settings/security" class="nav-item"><i data-lucide="lock"></i> Security</a>
<a href="/settings/two-factor" class="nav-item"><i data-lucide="shield"></i> 2FA</a>
</div>
<form class="profile-form" @submit.prevent="submit">
<div class="form-section">
<div class="sec-label">Account Core</div>
<div class="grid2">
<label class="field" :class="{ locked: locked.email }">
<span class="lbl">Email Address</span>
<div class="input-wrapper">
<i data-lucide="mail"></i>
<input class="inp" type="email" v-model="form.email" :disabled="locked.email" required />
<i v-if="locked.email" data-lucide="lock" class="lock-icon"></i>
</div>
<div v-if="locked.email" class="locked-hint">Contact support to change</div>
</label>
<label class="field" :class="{ locked: locked.name }">
<span class="lbl">Display Name</span>
<div class="input-wrapper">
<i data-lucide="user"></i>
<input class="inp" type="text" v-model="form.name" :disabled="locked.name" required />
<i v-if="locked.name" data-lucide="lock" class="lock-icon"></i>
</div>
<div v-if="locked.name" class="locked-hint">Contact support to change</div>
</label>
</div>
</div>
<div class="form-section">
<div class="sec-label">Identity Details</div>
<div class="grid2">
<label class="field" :class="{ locked: locked.first_name }">
<span class="lbl">First Name</span>
<div class="input-wrapper">
<input class="inp pl-reset" type="text" v-model="form.first_name" :disabled="locked.first_name" />
<i v-if="locked.first_name" data-lucide="lock" class="lock-icon"></i>
</div>
</label>
<label class="field" :class="{ locked: locked.last_name }">
<span class="lbl">Last Name</span>
<div class="input-wrapper">
<input class="inp pl-reset" type="text" v-model="form.last_name" :disabled="locked.last_name" />
<i v-if="locked.last_name" data-lucide="lock" class="lock-icon"></i>
</div>
</label>
</div>
<div class="grid3">
<label class="field" :class="{ locked: locked.birthdate }">
<span class="lbl">Birthdate</span>
<div class="input-wrapper">
<input class="inp pl-reset" type="date" v-model="form.birthdate" :disabled="locked.birthdate" />
<i v-if="locked.birthdate" data-lucide="lock" class="lock-icon"></i>
</div>
</label>
<label class="field" :class="{ locked: locked.gender }">
<span class="lbl">Gender</span>
<div class="input-wrapper">
<Select
v-model="form.gender"
:disabled="locked.gender"
:options="[
{ label: 'Male', value: 'male', icon: 'user' },
{ label: 'Female', value: 'female', icon: 'user' },
{ label: 'Other', value: 'other', icon: 'user' }
]"
placeholder="Select Gender"
/>
<i v-if="locked.gender" data-lucide="lock" class="lock-icon" style="right: 30px; left: auto; z-index: 10;"></i>
</div>
</label>
<label class="field" :class="{ locked: locked.country }">
<span class="lbl">Country (ISO)</span>
<div class="input-wrapper">
<input class="inp pl-reset" type="text" v-model="form.country" placeholder="e.g. DE" maxlength="2" :disabled="locked.country" />
<i v-if="locked.country" data-lucide="lock" class="lock-icon"></i>
</div>
</label>
</div>
</div>
<div class="form-section">
<div class="sec-label">Residential Address</div>
<div class="grid1">
<label class="field" :class="{ locked: locked.address_line1 }">
<span class="lbl">Address Line 1</span>
<div class="input-wrapper">
<input class="inp pl-reset" type="text" v-model="form.address_line1" placeholder="Street and house number" :disabled="locked.address_line1" />
<i v-if="locked.address_line1" data-lucide="lock" class="lock-icon"></i>
</div>
</label>
</div>
<div class="grid2">
<label class="field" :class="{ locked: locked.address_line2 }">
<span class="lbl">Address Line 2 (Optional)</span>
<div class="input-wrapper">
<input class="inp pl-reset" type="text" v-model="form.address_line2" :disabled="locked.address_line2" />
<i v-if="locked.address_line2" data-lucide="lock" class="lock-icon"></i>
</div>
</label>
<label class="field" :class="{ locked: locked.city }">
<span class="lbl">City</span>
<div class="input-wrapper">
<input class="inp pl-reset" type="text" v-model="form.city" :disabled="locked.city" />
<i v-if="locked.city" data-lucide="lock" class="lock-icon"></i>
</div>
</label>
</div>
<div class="grid2">
<label class="field" :class="{ locked: locked.state }">
<span class="lbl">State / Region</span>
<div class="input-wrapper">
<input class="inp pl-reset" type="text" v-model="form.state" :disabled="locked.state" />
<i v-if="locked.state" data-lucide="lock" class="lock-icon"></i>
</div>
</label>
<label class="field" :class="{ locked: locked.postal_code }">
<span class="lbl">Postal Code</span>
<div class="input-wrapper">
<input class="inp pl-reset" type="text" v-model="form.postal_code" :disabled="locked.postal_code" />
<i v-if="locked.postal_code" data-lucide="lock" class="lock-icon"></i>
</div>
</label>
</div>
</div>
<div class="form-section">
<div class="sec-label">Contact Information</div>
<div class="grid1">
<label class="field" :class="{ locked: locked.phone }">
<span class="lbl">Mobile Phone</span>
<div class="input-wrapper">
<i data-lucide="phone"></i>
<input class="inp" type="tel" v-model="form.phone" placeholder="+49 123 456789" :disabled="locked.phone" />
<i v-if="locked.phone" data-lucide="lock" class="lock-icon"></i>
</div>
</label>
</div>
</div>
<div class="form-section">
<div class="sec-label">Appearance</div>
<div class="grid1">
<div class="field">
<span class="lbl">Accent color</span>
<div class="input-wrapper" style="gap:8px; align-items:center;">
<input class="inp pl-reset" type="color" :value="primaryColor" @input="onPickColor" style="width:46px; height:36px; padding:0;" />
<input class="inp pl-reset" type="text" v-model="hexInput" @input="onHexInput" placeholder="#ff007a" style="max-width:160px;" />
<button type="button" class="inp pl-reset" @click="applyHex" style="width:auto; cursor:pointer;">Apply</button>
<button type="button" class="inp pl-reset" @click="resetToDefault" style="width:auto; cursor:pointer;">Reset</button>
</div>
<div class="locked-hint" style="margin-top:6px; display:flex; align-items:center; gap:6px;">
<span>Preview:</span>
<span :style="{background: primaryColor, display:'inline-block', width:'18px', height:'12px', borderRadius:'4px', border:'1px solid #222'}"></span>
<code style="color:#aaa;">{{ primaryColor }}</code>
</div>
</div>
</div>
</div>
<div class="form-footer">
<button class="btn-save" type="submit" :disabled="isSaving">
<span v-if="!isSaving">{{ showSuccess ? 'Updated!' : 'Save Protocol' }}</span>
<div v-else class="loader"></div>
</button>
</div>
</form>
</div>
</div>
</section>
</UserLayout>
</template>
<style scoped>
:global(:root) {
--bg-card: #0a0a0a;
--border: #151515;
--cyan: #00f2ff;
--magenta: #ff007a;
--green: #00ff9d;
}
.content { padding: 30px; animation: fade-in 0.8s cubic-bezier(0.2, 0, 0, 1); }
.wrap { max-width: 900px; margin: 0 auto; }
.main-panel { background: var(--bg-card); border: 1px solid var(--border); border-radius: 20px; overflow: hidden; box-shadow: 0 20px 50px rgba(0,0,0,0.6); }
/* Header */
.page-head { padding: 25px 30px; border-bottom: 1px solid var(--border); background: linear-gradient(to right, rgba(0,242,255,0.03), transparent); }
.head-flex { display: flex; justify-content: space-between; align-items: center; }
.title { font-size: 14px; font-weight: 900; color: #fff; letter-spacing: 3px; text-transform: uppercase; }
.subtitle { color: #555; font-size: 12px; margin-top: 4px; font-weight: 600; }
.security-badge { display: flex; align-items: center; gap: 8px; color: var(--green); background: rgba(0,255,157,0.05); padding: 6px 14px; border-radius: 50px; border: 1px solid rgba(0,255,157,0.1); font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 1px; }
.security-badge i { width: 14px; }
/* Navigation */
.settings-nav { display: flex; gap: 5px; padding: 15px 30px; border-bottom: 1px solid var(--border); background: rgba(0,0,0,0.2); overflow-x: auto; }
.nav-item { display: flex; align-items: center; gap: 8px; padding: 10px 16px; border-radius: 8px; font-size: 11px; font-weight: 800; color: #666; text-transform: uppercase; letter-spacing: 1px; transition: 0.2s; text-decoration: none; }
.nav-item:hover { color: #fff; background: rgba(255,255,255,0.05); }
.nav-item.active { color: var(--cyan); background: rgba(0,242,255,0.1); }
.nav-item i { width: 14px; }
/* Form Layout */
.profile-form { padding: 30px; display: grid; gap: 30px; }
.form-section { display: grid; gap: 15px; animation: slide-up 0.6s cubic-bezier(0.2, 0, 0, 1) backwards; }
.form-section:nth-child(1) { animation-delay: 0.1s; }
.form-section:nth-child(2) { animation-delay: 0.2s; }
.form-section:nth-child(3) { animation-delay: 0.3s; }
.form-section:nth-child(4) { animation-delay: 0.4s; }
.sec-label { font-size: 10px; font-weight: 900; color: #333; text-transform: uppercase; letter-spacing: 2px; margin-bottom: 5px; border-bottom: 1px solid #111; padding-bottom: 8px; }
.grid1 { display: grid; gap: 15px; }
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; }
.grid3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 15px; }
/* Inputs */
.field { display: block; position: relative; }
.lbl { display: block; font-size: 10px; font-weight: 900; color: #555; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 8px; transition: 0.3s; }
.input-wrapper { position: relative; display: flex; align-items: center; }
.input-wrapper i:not(.lock-icon) { position: absolute; left: 14px; width: 14px; color: #444; pointer-events: none; transition: 0.3s; }
.input-wrapper .inp { padding-left: 40px; }
.input-wrapper .inp.pl-reset { padding-left: 14px; }
.inp, select { width: 100%; background: #000; border: 1px solid var(--border); color: #fff; padding: 12px 14px; border-radius: 12px; font-size: 13px; transition: 0.3s cubic-bezier(0.2, 0, 0, 1); }
.inp:focus { border-color: var(--cyan); outline: none; box-shadow: 0 0 15px rgba(0,242,255,0.1); background: #050505; }
.inp:focus + i { color: var(--cyan); }
/* Locked State */
.inp:disabled { background: #080808; color: #666; border-color: #111; cursor: not-allowed; }
.lock-icon { position: absolute; right: 14px; width: 12px; color: #333; pointer-events: none; }
.locked-hint { font-size: 9px; color: #444; margin-top: 6px; font-weight: 700; display: flex; align-items: center; gap: 5px; }
.locked-hint::before { content: ''; display: block; width: 4px; height: 4px; background: #333; border-radius: 50%; }
/* Custom Select */
.select-wrapper { position: relative; }
.select-wrapper::after { content: '↓'; position: absolute; right: 14px; top: 50%; transform: translateY(-50%); font-size: 10px; color: #444; pointer-events: none; }
/* Actions & Footer */
.form-footer { display: flex; justify-content: flex-end; align-items: center; margin-top: 20px; padding-top: 25px; border-top: 1px solid var(--border); animation: fade-in 1s 0.5s backwards; }
.btn-save { background: var(--cyan); color: #000; border: none; border-radius: 12px; padding: 14px 30px; font-size: 12px; font-weight: 900; text-transform: uppercase; letter-spacing: 1px; cursor: pointer; transition: 0.3s; box-shadow: 0 0 20px rgba(0,242,255,0.2); min-width: 160px; display: flex; justify-content: center; }
.btn-save:hover:not(:disabled) { transform: translateY(-2px); box-shadow: 0 5px 25px rgba(0,242,255,0.4); }
.btn-save:disabled { opacity: 0.6; cursor: not-allowed; }
.loader { width: 16px; height: 16px; border: 2px solid rgba(0,0,0,0.1); border-top-color: #000; border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes fade-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
@keyframes slide-up { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
@media (max-width: 768px) {
.grid2, .grid3 { grid-template-columns: 1fr; }
.form-footer { flex-direction: column-reverse; gap: 20px; align-items: stretch; }
.profile-form { padding: 20px; }
.settings-nav { padding: 10px 15px; }
}
</style>

View File

@@ -0,0 +1,180 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { ref, onMounted, nextTick } from 'vue';
import UserLayout from '../../layouts/user/userlayout.vue';
type Sess = { id: string; ip: string|null; user_agent: string|null; last_activity: number; current: boolean };
const sessions = ref<Sess[]>([]);
const loading = ref(true);
const error = ref<string|null>(null);
async function loadSessions() {
loading.value = true;
error.value = null;
try {
const res = await fetch('/settings/security/sessions', { headers: { 'X-Requested-With':'XMLHttpRequest' } });
if (!res.ok) throw new Error('Failed to load sessions');
const j = await res.json();
sessions.value = j.data || [];
} catch (e: any) {
error.value = e?.message || 'Failed to load sessions';
} finally {
loading.value = false;
nextTick(() => { if (window.lucide) window.lucide.createIcons(); });
}
}
async function revoke(id: string) {
if (!confirm('Revoke this session?')) return;
const res = await fetch(`/settings/security/sessions/${id}`, {
method: 'DELETE',
headers: { 'X-Requested-With':'XMLHttpRequest', 'X-CSRF-TOKEN': (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement)?.content || '' },
});
if (res.ok) {
await loadSessions();
}
}
function fmtTime(ts: number) {
try { return new Date(ts * 1000).toLocaleString(); } catch { return String(ts); }
}
onMounted(loadSessions);
</script>
<template>
<UserLayout>
<Head title="Security" />
<section class="content">
<div class="wrap">
<div class="main-panel">
<header class="page-head">
<div class="head-flex">
<div class="title-group">
<div class="title">Security Center</div>
<p class="subtitle">Manage sessions, change your password, and configure two-factor authentication.</p>
</div>
<div class="security-badge">
<i data-lucide="shield"></i>
<span>Protection Active</span>
</div>
</div>
</header>
<div class="settings-nav">
<a href="/settings/profile" class="nav-item"><i data-lucide="user"></i> Profile</a>
<a href="/settings/kyc" class="nav-item"><i data-lucide="file-check"></i> KYC</a>
<a href="/settings/security" class="nav-item active"><i data-lucide="lock"></i> Security</a>
<a href="/settings/two-factor" class="nav-item"><i data-lucide="shield"></i> 2FA</a>
</div>
<div class="settings-body">
<div class="left-col">
<div class="card">
<div class="card-head"><i data-lucide="monitor-smartphone"></i> Active sessions</div>
<div v-if="loading" class="empty">
<div class="spinner"></div>
<span>Loading sessions...</span>
</div>
<div v-else-if="error" class="err">{{ error }}</div>
<div v-else>
<div v-if="!sessions.length" class="empty">No active sessions found.</div>
<div v-for="s in sessions" :key="s.id" class="sess">
<div class="meta">
<div class="ua">{{ s.user_agent || 'Unknown device' }}</div>
<div class="ip">IP: {{ s.ip || '—' }}</div>
</div>
<div class="meta2">
<span class="muted">Last activity: {{ fmtTime(s.last_activity) }}</span>
<span v-if="s.current" class="badge cur">Current session</span>
</div>
<div class="actions">
<button class="btn danger" :disabled="s.current" @click="revoke(s.id)">Revoke</button>
</div>
</div>
</div>
</div>
<div class="row2">
<div class="card hover-effect">
<div class="card-head"><i data-lucide="key-round"></i> Change password</div>
<p class="muted">Use a strong and unique password. You will be asked for your current password.</p>
<a class="btn" href="/settings/password">Change password</a>
</div>
<div class="card hover-effect">
<div class="card-head"><i data-lucide="shield"></i> Two-Factor Authentication (2FA)</div>
<p class="muted">Protect your account with a second step, like an authenticator app.</p>
<a class="btn" href="/settings/two-factor">Manage 2FA</a>
</div>
</div>
</div>
<aside class="right-col">
<div class="side-card">
<div class="card-head">Tips</div>
<ul>
<li>Enable 2FA for better security.</li>
<li>Review sessions regularly and revoke unknown devices.</li>
<li>Never share your password or 2FA codes.</li>
</ul>
</div>
</aside>
</div>
</div>
</div>
</section>
</UserLayout>
</template>
<style scoped>
:global(:root) { --bg-card:#0a0a0a; --border:#151515; --cyan:#00f2ff; --magenta:#ff007a; --green:#00ff9d; }
.content { padding: 30px; animation: fade-in 0.8s cubic-bezier(0.2, 0, 0, 1); }
.wrap { max-width: 1100px; margin: 0 auto; }
.main-panel { background: var(--bg-card); border: 1px solid var(--border); border-radius: 20px; overflow: hidden; box-shadow: 0 20px 50px rgba(0,0,0,0.6); }
.page-head { padding: 25px 30px; border-bottom: 1px solid var(--border); background: linear-gradient(to right, rgba(0,242,255,0.03), transparent); }
.head-flex { display: flex; justify-content: space-between; align-items: center; }
.title { font-size: 14px; font-weight: 900; color: #fff; letter-spacing: 3px; text-transform: uppercase; }
.subtitle { color: #555; font-size: 12px; margin-top: 4px; font-weight: 600; }
.security-badge { display:flex; align-items:center; gap:8px; color: var(--green); background: rgba(0,255,157,0.05); padding:6px 14px; border-radius:50px; border:1px solid rgba(0,255,157,0.1); font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:1px; }
/* Navigation */
.settings-nav { display: flex; gap: 5px; padding: 15px 30px; border-bottom: 1px solid var(--border); background: rgba(0,0,0,0.2); overflow-x: auto; }
.nav-item { display: flex; align-items: center; gap: 8px; padding: 10px 16px; border-radius: 8px; font-size: 11px; font-weight: 800; color: #666; text-transform: uppercase; letter-spacing: 1px; transition: 0.2s; text-decoration: none; }
.nav-item:hover { color: #fff; background: rgba(255,255,255,0.05); }
.nav-item.active { color: var(--cyan); background: rgba(0,242,255,0.1); }
.nav-item i { width: 14px; }
.settings-body { padding: 24px 28px; display:grid; grid-template-columns: 1.6fr .8fr; gap: 24px; }
.left-col { display: flex; flex-direction: column; gap: 18px; animation: slide-up 0.6s cubic-bezier(0.2, 0, 0, 1) backwards; animation-delay: 0.1s; }
.right-col { animation: slide-up 0.6s cubic-bezier(0.2, 0, 0, 1) backwards; animation-delay: 0.2s; }
.card { border: 1px solid var(--border); background: #0a0a0a; border-radius: 14px; padding: 14px; display: grid; gap: 12px; transition: 0.3s; }
.card.hover-effect:hover { border-color: #333; transform: translateY(-2px); box-shadow: 0 10px 30px rgba(0,0,0,0.3); }
.card-head { color: #fff; font-weight: 900; display:flex; align-items:center; gap:8px; }
.row2 { display:grid; grid-template-columns: 1fr 1fr; gap: 18px; }
.empty { color: #666; display: flex; align-items: center; gap: 10px; padding: 20px; justify-content: center; }
.spinner { width: 16px; height: 16px; border: 2px solid rgba(255,255,255,0.1); border-top-color: #fff; border-radius: 50%; animation: spin 0.8s linear infinite; }
.err { color: #ff5b5b; }
.sess { border: 1px solid #151515; background: #050505; border-radius: 12px; padding: 12px; display: grid; gap: 8px; transition: 0.3s; animation: fade-in 0.5s backwards; }
.sess:hover { border-color: #333; }
.meta { display:flex; justify-content:space-between; gap:10px; color:#ddd; }
.meta2 { display:flex; gap:14px; color:#666; font-size:12px; align-items:center; }
.badge.cur { font-size:10px; font-weight:900; padding:3px 8px; border-radius:999px; border:1px solid var(--border); color:#bbb; background:#0b0b0b; }
.actions { display:flex; gap:8px; }
.btn { background: var(--cyan); color: #000; border: none; border-radius: 10px; padding: 10px 14px; font-weight: 900; cursor: pointer; text-decoration: none; text-align: center; display: inline-block; transition: 0.3s; }
.btn:hover { transform: translateY(-2px); box-shadow: 0 5px 15px rgba(0,242,255,0.2); }
.btn.danger { background: #ff5b5b; color: #000; }
.btn.danger:hover { box-shadow: 0 5px 15px rgba(255,91,91,0.2); }
.side-card { border: 1px solid var(--border); background: #0a0a0a; border-radius: 14px; padding: 16px; position: sticky; top: 20px; }
.side-card ul { padding-left: 16px; color:#9aa0a6; }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes fade-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
@keyframes slide-up { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
@media (max-width: 900px) { .settings-body { grid-template-columns: 1fr; padding: 16px; } .row2 { grid-template-columns: 1fr; } .side-card { position: static; } .settings-nav { padding: 10px 15px; } }
</style>

View File

@@ -0,0 +1,151 @@
<script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3';
import { onMounted, onUnmounted, ref, nextTick } from 'vue';
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
import { disable, enable } from '@/routes/two-factor';
import UserLayout from '../../layouts/user/userlayout.vue';
import TwoFactorRecoveryCodes from '@/components/TwoFactorRecoveryCodes.vue';
import TwoFactorSetupModal from '@/components/TwoFactorSetupModal.vue';
type Props = {
requiresConfirmation?: boolean;
twoFactorEnabled?: boolean;
};
withDefaults(defineProps<Props>(), {
requiresConfirmation: false,
twoFactorEnabled: false,
});
const { hasSetupData, clearTwoFactorAuthData } = useTwoFactorAuth();
const showSetupModal = ref<boolean>(false);
onMounted(() => {
nextTick(() => { if (window.lucide) window.lucide.createIcons(); });
});
onUnmounted(() => {
clearTwoFactorAuthData();
});
</script>
<template>
<UserLayout>
<Head title="Two-Factor Authentication" />
<section class="content">
<div class="wrap">
<div class="main-panel">
<header class="page-head">
<div class="head-flex">
<div class="title-group">
<div class="title">Two-Factor Authentication</div>
<p class="subtitle">Manage your two-factor authentication settings</p>
</div>
<div class="security-badge">
<i data-lucide="shield-check"></i>
<span v-if="twoFactorEnabled">Enabled</span>
<span v-else>Disabled</span>
</div>
</div>
</header>
<div class="settings-nav">
<a href="/settings/profile" class="nav-item"><i data-lucide="user"></i> Profile</a>
<a href="/settings/kyc" class="nav-item"><i data-lucide="file-check"></i> KYC</a>
<a href="/settings/security" class="nav-item"><i data-lucide="lock"></i> Security</a>
<a href="/settings/two-factor" class="nav-item active"><i data-lucide="shield"></i> 2FA</a>
</div>
<div class="tf-body">
<div v-if="!twoFactorEnabled" class="section">
<div class="badge bad">Disabled</div>
<p class="muted">
When you enable two-factor authentication, you will be prompted for a secure pin during login.
This pin can be retrieved from a TOTP-supported application on your phone.
</p>
<div class="actions">
<button v-if="hasSetupData" class="btn" @click="showSetupModal = true">
<i data-lucide="shield-check"></i> Continue Setup
</button>
<Form v-else v-bind="enable.form()" @success="showSetupModal = true" #default="{ processing }">
<button class="btn" type="submit" :disabled="processing">
<i data-lucide="shield-check"></i> Enable 2FA
</button>
</Form>
</div>
</div>
<div v-else class="section">
<div class="badge ok">Enabled</div>
<p class="muted">
With two-factor authentication enabled, you will be prompted for a secure, random pin during login,
which you can retrieve from the TOTP-supported application on your phone.
</p>
<TwoFactorRecoveryCodes />
<div class="actions">
<Form v-bind="disable.form()" #default="{ processing }">
<button class="btn danger" type="submit" :disabled="processing">
<i data-lucide="shield"></i> Disable 2FA
</button>
</Form>
</div>
</div>
<TwoFactorSetupModal
v-model:isOpen="showSetupModal"
:requiresConfirmation="requiresConfirmation"
:twoFactorEnabled="twoFactorEnabled"
/>
</div>
</div>
</div>
</section>
</UserLayout>
</template>
<style scoped>
:global(:root) { --bg-card:#0a0a0a; --border:#151515; --cyan:#00f2ff; --magenta:#ff007a; --green:#00ff9d; }
.content { padding: 30px; animation: fade-in 0.8s cubic-bezier(0.2, 0, 0, 1); }
.wrap { max-width: 900px; margin: 0 auto; }
.main-panel { background: var(--bg-card); border: 1px solid var(--border); border-radius: 20px; overflow: hidden; box-shadow: 0 20px 50px rgba(0,0,0,0.6); }
.page-head { padding: 25px 30px; border-bottom: 1px solid var(--border); background: linear-gradient(to right, rgba(0,242,255,0.03), transparent); }
.head-flex { display: flex; justify-content: space-between; align-items: center; }
.title { font-size: 14px; font-weight: 900; color: #fff; letter-spacing: 3px; text-transform: uppercase; }
.subtitle { color: #555; font-size: 12px; margin-top: 4px; font-weight: 600; }
.security-badge { display:flex; align-items:center; gap:8px; color: var(--green); background: rgba(0,255,157,0.05); padding:6px 14px; border-radius:50px; border:1px solid rgba(0,255,157,0.1); font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:1px; }
/* Navigation */
.settings-nav { display: flex; gap: 5px; padding: 15px 30px; border-bottom: 1px solid var(--border); background: rgba(0,0,0,0.2); overflow-x: auto; }
.nav-item { display: flex; align-items: center; gap: 8px; padding: 10px 16px; border-radius: 8px; font-size: 11px; font-weight: 800; color: #666; text-transform: uppercase; letter-spacing: 1px; transition: 0.2s; text-decoration: none; }
.nav-item:hover { color: #fff; background: rgba(255,255,255,0.05); }
.nav-item.active { color: var(--cyan); background: rgba(0,242,255,0.1); }
.nav-item i { width: 14px; }
.tf-body { padding: 24px 28px; display: grid; gap: 22px; }
.section { border: 1px solid var(--border); background: #0a0a0a; border-radius: 14px; padding: 18px; display: grid; gap: 12px; animation: slide-up 0.6s cubic-bezier(0.2, 0, 0, 1) backwards; }
.badge { width: max-content; font-size:10px; font-weight:900; text-transform: uppercase; letter-spacing:1px; padding:6px 10px; border-radius:999px; border:1px solid #222; }
.badge.ok { color:#000; background: var(--green); border-color: rgba(0,255,157,.35); }
.badge.bad { color:#000; background: #ff5b5b; border-color: rgba(255,91,91,.35); }
.muted { color:#9aa0a6; }
.actions { display:flex; gap:10px; align-items:center; }
.btn { background: var(--cyan); color: #000; border: none; border-radius: 12px; padding: 12px 16px; font-size: 12px; font-weight: 900; text-transform: uppercase; letter-spacing: 1px; cursor: pointer; transition: 0.3s; box-shadow: 0 0 20px rgba(0,242,255,0.2); }
.btn:hover:not(:disabled) { transform: translateY(-1px); box-shadow: 0 6px 22px rgba(0,242,255,0.35); }
.btn:disabled { opacity:.6; cursor:not-allowed; }
.btn.danger { background:#ff5b5b; color:#000; box-shadow: 0 0 20px rgba(255,91,91,.25); }
@keyframes fade-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
@keyframes slide-up { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
@media (max-width: 768px) {
.wrap { max-width: 100%; }
.tf-body { padding: 18px; }
.settings-nav { padding: 10px 15px; }
}
</style>