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>