Initialer Laravel Commit für BetiX
This commit is contained in:
502
resources/js/pages/Admin/CasinoDashboard.vue
Normal file
502
resources/js/pages/Admin/CasinoDashboard.vue
Normal 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>
|
||||
110
resources/js/pages/Admin/Chat.vue
Normal file
110
resources/js/pages/Admin/Chat.vue
Normal 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>
|
||||
761
resources/js/pages/Admin/ChatReportShow.vue
Normal file
761
resources/js/pages/Admin/ChatReportShow.vue
Normal 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>
|
||||
354
resources/js/pages/Admin/ChatReports.vue
Normal file
354
resources/js/pages/Admin/ChatReports.vue
Normal 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>
|
||||
155
resources/js/pages/Admin/Dashboard.vue
Normal file
155
resources/js/pages/Admin/Dashboard.vue
Normal 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>
|
||||
440
resources/js/pages/Admin/GeoBlock.vue
Normal file
440
resources/js/pages/Admin/GeoBlock.vue
Normal 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>
|
||||
297
resources/js/pages/Admin/PaymentsSettings.vue
Normal file
297
resources/js/pages/Admin/PaymentsSettings.vue
Normal 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 Live‑Modus, Coins‑Whitelist, Limits und BTX‑Kurs.</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 Live‑Betrieb 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>Coins‑Whitelist</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>Per‑Wä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>
|
||||
1007
resources/js/pages/Admin/ProfileReportShow.vue
Normal file
1007
resources/js/pages/Admin/ProfileReportShow.vue
Normal file
File diff suppressed because it is too large
Load Diff
359
resources/js/pages/Admin/ProfileReports.vue
Normal file
359
resources/js/pages/Admin/ProfileReports.vue
Normal 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>
|
||||
137
resources/js/pages/Admin/Promos.vue
Normal file
137
resources/js/pages/Admin/Promos.vue
Normal 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>
|
||||
281
resources/js/pages/Admin/SiteSettings.vue
Normal file
281
resources/js/pages/Admin/SiteSettings.vue
Normal 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>
|
||||
632
resources/js/pages/Admin/Support.vue
Normal file
632
resources/js/pages/Admin/Support.vue
Normal 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>
|
||||
420
resources/js/pages/Admin/UserShow.vue
Normal file
420
resources/js/pages/Admin/UserShow.vue
Normal 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>
|
||||
142
resources/js/pages/Admin/Users.vue
Normal file
142
resources/js/pages/Admin/Users.vue
Normal 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>
|
||||
139
resources/js/pages/Admin/WalletsSettings.vue
Normal file
139
resources/js/pages/Admin/WalletsSettings.vue
Normal 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">PIN‑Policy, 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>Transfer‑Limits (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>
|
||||
Reference in New Issue
Block a user