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

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

172
resources/css/app.css Normal file
View File

@@ -0,0 +1,172 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../storage/framework/views/*.php';
@custom-variant dark (&:is(.dark *));
@theme inline {
--font-sans:
Instrument Sans, ui-sans-serif, system-ui, sans-serif,
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar-background);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
/*
The default border color has changed to `currentColor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentColor);
}
}
@layer utilities {
body,
html {
--font-sans:
'Instrument Sans', ui-sans-serif, system-ui, sans-serif,
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
}
}
:root {
--background: hsl(0 0% 100%);
--foreground: hsl(0 0% 3.9%);
--card: hsl(0 0% 100%);
--card-foreground: hsl(0 0% 3.9%);
--popover: hsl(0 0% 100%);
--popover-foreground: hsl(0 0% 3.9%);
--primary: hsl(0 0% 9%);
--primary-foreground: hsl(0 0% 98%);
--secondary: hsl(0 0% 92.1%);
--secondary-foreground: hsl(0 0% 9%);
--muted: hsl(0 0% 96.1%);
--muted-foreground: hsl(0 0% 45.1%);
--accent: hsl(0 0% 96.1%);
--accent-foreground: hsl(0 0% 9%);
--destructive: hsl(0 84.2% 60.2%);
--destructive-foreground: hsl(0 0% 98%);
--border: hsl(0 0% 92.8%);
--input: hsl(0 0% 89.8%);
--ring: hsl(0 0% 3.9%);
--chart-1: hsl(12 76% 61%);
--chart-2: hsl(173 58% 39%);
--chart-3: hsl(197 37% 24%);
--chart-4: hsl(43 74% 66%);
--chart-5: hsl(27 87% 67%);
--radius: 0.5rem;
--sidebar-background: hsl(0 0% 98%);
--sidebar-foreground: hsl(240 5.3% 26.1%);
--sidebar-primary: hsl(0 0% 10%);
--sidebar-primary-foreground: hsl(0 0% 98%);
--sidebar-accent: hsl(0 0% 94%);
--sidebar-accent-foreground: hsl(0 0% 30%);
--sidebar-border: hsl(0 0% 91%);
--sidebar-ring: hsl(217.2 91.2% 59.8%);
--sidebar: hsl(0 0% 98%);
}
.dark {
--background: hsl(0 0% 3.9%);
--foreground: hsl(0 0% 98%);
--card: hsl(0 0% 3.9%);
--card-foreground: hsl(0 0% 98%);
--popover: hsl(0 0% 3.9%);
--popover-foreground: hsl(0 0% 98%);
--primary: hsl(0 0% 98%);
--primary-foreground: hsl(0 0% 9%);
--secondary: hsl(0 0% 14.9%);
--secondary-foreground: hsl(0 0% 98%);
--muted: hsl(0 0% 16.08%);
--muted-foreground: hsl(0 0% 63.9%);
--accent: hsl(0 0% 14.9%);
--accent-foreground: hsl(0 0% 98%);
--destructive: hsl(0 84% 60%);
--destructive-foreground: hsl(0 0% 98%);
--border: hsl(0 0% 14.9%);
--input: hsl(0 0% 14.9%);
--ring: hsl(0 0% 83.1%);
--chart-1: hsl(220 70% 50%);
--chart-2: hsl(160 60% 45%);
--chart-3: hsl(30 80% 55%);
--chart-4: hsl(280 65% 60%);
--chart-5: hsl(340 75% 55%);
--sidebar-background: hsl(0 0% 7%);
--sidebar-foreground: hsl(0 0% 95.9%);
--sidebar-primary: hsl(360, 100%, 100%);
--sidebar-primary-foreground: hsl(0 0% 100%);
--sidebar-accent: hsl(0 0% 15.9%);
--sidebar-accent-foreground: hsl(240 4.8% 95.9%);
--sidebar-border: hsl(0 0% 15.9%);
--sidebar-ring: hsl(217.2 91.2% 59.8%);
--sidebar: hsl(240 5.9% 10%);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

49
resources/js/app.ts Normal file
View File

@@ -0,0 +1,49 @@
import { createInertiaApp } from '@inertiajs/vue3';
import axios from 'axios';
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
import type { DefineComponent } from 'vue';
import { createApp, h } from 'vue';
import '../css/app.css';
import { initializeTheme } from './composables/useAppearance';
import { initializePrimaryColor } from './composables/usePrimaryColor';
import { i18n, initI18n } from './i18n';
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
// Configure Axios to include CSRF token
axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
const token = document.head.querySelector('meta[name="csrf-token"]');
if (token) {
axios.defaults.headers.common['X-CSRF-TOKEN'] = (token as HTMLMetaElement).content;
}
createInertiaApp({
title: (title) => (title ? `${title} - ${appName}` : appName),
resolve: (name) =>
resolvePageComponent(
`./pages/${name}.vue`,
import.meta.glob<DefineComponent>('./pages/**/*.vue', { eager: false }),
),
setup({ el, App, props, plugin }) {
const initialLocale = ((props as any).initialPage?.props?.locale) || 'en';
const app = createApp({ render: () => h(App, props) });
// Initialize i18n with the server-provided locale, then mount
initI18n(initialLocale).finally(() => {
app.use(plugin).use(i18n).mount(el);
});
},
progress: {
color: '#df006a',
},
});
// This will set light / dark mode on page load...
initializeTheme();
// Apply saved primary color (main accent) on page load...
initializePrimaryColor();
// Initialize casino data-theme before first paint to avoid flash
try {
const t = localStorage.getItem('casino-theme');
document.documentElement.setAttribute('data-theme', t === 'light' ? 'light' : 'dark');
} catch {}

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
const { variant = 'default' } = defineProps<{ variant?: 'default' | 'sidebar' }>();
</script>
<template>
<main :class="[
'flex-1 w-full',
variant === 'sidebar' ? 'p-4 lg:p-8' : 'container mx-auto p-4 lg:p-8'
]">
<slot />
</main>
</template>

View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
import type { BreadcrumbItem } from '@/types';
const props = withDefaults(defineProps<{
breadcrumbs?: BreadcrumbItem[];
}>(), {
breadcrumbs: () => [],
});
</script>
<template>
<header class="w-full border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div class="container mx-auto flex h-14 items-center gap-2 px-4">
<nav v-if="props.breadcrumbs?.length" class="flex items-center gap-2 text-sm text-muted-foreground">
<template v-for="(item, idx) in props.breadcrumbs" :key="idx">
<a v-if="item.href" :href="item.href" class="hover:underline">{{ item.title }}</a>
<span v-else>{{ item.title }}</span>
<span v-if="idx < (props.breadcrumbs!.length - 1)" aria-hidden="true">/</span>
</template>
</nav>
<div class="ml-auto">
<slot name="actions" />
</div>
</div>
</header>
</template>

View File

@@ -0,0 +1,14 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="text-[#ff007a]"
>
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
</svg>
</template>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
const { variant = 'default' } = defineProps<{ variant?: 'default' | 'sidebar'; class?: string }>();
</script>
<template>
<div :class="[
'min-h-screen w-full',
variant === 'sidebar' ? 'flex' : 'flex flex-col',
]">
<slot />
</div>
</template>

View File

@@ -0,0 +1,9 @@
<script setup lang="ts">
// Minimal placeholder for sidebar
</script>
<template>
<aside class="w-64 border-r bg-background/50 hidden lg:block">
<slot />
</aside>
</template>

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
import type { BreadcrumbItem } from '@/types';
const props = withDefaults(defineProps<{
breadcrumbs?: BreadcrumbItem[];
}>(), {
breadcrumbs: () => [],
});
</script>
<template>
<div class="w-full border-b bg-background/50">
<div class="container mx-auto flex h-12 items-center px-4">
<nav v-if="props.breadcrumbs?.length" class="flex items-center gap-2 text-sm text-muted-foreground">
<template v-for="(item, idx) in props.breadcrumbs" :key="idx">
<a v-if="item.href" :href="item.href" class="hover:underline">{{ item.title }}</a>
<span v-else>{{ item.title }}</span>
<span v-if="idx < (props.breadcrumbs!.length - 1)" aria-hidden="true">/</span>
</template>
</nav>
<slot name="actions" />
</div>
</div>
</template>

View File

@@ -0,0 +1,96 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { usePrimaryColor } from '@/composables/usePrimaryColor';
const active = ref<'appearance'|'theme'|'layout'>('appearance');
// Accent color state using global composable
const { primaryColor, updatePrimaryColor } = usePrimaryColor();
// Local hex input model to allow free typing without instantly rejecting partial values
const hexInput = ref<string>(primaryColor.value || '#ff007a');
watch(primaryColor, (val) => {
if (val && val.toLowerCase() !== hexInput.value.toLowerCase()) {
hexInput.value = val;
}
});
function onPickColor(e: Event) {
const value = (e.target as HTMLInputElement).value;
hexInput.value = value;
updatePrimaryColor(value);
}
function onHexInput(e: Event) {
const value = (e.target as HTMLInputElement).value.trim();
hexInput.value = value;
}
function applyHex() {
// Accept #RGB or #RRGGBB; auto-add leading # if missing
let v = hexInput.value.trim();
if (!v.startsWith('#')) v = `#${v}`;
const isValid = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(v);
if (isValid) {
hexInput.value = v.toLowerCase();
updatePrimaryColor(hexInput.value);
}
}
function resetToDefault() {
const def = '#ff007a';
hexInput.value = def;
updatePrimaryColor(def);
}
</script>
<template>
<div class="appearance-tabs">
<div class="tabs">
<button :class="{active: active==='appearance'}" @click="active='appearance'">Appearance</button>
<button :class="{active: active==='theme'}" @click="active='theme'">Theme</button>
<button :class="{active: active==='layout'}" @click="active='layout'">Layout</button>
</div>
<div class="panel">
<div v-if="active==='appearance'" class="section">
<div class="row">
<div class="label"><i data-lucide="palette"></i> Accent color</div>
<div class="controls">
<input class="color" type="color" :value="primaryColor" @input="onPickColor" />
<input class="hex" type="text" v-model="hexInput" @input="onHexInput" placeholder="#ff007a" />
<button class="apply" @click="applyHex">Apply</button>
<button class="reset" @click="resetToDefault">Reset</button>
</div>
</div>
<div class="hint">Tipp: Du kannst entweder den Farbwähler benutzen oder einen HEXWert eingeben (z. B. #00aaff).</div>
<div class="preview">
<div class="swatch" :style="{ background: primaryColor }"></div>
<span class="code">{{ primaryColor }}</span>
</div>
</div>
<p v-else-if="active==='theme'">Theme settings coming soon.</p>
<p v-else>Layout settings coming soon.</p>
</div>
</div>
</template>
<style scoped>
.appearance-tabs { background:#0a0a0a; border:1px solid #151515; border-radius:12px; padding:12px; }
.tabs { display:flex; gap:8px; margin-bottom:10px; }
.tabs button { background:#0a0a0a; border:1px solid #1a1a1a; color:#fff; padding:8px 10px; border-radius:10px; font-size:12px; font-weight:900; cursor:pointer; }
.tabs button.active { background:#121212; border-color:#333; }
.panel { color:#aaa; font-size:12px; }
.section { display:flex; flex-direction:column; gap:10px; }
.row { display:flex; align-items:center; justify-content:space-between; gap:10px; }
.label { display:flex; align-items:center; gap:8px; color:#fff; font-weight:800; font-size:13px; }
.controls { display:flex; align-items:center; gap:8px; }
.color { width: 34px; height: 24px; border:1px solid #222; background:#111; border-radius:6px; padding:0; cursor:pointer; }
.hex { width:120px; background:#0a0a0a; border:1px solid #1a1a1a; color:#fff; padding:6px 8px; border-radius:8px; font-size:12px; font-weight:700; }
.apply, .reset { background:#0a0a0a; border:1px solid #1a1a1a; color:#fff; padding:6px 10px; border-radius:8px; font-size:12px; font-weight:800; cursor:pointer; }
.apply:hover, .reset:hover { border-color:#333; }
.hint { font-size:12px; color:#888; }
.preview { display:flex; align-items:center; gap:10px; }
.swatch { width:28px; height:18px; border-radius:6px; border:1px solid #222; }
.code { font-size:12px; color:#ccc; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; }
</style>

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
import { computed } from 'vue';
const props = withDefaults(defineProps<{
title: string;
description?: string;
variant?: 'default' | 'small';
}>(), {
variant: 'default',
});
const headingTag = computed(() => props.variant === 'small' ? 'h3' : 'h2');
</script>
<template>
<div class="space-y-1">
<component :is="headingTag" class="font-semibold text-2xl" :class="{ 'text-xl': variant === 'small' }">
{{ title }}
</component>
<p v-if="description" class="text-muted-foreground text-sm">
{{ description }}
</p>
</div>
</template>

View File

@@ -0,0 +1,13 @@
<script setup lang="ts">
defineProps<{
message?: string;
}>();
</script>
<template>
<div v-show="message">
<p class="text-sm text-red-500">
{{ message }}
</p>
</div>
</template>

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
defineProps<{
href: string;
tabindex?: number;
}>();
</script>
<template>
<Link
:href="href"
:tabindex="tabindex"
class="font-medium text-[#00f2ff] hover:text-[#00f2ff]/80 transition-colors"
>
<slot />
</Link>
</template>

View File

@@ -0,0 +1,107 @@
<script setup lang="ts">
import { ref, onMounted, nextTick } from 'vue';
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
import { regenerateRecoveryCodes } from '@/routes/two-factor';
const loading = ref(false);
const regenerating = ref(false);
const { recoveryCodesList, fetchRecoveryCodes, errors, clearErrors } = useTwoFactorAuth();
async function loadCodes() {
loading.value = true;
clearErrors();
try {
await fetchRecoveryCodes();
} finally {
loading.value = false;
nextTick(() => { if ((window as any).lucide) (window as any).lucide.createIcons(); });
}
}
async function regenerate() {
regenerating.value = true;
clearErrors();
try {
const res = await fetch(regenerateRecoveryCodes.url(), {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-TOKEN': (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement)?.content || '',
'Accept': 'application/json',
},
});
if (!res.ok) {
throw new Error('Failed to regenerate recovery codes');
}
await loadCodes();
} catch (e: any) {
errors.value.push(e?.message || 'Failed to regenerate recovery codes');
} finally {
regenerating.value = false;
}
}
onMounted(() => {
// Lazy-load by default; uncomment to auto-load
loadCodes();
});
</script>
<template>
<div class="rc-panel">
<div class="rc-head">
<div class="rc-title"><i data-lucide="key-round"></i> Recovery Codes</div>
<div class="rc-actions">
<button class="btn ghost" type="button" @click="loadCodes" :disabled="loading">
<span v-if="loading" class="spinner" />
<span>Show Codes</span>
</button>
<button class="btn danger" type="button" @click="regenerate" :disabled="regenerating">
<span v-if="regenerating" class="spinner" />
<span>Regenerate</span>
</button>
</div>
</div>
<div v-if="errors.length" class="rc-errors">
<div v-for="(err, i) in errors" :key="i" class="err-item">
<i data-lucide="alert-triangle"></i>{{ err }}
</div>
</div>
<div v-if="!recoveryCodesList.length && !loading" class="empty">
<i data-lucide="folder-open"></i>
<div>No recovery codes yet</div>
</div>
<ul v-else class="codes">
<li v-for="(code, idx) in recoveryCodesList" :key="idx" class="code">
<i data-lucide="shield"></i>
<span>{{ code }}</span>
</li>
</ul>
</div>
</template>
<style scoped>
:global(:root) { --bg-card:#0a0a0a; --border:#151515; --cyan:#00f2ff; --red:#ff5b5b; }
.rc-panel { border:1px solid var(--border); background:#0a0a0a; border-radius:14px; padding:16px; display:grid; gap:12px; }
.rc-head { display:flex; align-items:center; justify-content:space-between; gap:12px; }
.rc-title { display:flex; align-items:center; gap:8px; font-weight:900; color:#fff; }
.rc-title i { width:14px; color:#666; }
.rc-actions { display:flex; gap:8px; }
.btn { background: var(--cyan); color:#000; border:none; border-radius:10px; padding:10px 14px; font-weight:900; cursor:pointer; display:flex; align-items:center; gap:8px; }
.btn.ghost { background: #111; color:#ddd; border:1px solid #181818; }
.btn.danger { background: var(--red); color:#000; }
.btn:disabled { opacity:.6; cursor:not-allowed; }
.spinner { width: 14px; height: 14px; border: 2px solid rgba(0,0,0,0.1); border-top-color:#000; border-radius: 50%; animation: spin .8s linear infinite; }
.codes { list-style:none; padding:0; margin:0; display:grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap:10px; }
.code { display:flex; align-items:center; gap:10px; border:1px solid #151515; background:#050505; border-radius:10px; padding:10px 12px; font-weight:800; color:#eee; }
.code i { width:14px; color:#333; }
.empty { color:#666; display:flex; align-items:center; gap:10px; }
.empty i { width:18px; }
.rc-errors { display:grid; gap:6px; }
.err-item { display:flex; align-items:center; gap:8px; color:#ff5b5b; font-weight:800; }
.err-item i { width:14px; }
@keyframes spin { to { transform: rotate(360deg); } }
</style>

View File

@@ -0,0 +1,153 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch, nextTick } from 'vue';
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
// Props
const props = withDefaults(defineProps<{
isOpen: boolean;
requiresConfirmation?: boolean;
twoFactorEnabled?: boolean;
}>(), {
requiresConfirmation: false,
twoFactorEnabled: false,
});
// Emits
const emit = defineEmits<{
'update:isOpen': [boolean];
}>();
const internalOpen = ref<boolean>(props.isOpen);
watch(() => props.isOpen, (v) => internalOpen.value = v);
watch(internalOpen, (v) => emit('update:isOpen', v));
// 2FA Data
const {
qrCodeSvg,
manualSetupKey,
errors,
hasSetupData,
clearTwoFactorAuthData,
fetchSetupData,
} = useTwoFactorAuth();
const loading = ref(false);
async function loadSetup() {
loading.value = true;
try {
await fetchSetupData();
} finally {
loading.value = false;
nextTick(() => { if ((window as any).lucide) (window as any).lucide.createIcons(); });
}
}
function close() {
internalOpen.value = false;
}
onMounted(() => {
if (internalOpen.value) loadSetup();
});
watch(internalOpen, (open) => {
if (open) loadSetup();
else clearTwoFactorAuthData();
});
const canContinue = computed(() => hasSetupData.value);
</script>
<template>
<teleport to="body">
<div v-if="internalOpen" class="modal-backdrop" @click.self="close">
<div class="modal">
<header class="modal-head">
<div class="title">
<i data-lucide="shield-check"></i>
<span>Two-Factor Setup</span>
</div>
<button class="icon-btn" type="button" @click="close" aria-label="Close">
<i data-lucide="x"></i>
</button>
</header>
<section class="modal-body">
<div v-if="errors.length" class="err-list">
<div v-for="(e, idx) in errors" :key="idx" class="err">
<i data-lucide="alert-triangle"></i>{{ e }}
</div>
</div>
<div class="grid">
<div class="qr-box">
<div class="qr-wrap">
<div v-if="loading" class="qr-loading">
<div class="spinner"></div>
</div>
<div v-else-if="qrCodeSvg" class="qr" v-html="qrCodeSvg" />
<div v-else class="empty">
<i data-lucide="scan-line"></i>
<div>QR code not available</div>
</div>
</div>
<div class="hint">Scan this code with your authenticator app</div>
</div>
<div class="key-box">
<div class="lbl">Manual Setup Key</div>
<div class="key">
<i data-lucide="key"></i>
<span>{{ manualSetupKey || '—' }}</span>
</div>
<div class="hint">Enter this key in your authenticator app if you cannot scan the QR</div>
<div class="footer">
<button class="btn" type="button" :disabled="loading" @click="loadSetup">
<span v-if="loading" class="spinner" />
<span>Refresh Data</span>
</button>
<button class="btn primary" type="button" :disabled="!canContinue || loading" @click="close">
<i data-lucide="check"></i>
<span>{{ props.requiresConfirmation ? 'Continue to Confirm' : 'Done' }}</span>
</button>
</div>
</div>
</div>
</section>
</div>
</div>
</teleport>
</template>
<style scoped>
:global(:root) { --bg:#0a0a0a; --overlay: rgba(0,0,0,.7); --border:#151515; --cyan:#00f2ff; }
.modal-backdrop { position: fixed; inset: 0; background: var(--overlay); display: grid; place-items: center; z-index: 1000; padding: 16px; }
.modal { width: min(900px, 100%); background: #050505; border: 1px solid var(--border); border-radius: 16px; overflow: hidden; box-shadow: 0 20px 60px rgba(0,0,0,.7); }
.modal-head { display:flex; align-items:center; justify-content:space-between; padding:14px 16px; border-bottom:1px solid var(--border); }
.title { display:flex; align-items:center; gap:10px; font-weight:900; color:#fff; text-transform:uppercase; letter-spacing:1px; font-size:12px; }
.title i { width:14px; color:#666; }
.icon-btn { background: transparent; border: 1px solid #151515; color:#888; border-radius: 8px; width:32px; height:32px; display:flex; align-items:center; justify-content:center; cursor:pointer; }
.icon-btn:hover { color:#fff; border-color:#222; }
.modal-body { padding: 16px; display:grid; gap: 16px; }
.err-list { display:grid; gap:6px; }
.err { display:flex; align-items:center; gap:8px; color:#ff5b5b; font-weight:800; }
.err i { width:14px; }
.grid { display:grid; grid-template-columns: 320px 1fr; gap: 16px; }
.qr-wrap { border:1px solid var(--border); border-radius: 12px; padding: 10px; background:#0a0a0a; position: relative; min-height: 220px; display:flex; align-items:center; justify-content:center; }
.qr { display:block; width: 100%; height: auto; }
.qr-loading { display:flex; align-items:center; justify-content:center; width:100%; height:200px; }
.spinner { width: 18px; height: 18px; border: 2px solid rgba(0,0,0,0.1); border-top-color:#000; border-radius: 50%; animation: spin .8s linear infinite; }
.key-box { display:grid; gap: 10px; align-content:start; }
.lbl { font-size:10px; font-weight:900; color:#555; text-transform:uppercase; letter-spacing:1px; }
.key { display:flex; align-items:center; gap:10px; border:1px solid var(--border); background:#0a0a0a; border-radius:10px; padding:10px 12px; color:#eee; font-weight:800; }
.key i { width:14px; color:#333; }
.hint { color:#666; font-size:12px; }
.footer { margin-top: 8px; display:flex; gap:10px; }
.btn { background: #111; color:#ddd; border:1px solid #181818; border-radius: 10px; padding: 10px 14px; font-weight: 900; cursor: pointer; display:flex; align-items:center; gap:8px; }
.btn.primary { background: var(--cyan); color:#000; border-color: transparent; box-shadow: 0 0 20px rgba(0,242,255,0.2); }
.btn:disabled { opacity:.6; cursor:not-allowed; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (max-width: 800px) { .grid { grid-template-columns: 1fr; } }
</style>

View File

@@ -0,0 +1,323 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch } from 'vue';
import LoginForm from './LoginForm.vue';
import RegisterForm from './RegisterForm.vue';
import { X } from 'lucide-vue-next';
const props = defineProps<{
showLogin: boolean;
showRegister: boolean;
}>();
const emit = defineEmits<{
(e: 'close'): void;
(e: 'switch', type: 'login' | 'register'): void;
}>();
const handleClose = () => emit('close');
const switchTo = (type: 'login' | 'register') => emit('switch', type);
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') handleClose();
}
onMounted(() => document.addEventListener('keydown', onKey));
onBeforeUnmount(() => document.removeEventListener('keydown', onKey));
// Lock body scroll when modal is open
watch(() => props.showLogin || props.showRegister, (open) => {
document.body.style.overflow = open ? 'hidden' : '';
}, { immediate: true });
</script>
<template>
<transition name="auth-fade">
<div v-if="showLogin || showRegister" class="auth-overlay" @click.self="handleClose">
<div class="auth-card" :class="{ 'wide': showRegister }">
<!-- Decorative glow bg -->
<div class="auth-glow-1"></div>
<div class="auth-glow-2"></div>
<!-- Close -->
<button class="auth-close" @click="handleClose" aria-label="Close">
<X class="w-4 h-4" />
</button>
<!-- Brand header -->
<div class="auth-brand">
<div class="brand-logo">Beti<span>X</span></div>
<div class="brand-tagline">The Ultimate Crypto Casino</div>
</div>
<!-- Social proof bar -->
<div class="auth-proof">
<span class="proof-dot"></span>
<span>50,000+ active players</span>
<span class="proof-sep">·</span>
<span>Instant withdrawals</span>
<span class="proof-sep">·</span>
<span>Provably fair</span>
</div>
<!-- Login view -->
<div v-if="showLogin" class="auth-body">
<div class="auth-title-block">
<h2 class="auth-title">Welcome <span>Back</span></h2>
<p class="auth-sub">Log in to access your account</p>
</div>
<LoginForm :onSuccess="handleClose">
<template #forgot-password>
<a href="/forgot-password" class="forgot-link">Forgot password?</a>
</template>
</LoginForm>
<div class="auth-switch">
Don't have an account?
<button @click="switchTo('register')" class="switch-btn">Create one free</button>
</div>
</div>
<!-- Register view -->
<div v-if="showRegister" class="auth-body">
<div class="auth-title-block">
<h2 class="auth-title">Create <span>Account</span></h2>
<p class="auth-sub">Join the ultimate crypto protocol</p>
</div>
<RegisterForm :onSuccess="handleClose" />
<div class="auth-switch">
Already have an account?
<button @click="switchTo('login')" class="switch-btn">Log in</button>
</div>
</div>
</div>
</div>
</transition>
</template>
<style scoped>
.auth-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.88);
backdrop-filter: blur(12px);
display: flex;
align-items: center;
justify-content: center;
z-index: 9000;
padding: 16px;
}
.auth-card {
position: relative;
background: #0a0a0a;
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 24px;
width: 100%;
max-width: 460px;
overflow: hidden;
box-shadow:
0 0 0 1px rgba(223, 0, 106, 0.08),
0 40px 80px rgba(0, 0, 0, 0.7),
0 0 60px rgba(223, 0, 106, 0.06);
}
.auth-card.wide {
max-width: 520px;
}
/* Decorative glows */
.auth-glow-1 {
position: absolute;
top: -60px;
right: -60px;
width: 200px;
height: 200px;
background: radial-gradient(circle, rgba(223, 0, 106, 0.15) 0%, transparent 70%);
pointer-events: none;
z-index: 0;
}
.auth-glow-2 {
position: absolute;
bottom: -60px;
left: -40px;
width: 160px;
height: 160px;
background: radial-gradient(circle, rgba(0, 242, 255, 0.07) 0%, transparent 70%);
pointer-events: none;
z-index: 0;
}
.auth-close {
position: absolute;
top: 16px;
right: 16px;
width: 32px;
height: 32px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.08);
color: #666;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
z-index: 10;
}
.auth-close:hover {
color: #fff;
background: rgba(223, 0, 106, 0.2);
border-color: rgba(223, 0, 106, 0.4);
transform: rotate(90deg);
}
/* Brand header */
.auth-brand {
position: relative;
z-index: 1;
padding: 28px 32px 0;
text-align: center;
}
.brand-logo {
font-size: 2rem;
font-weight: 900;
color: #fff;
letter-spacing: -0.02em;
}
.brand-logo span {
color: var(--primary, #df006a);
}
.brand-tagline {
font-size: 11px;
color: #444;
text-transform: uppercase;
letter-spacing: 0.12em;
font-weight: 700;
margin-top: 2px;
}
/* Social proof */
.auth-proof {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
font-size: 11px;
color: #555;
font-weight: 600;
padding: 12px 32px 0;
}
.proof-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #00ff9d;
box-shadow: 0 0 6px #00ff9d;
animation: proof-pulse 2s infinite;
flex-shrink: 0;
}
@keyframes proof-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.proof-sep { color: #333; }
/* Body */
.auth-body {
position: relative;
z-index: 1;
padding: 24px 32px 32px;
max-height: calc(90vh - 120px);
overflow-y: auto;
}
.auth-body::-webkit-scrollbar { width: 4px; }
.auth-body::-webkit-scrollbar-track { background: transparent; }
.auth-body::-webkit-scrollbar-thumb { background: #222; border-radius: 2px; }
.auth-title-block {
text-align: center;
margin-bottom: 24px;
}
.auth-title {
font-size: 1.75rem;
font-weight: 900;
color: #fff;
letter-spacing: -0.02em;
margin: 0 0 4px;
line-height: 1.1;
}
.auth-title span {
color: var(--primary, #df006a);
}
.auth-sub {
font-size: 13px;
color: #666;
margin: 0;
}
/* Switch prompt */
.auth-switch {
text-align: center;
margin-top: 20px;
font-size: 12px;
color: #555;
}
.switch-btn {
color: var(--primary, #df006a);
font-weight: 700;
background: none;
border: none;
cursor: pointer;
padding: 0 0 0 4px;
transition: opacity 0.15s;
}
.switch-btn:hover { opacity: 0.75; text-decoration: underline; }
/* Forgot link */
.forgot-link {
font-size: 12px;
color: #555;
text-decoration: none;
transition: color 0.15s;
}
.forgot-link:hover { color: var(--primary, #df006a); }
/* Transition */
.auth-fade-enter-active, .auth-fade-leave-active {
transition: opacity 0.2s ease;
}
.auth-fade-enter-active .auth-card,
.auth-fade-leave-active .auth-card {
transition: transform 0.25s cubic-bezier(0.2, 0, 0, 1), opacity 0.2s ease;
}
.auth-fade-enter-from { opacity: 0; }
.auth-fade-leave-to { opacity: 0; }
.auth-fade-enter-from .auth-card { transform: scale(0.95) translateY(12px); }
.auth-fade-leave-to .auth-card { transform: scale(0.97); }
@media (max-width: 480px) {
.auth-card { border-radius: 16px; }
.auth-body { padding: 20px 20px 24px; }
.auth-brand { padding: 24px 20px 0; }
.auth-proof { padding: 10px 20px 0; flex-wrap: wrap; gap: 4px; }
.auth-title { font-size: 1.5rem; }
}
</style>

View File

@@ -0,0 +1,289 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { Check, Eye, EyeOff, X, AtSign, Lock } from 'lucide-vue-next';
import { reactive, ref, watch } from 'vue';
import InputError from '@/components/InputError.vue';
import Spinner from '@/components/ui/spinner.vue';
const props = defineProps<{
status?: string;
onSuccess?: () => void;
}>();
const form = useForm({
login: '',
password: '',
remember: false,
});
const showPassword = ref(false);
const validation = reactive({
login: { valid: false, error: '' },
password: { valid: false, error: '' },
});
const validateField = (field: string, value: any) => {
if (field === 'login') {
validation.login = value.length >= 3
? { valid: true, error: '' }
: { valid: false, error: 'Too short' };
}
if (field === 'password') {
validation.password = value
? { valid: true, error: '' }
: { valid: false, error: 'Required' };
}
};
watch(() => form.login, (v) => validateField('login', v));
watch(() => form.password, (v) => validateField('password', v));
const submit = () => {
form.transform((data) => ({ ...data, email: data.login })).post('/login', {
onSuccess: () => { if (props.onSuccess) props.onSuccess(); },
onFinish: () => form.reset('password'),
});
};
</script>
<template>
<div class="login-form-content">
<!-- Status message -->
<div v-if="status" class="lf-status">{{ status }}</div>
<form @submit.prevent="submit" class="lf-form">
<!-- Login -->
<div class="lf-field" :class="{ valid: validation.login.valid, invalid: validation.login.error || form.errors.login }">
<label class="lf-label" for="login">Email oder Benutzername</label>
<div class="lf-input-wrap">
<span class="lf-icon-left"><AtSign :size="15" /></span>
<input
id="login"
class="lf-input"
type="text"
v-model="form.login"
required
autofocus
autocomplete="username"
placeholder="CryptoKing oder email@example.com"
/>
<span class="lf-status-icon">
<Check v-if="validation.login.valid" :size="14" class="icon-valid" />
<X v-if="validation.login.error || form.errors.login" :size="14" class="icon-invalid" />
</span>
</div>
<InputError :message="form.errors.login" />
</div>
<!-- Password -->
<div class="lf-field" :class="{ valid: validation.password.valid, invalid: validation.password.error || form.errors.password }">
<label class="lf-label" for="password">Passwort</label>
<div class="lf-input-wrap">
<span class="lf-icon-left"><Lock :size="15" /></span>
<input
id="password"
class="lf-input lf-input-pw"
:type="showPassword ? 'text' : 'password'"
v-model="form.password"
required
autocomplete="current-password"
placeholder="••••••••"
/>
<button type="button" class="lf-eye-btn" @click="showPassword = !showPassword" :title="showPassword ? 'Verbergen' : 'Anzeigen'">
<EyeOff v-if="showPassword" :size="15" />
<Eye v-else :size="15" />
</button>
</div>
<InputError :message="form.errors.password" />
</div>
<!-- Remember + Forgot -->
<div class="lf-row-between">
<label class="lf-remember">
<input type="checkbox" name="remember" v-model="form.remember" class="sr-only peer" />
<span class="lf-check-box">
<Check :size="11" class="lf-tick" stroke-width="3.5" />
</span>
<span class="lf-remember-text">Angemeldet bleiben</span>
</label>
<slot name="forgot-password"></slot>
</div>
<!-- Submit -->
<button
type="submit"
class="lf-submit"
:disabled="form.processing"
>
<span class="lf-submit-shine"></span>
<Spinner v-if="form.processing" class="lf-spinner" />
<span :class="{ 'opacity-0': form.processing }">Einloggen</span>
</button>
</form>
</div>
</template>
<style scoped>
.login-form-content { width: 100%; }
/* Status */
.lf-status {
margin-bottom: 18px;
padding: 10px 14px;
border-radius: 10px;
font-size: .8rem;
color: #4ade80;
background: rgba(74,222,128,.08);
border: 1px solid rgba(74,222,128,.2);
text-align: center;
}
/* Form */
.lf-form { display: flex; flex-direction: column; gap: 18px; }
/* Field */
.lf-field { display: flex; flex-direction: column; gap: 7px; }
.lf-label { font-size: .75rem; font-weight: 700; color: #666; letter-spacing: .8px; text-transform: uppercase; }
/* Input wrapper */
.lf-input-wrap {
position: relative;
display: flex;
align-items: center;
}
.lf-icon-left {
position: absolute; left: 13px;
color: #444; pointer-events: none;
display: flex; align-items: center;
transition: color .2s;
}
.lf-input {
width: 100%;
height: 46px;
background: rgba(255,255,255,.04);
border: 1px solid rgba(255,255,255,.08);
border-radius: 12px;
color: #fff;
font-size: .9rem;
padding: 0 40px 0 38px;
outline: none;
transition: border-color .2s, background .2s, box-shadow .2s;
-webkit-appearance: none;
}
.lf-input::placeholder { color: #333; }
.lf-input:focus {
border-color: rgba(223,0,106,.4);
background: rgba(223,0,106,.03);
box-shadow: 0 0 0 3px rgba(223,0,106,.08);
}
.lf-input:focus ~ .lf-icon-left,
.lf-input-wrap:focus-within .lf-icon-left { color: var(--primary, #df006a); }
/* Extra padding for password (eye button on right) */
.lf-input-pw { padding-right: 46px; }
/* Status icon (check / X) */
.lf-status-icon {
position: absolute; right: 12px;
pointer-events: none; display: flex; align-items: center;
}
.icon-valid { color: #4ade80; }
.icon-invalid{ color: #f87171; }
/* Password eye button */
.lf-eye-btn {
position: absolute; right: 12px;
width: 28px; height: 28px;
display: flex; align-items: center; justify-content: center;
background: none; border: none; cursor: pointer;
color: #444; border-radius: 8px; transition: .2s;
}
.lf-eye-btn:hover { color: #aaa; background: rgba(255,255,255,.05); }
/* Valid / Invalid field states */
.valid .lf-input { border-color: rgba(74,222,128,.3); }
.invalid .lf-input { border-color: rgba(248,113,113,.35); background: rgba(248,113,113,.025); animation: shake .35s ease; }
@keyframes shake {
0%,100% { transform: translateX(0); }
25% { transform: translateX(-5px); }
75% { transform: translateX(5px); }
}
/* Remember row */
.lf-row-between {
display: flex; align-items: center; justify-content: space-between;
}
.lf-remember {
display: flex; align-items: center; gap: 9px;
cursor: pointer; user-select: none;
}
.lf-check-box {
width: 18px; height: 18px;
background: rgba(0,0,0,.4);
border: 1.5px solid #2a2a2a;
border-radius: 5px;
display: flex; align-items: center; justify-content: center;
transition: .2s;
flex-shrink: 0;
}
.sr-only:checked ~ .lf-check-box {
background: var(--primary, #df006a);
border-color: var(--primary, #df006a);
box-shadow: 0 0 10px rgba(223,0,106,.35);
}
.lf-tick {
color: #fff;
opacity: 0;
transform: scale(0);
transition: .15s cubic-bezier(.175,.885,.32,1.275);
}
.sr-only:checked ~ .lf-check-box .lf-tick { opacity: 1; transform: scale(1); }
/* Tailwind peer trick doesn't work in scoped — use sibling selector */
input[type="checkbox"]:checked + .lf-check-box { background: var(--primary, #df006a); border-color: var(--primary, #df006a); box-shadow: 0 0 10px rgba(223,0,106,.3); }
input[type="checkbox"]:checked + .lf-check-box .lf-tick { opacity: 1; transform: scale(1); }
.lf-remember-text { font-size: .78rem; color: #555; transition: .2s; }
.lf-remember:hover .lf-remember-text { color: #aaa; }
/* Submit button */
.lf-submit {
position: relative; overflow: hidden;
width: 100%; height: 48px;
background: linear-gradient(90deg, var(--primary, #df006a), color-mix(in srgb, var(--primary, #df006a) 65%, #7c3aed));
border: none; border-radius: 14px;
color: #fff; font-size: .9rem; font-weight: 800;
letter-spacing: 1.5px; text-transform: uppercase;
cursor: pointer; transition: .25s;
display: flex; align-items: center; justify-content: center;
box-shadow: 0 4px 20px rgba(223,0,106,.3);
margin-top: 4px;
}
.lf-submit:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 8px 32px rgba(223,0,106,.45);
filter: brightness(1.08);
}
.lf-submit:active:not(:disabled) { transform: translateY(0); }
.lf-submit:disabled { opacity: .55; cursor: not-allowed; }
.lf-submit-shine {
position: absolute; top: 0; left: -100%; width: 60%; height: 100%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,.15), transparent);
transform: skewX(-15deg);
animation: shine 3s ease-in-out infinite;
}
@keyframes shine {
0% { left: -100%; }
40% { left: 150%; }
100% { left: 150%; }
}
.lf-spinner { width: 18px; height: 18px; position: absolute; }
/* sr-only */
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
</style>

View File

@@ -0,0 +1,567 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { ChevronRight, ChevronLeft, Check, X } from 'lucide-vue-next';
import { ref, computed, watch, reactive } from 'vue';
import InputError from '@/components/InputError.vue';
import Button from '@/components/ui/button.vue';
import CountrySelect from '@/components/ui/CountrySelect.vue';
import DatePicker from '@/components/ui/DatePicker.vue';
import Input from '@/components/ui/input.vue';
import Label from '@/components/ui/label.vue';
import Select from '@/components/ui/Select.vue';
import Spinner from '@/components/ui/spinner.vue';
import { csrfFetch } from '@/utils/csrfFetch';
const props = defineProps<{
onSuccess?: () => void;
}>();
const form = useForm({
username: '',
first_name: '',
last_name: '',
full_name: '',
email: '',
birthdate: '',
gender: '',
phone: '',
country: '',
address_line1: '',
address_line2: '',
city: '',
postal_code: '',
currency: 'EUR',
password: '',
password_confirmation: '',
is_adult: false,
terms_accepted: false,
});
const currentStep = ref(1);
const totalSteps = 4;
const showPassword = ref(false);
const showConfirmPassword = ref(false);
const validation = reactive({
username: { valid: false, error: '' },
first_name: { valid: false, error: '' },
last_name: { valid: false, error: '' },
email: { valid: false, error: '' },
phone: { valid: false, error: '' },
birthdate: { valid: false, error: '' },
gender: { valid: false, error: '' },
address_line1: { valid: false, error: '' },
city: { valid: false, error: '' },
postal_code: { valid: false, error: '' },
password: { valid: false, error: '' },
password_confirmation: { valid: false, error: '' },
});
const availability = reactive({
username: { checked: false, checking: false, available: false, error: '' },
email: { checked: false, checking: false, available: false, error: '' },
});
const debounceTimers: Record<string, any> = { username: null, email: null };
const checkAvailability = (field: 'username' | 'email', value: string) => {
if (debounceTimers[field]) clearTimeout(debounceTimers[field]);
if (!value || (field === 'username' && validation.username.error) || (field === 'email' && validation.email.error)) {
availability[field].checked = false;
availability[field].available = false;
availability[field].error = '';
return;
}
debounceTimers[field] = setTimeout(async () => {
availability[field].checking = true;
availability[field].error = '';
const currentValue = value;
try {
const url = `/api/auth/availability?field=${field}&value=${encodeURIComponent(currentValue)}`;
const res = await csrfFetch(url, {
method: 'GET',
headers: { 'Accept': 'application/json' }
});
if (form[field] !== currentValue) return;
const data = await res.json();
if (res.ok && data.available) {
availability[field].available = true;
} else {
availability[field].available = false;
availability[field].error = data.message || (field === 'username' ? 'Username taken' : 'Email in use');
}
} catch (error) {
console.error('Availability check failed:', error);
availability[field].available = false;
availability[field].error = 'Error checking availability. Please try again.';
} finally {
availability[field].checking = false;
availability[field].checked = true;
}
}, 350);
};
const validateField = (field: string, value: any) => {
switch (field) {
case 'username':
if ((value || '').length < 3) {
validation.username = { valid: false, error: 'Min. 3 characters' };
} else {
validation.username = { valid: true, error: '' };
checkAvailability('username', value);
}
break;
case 'email':
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(value || '')) {
validation.email = { valid: false, error: 'Invalid email' };
} else {
validation.email = { valid: true, error: '' };
checkAvailability('email', value);
}
break;
case 'password':
if ((value || '').length < 8) {
validation.password = { valid: false, error: 'Min. 8 characters' };
} else {
validation.password = { valid: true, error: '' };
}
break;
case 'password_confirmation':
if (value !== form.password) {
validation.password_confirmation = { valid: false, error: 'Passwords mismatch' };
} else {
validation.password_confirmation = { valid: true, error: '' };
}
break;
case 'first_name':
case 'last_name':
case 'city':
case 'address_line1':
case 'postal_code':
validation[field] = (value || '').length > 0
? { valid: true, error: '' }
: { valid: false, error: 'Required' };
break;
case 'phone':
validation.phone = (value || '').length > 5
? { valid: true, error: '' }
: { valid: false, error: 'Required' };
break;
case 'birthdate':
validation.birthdate = value ? { valid: true, error: '' } : { valid: false, error: 'Required' };
break;
case 'gender':
validation.gender = value ? { valid: true, error: '' } : { valid: false, error: 'Required' };
break;
}
};
watch(() => form.username, (v) => validateField('username', v));
watch(() => form.email, (v) => validateField('email', v));
watch(() => form.password, (v) => {
validateField('password', v);
validateField('password_confirmation', form.password_confirmation);
});
watch(() => form.password_confirmation, (v) => validateField('password_confirmation', v));
watch(() => form.first_name, (v) => validateField('first_name', v));
watch(() => form.last_name, (v) => validateField('last_name', v));
watch(() => form.city, (v) => validateField('city', v));
watch(() => form.address_line1, (v) => validateField('address_line1', v));
watch(() => form.postal_code, (v) => validateField('postal_code', v));
watch(() => form.phone, (v) => validateField('phone', v));
watch(() => form.birthdate, (v) => validateField('birthdate', v));
watch(() => form.gender, (v) => validateField('gender', v));
const step1Valid = computed(() =>
validation.username.valid && availability.username.available &&
validation.first_name.valid && validation.last_name.valid
);
const step2Valid = computed(() =>
validation.email.valid && availability.email.available &&
validation.phone.valid && validation.birthdate.valid && validation.gender.valid
);
const step3Valid = computed(() =>
validation.address_line1.valid && validation.city.valid &&
validation.postal_code.valid && form.country && form.currency
);
const step4Valid = computed(() =>
validation.password.valid && validation.password_confirmation.valid &&
form.is_adult && form.terms_accepted
);
const nextStep = () => { if (currentStep.value < totalSteps) currentStep.value++; };
const prevStep = () => { if (currentStep.value > 1) currentStep.value--; };
const submit = () => {
form.post('/register', {
onSuccess: () => {
if (props.onSuccess) props.onSuccess();
},
onFinish: () => form.reset('password', 'password_confirmation'),
});
};
</script>
<template>
<div class="register-form-content">
<!-- Progress Bar -->
<div class="progress-container mb-8">
<div class="flex justify-between mb-2">
<span class="text-xs font-bold text-[var(--primary, #df006a)] uppercase tracking-wider">Step {{ currentStep }} of {{ totalSteps }}</span>
<span class="text-xs font-bold text-[#888] uppercase tracking-wider">{{ Math.round((currentStep / totalSteps) * 100) }}% Complete</span>
</div>
<div class="progress-track">
<div class="progress-fill" :style="{ width: `${(currentStep / totalSteps) * 100}%` }"></div>
</div>
</div>
<form @submit.prevent="submit" class="form-content">
<!-- Step 1: Basic Info -->
<div v-if="currentStep === 1" class="step-container space-y-4">
<div class="input-group" :class="{ 'valid': validation.username.valid && availability.username.available, 'invalid': validation.username.error || availability.username.error }">
<Label for="username">Username</Label>
<div class="input-wrapper">
<Input id="username" v-model="form.username" placeholder="CryptoKing" />
<div class="status-icon">
<Spinner v-if="availability.username.checking" class="w-4 h-4" />
<Check v-else-if="availability.username.available && validation.username.valid" class="text-green-500 w-4 h-4" />
<X v-else-if="availability.username.error || validation.username.error" class="text-red-500 w-4 h-4" />
</div>
</div>
<span v-if="availability.username.error" class="text-[10px] text-red-500 mt-1 uppercase font-bold">{{ availability.username.error }}</span>
<InputError :message="form.errors.username" />
</div>
<div class="grid grid-cols-2 gap-4">
<div class="input-group" :class="{ 'valid': validation.first_name.valid, 'invalid': validation.first_name.error }">
<Label for="first_name">First Name</Label>
<Input id="first_name" v-model="form.first_name" placeholder="John" />
<InputError :message="form.errors.first_name" />
</div>
<div class="input-group" :class="{ 'valid': validation.last_name.valid, 'invalid': validation.last_name.error }">
<Label for="last_name">Last Name</Label>
<Input id="last_name" v-model="form.last_name" placeholder="Doe" />
<InputError :message="form.errors.last_name" />
</div>
</div>
<Button type="button" @click="nextStep" class="w-full mt-4" :disabled="!step1Valid">
NEXT STEP <ChevronRight class="ml-2 w-4 h-4" />
</Button>
</div>
<!-- Step 2: Contact & Identity -->
<div v-if="currentStep === 2" class="step-container space-y-4">
<div class="input-group" :class="{ 'valid': validation.email.valid && availability.email.available, 'invalid': validation.email.error || availability.email.error }">
<Label for="email">Email Address</Label>
<div class="input-wrapper">
<Input id="email" type="email" v-model="form.email" placeholder="john@example.com" />
<div class="status-icon">
<Spinner v-if="availability.email.checking" class="w-4 h-4" />
<Check v-else-if="availability.email.available && validation.email.valid" class="text-green-500 w-4 h-4" />
<X v-else-if="availability.email.error || validation.email.error" class="text-red-500 w-4 h-4" />
</div>
</div>
<span v-if="availability.email.error" class="text-[10px] text-red-500 mt-1 uppercase font-bold">{{ availability.email.error }}</span>
<InputError :message="form.errors.email" />
</div>
<div class="input-group" :class="{ 'valid': validation.phone.valid, 'invalid': validation.phone.error }">
<Label for="phone">Phone Number</Label>
<Input id="phone" v-model="form.phone" placeholder="+49 123 456789" />
<InputError :message="form.errors.phone" />
</div>
<div class="grid grid-cols-2 gap-4">
<div class="input-group">
<Label>Birthdate</Label>
<DatePicker v-model="form.birthdate" />
<InputError :message="form.errors.birthdate" />
</div>
<div class="input-group">
<Label>Gender</Label>
<Select
v-model="form.gender"
:options="[
{ label: 'Male', value: 'male', icon: 'user' },
{ label: 'Female', value: 'female', icon: 'user' },
{ label: 'Other', value: 'other', icon: 'user' }
]"
placeholder="Select Gender"
/>
<InputError :message="form.errors.gender" />
</div>
</div>
<div class="flex gap-4 mt-4">
<Button type="button" variant="outline" @click="prevStep" class="flex-1">
<ChevronLeft class="mr-2 w-4 h-4" /> BACK
</Button>
<Button type="button" @click="nextStep" class="flex-[2]" :disabled="!step2Valid">
NEXT STEP <ChevronRight class="ml-2 w-4 h-4" />
</Button>
</div>
</div>
<!-- Step 3: Address -->
<div v-if="currentStep === 3" class="step-container space-y-4">
<div class="input-group">
<Label>Country</Label>
<CountrySelect v-model="form.country" />
<InputError :message="form.errors.country" />
</div>
<div class="input-group">
<Label for="address">Address</Label>
<Input id="address" v-model="form.address_line1" placeholder="Main Street 123" />
<InputError :message="form.errors.address_line1" />
</div>
<div class="input-group">
<Label for="address_line2">Address Line 2 (Optional)</Label>
<Input id="address_line2" v-model="form.address_line2" placeholder="Apartment, suite, etc." />
<InputError :message="form.errors.address_line2" />
</div>
<div class="grid grid-cols-2 gap-4">
<div class="input-group">
<Label for="city">City</Label>
<Input id="city" v-model="form.city" placeholder="Berlin" />
<InputError :message="form.errors.city" />
</div>
<div class="input-group">
<Label for="postal">Postal Code</Label>
<Input id="postal" v-model="form.postal_code" placeholder="10115" />
<InputError :message="form.errors.postal_code" />
</div>
</div>
<div class="input-group">
<Label>Preferred Currency</Label>
<Select
v-model="form.currency"
:options="[
{ label: 'EUR - Euro', value: 'EUR', icon: 'euro' },
{ label: 'USD - US Dollar', value: 'USD', icon: 'dollar-sign' },
{ label: 'BTC - Bitcoin', value: 'BTC', icon: 'bitcoin' }
]"
/>
</div>
<div class="flex gap-4 mt-4">
<Button type="button" variant="outline" @click="prevStep" class="flex-1">
<ChevronLeft class="mr-2 w-4 h-4" /> BACK
</Button>
<Button type="button" @click="nextStep" class="flex-[2]" :disabled="!step3Valid">
NEXT STEP <ChevronRight class="ml-2 w-4 h-4" />
</Button>
</div>
</div>
<!-- Step 4: Security -->
<div v-if="currentStep === 4" class="step-container space-y-4">
<div class="input-group">
<div class="flex justify-between">
<Label for="password">Password</Label>
<button type="button" @click="showPassword = !showPassword" class="text-xs text-[#888]">{{ showPassword ? 'Hide' : 'Show' }}</button>
</div>
<Input :type="showPassword ? 'text' : 'password'" v-model="form.password" placeholder="Min. 8 characters" />
<InputError :message="form.errors.password" />
</div>
<div class="input-group">
<div class="flex justify-between">
<Label for="password_confirmation">Confirm Password</Label>
<button type="button" @click="showConfirmPassword = !showConfirmPassword" class="text-xs text-[#888]">{{ showConfirmPassword ? 'Hide' : 'Show' }}</button>
</div>
<Input :type="showConfirmPassword ? 'text' : 'password'" v-model="form.password_confirmation" placeholder="Repeat password" />
<InputError :message="form.errors.password_confirmation" />
</div>
<div class="reg-checks">
<InputError :message="form.errors.is_adult" />
<InputError :message="form.errors.terms_accepted" />
<label class="reg-check-label" :class="{ 'reg-check-active': form.is_adult }">
<input type="checkbox" v-model="form.is_adult" class="reg-sr-only" />
<span class="reg-box">
<Check :size="11" class="reg-tick" stroke-width="3.5" />
</span>
<span class="reg-check-text">
Ich bestätige, dass ich <strong>18 Jahre oder älter</strong> bin und berechtigt bin, an Online-Spielen teilzunehmen.
</span>
</label>
<label class="reg-check-label" :class="{ 'reg-check-active': form.terms_accepted }">
<input type="checkbox" v-model="form.terms_accepted" class="reg-sr-only" />
<span class="reg-box">
<Check :size="11" class="reg-tick" stroke-width="3.5" />
</span>
<span class="reg-check-text">
Ich habe die <a href="/terms" target="_blank" class="reg-link">Nutzungsbedingungen</a> und <a href="/privacy" target="_blank" class="reg-link">Datenschutzerklärung</a> gelesen und akzeptiert.
</span>
</label>
</div>
<div class="flex gap-4 mt-4">
<Button type="button" variant="outline" @click="prevStep" class="flex-1">
<ChevronLeft class="mr-2 w-4 h-4" /> BACK
</Button>
<Button type="submit" class="flex-[2] neon-button" :disabled="form.processing || !step4Valid">
<Spinner v-if="form.processing" class="mr-2 w-4 h-4" />
FINISH REGISTRATION
</Button>
</div>
</div>
</form>
</div>
</template>
<style scoped>
.progress-track {
height: 4px;
background: rgba(255, 255, 255, 0.05);
border-radius: 2px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--primary, #df006a), #00f2ff);
box-shadow: 0 0 10px rgba(223, 0, 106, 0.5);
transition: width 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
.input-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.input-wrapper {
position: relative;
}
.status-icon {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
pointer-events: none;
}
.neon-button {
background: linear-gradient(90deg, var(--primary, #df006a), color-mix(in srgb, var(--primary, #df006a) 70%, #000));
color: #fff;
border: none;
box-shadow: 0 0 20px rgba(223, 0, 106, 0.4);
transition: all 0.3s ease;
}
.neon-button:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 0 40px rgba(223, 0, 106, 0.7);
}
.valid :deep(input) {
border-color: rgba(34, 197, 94, 0.3) !important;
}
.invalid :deep(input) {
border-color: rgba(239, 68, 68, 0.3) !important;
}
/* ── Custom Checkboxes (Step 4) ─────────────────────────── */
.reg-checks {
display: flex;
flex-direction: column;
gap: 10px;
padding-top: 10px;
border-top: 1px solid rgba(255,255,255,.05);
}
.reg-sr-only {
position: absolute; width: 1px; height: 1px;
padding: 0; margin: -1px; overflow: hidden;
clip: rect(0,0,0,0); white-space: nowrap; border: 0;
}
.reg-check-label {
display: flex;
align-items: flex-start;
gap: 11px;
padding: 12px 14px;
border-radius: 12px;
border: 1px solid rgba(255,255,255,.06);
background: rgba(255,255,255,.02);
cursor: pointer;
transition: border-color .2s, background .2s;
user-select: none;
}
.reg-check-label:hover {
border-color: rgba(223,0,106,.2);
background: rgba(223,0,106,.03);
}
.reg-check-active {
border-color: rgba(223,0,106,.3) !important;
background: rgba(223,0,106,.05) !important;
box-shadow: 0 0 0 1px rgba(223,0,106,.1);
}
.reg-box {
flex-shrink: 0;
width: 20px; height: 20px;
margin-top: 1px;
background: rgba(0,0,0,.4);
border: 1.5px solid #2a2a2a;
border-radius: 6px;
display: flex; align-items: center; justify-content: center;
transition: .2s;
}
.reg-check-active .reg-box {
background: var(--primary, #df006a);
border-color: var(--primary, #df006a);
box-shadow: 0 0 12px rgba(223,0,106,.4);
}
.reg-tick {
color: #fff;
opacity: 0;
transform: scale(0);
transition: .15s cubic-bezier(.175,.885,.32,1.275);
}
.reg-check-active .reg-tick {
opacity: 1;
transform: scale(1);
}
.reg-check-text {
font-size: 11px;
color: #666;
line-height: 1.55;
transition: color .2s;
}
.reg-check-label:hover .reg-check-text,
.reg-check-active .reg-check-text { color: #aaa; }
.reg-check-text strong { color: #bbb; font-weight: 700; }
.reg-link {
color: var(--primary, #df006a);
text-decoration: none;
font-weight: 600;
transition: .15s;
}
.reg-link:hover { text-decoration: underline; filter: brightness(1.2); }
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,694 @@
<script setup lang="ts">
import { ref, onMounted, nextTick, onUnmounted, computed, watch } from 'vue';
import { csrfFetch } from '@/utils/csrfFetch';
import { usePage } from '@inertiajs/vue3';
type Sender = 'user' | 'ai' | 'agent' | 'system';
type ServerMessage = { id: string; sender: Sender; body: string; at: string };
const page = usePage();
const user = computed(() => (page.props.auth as any).user || {});
const isOpen = ref(false);
const isDismissed = ref(false);
const hiddenByGame = ref(false);
const isClosing = ref(false);
const showCloseConfirm = ref(false);
const input = ref('');
const sending = ref(false);
const loading = ref(false);
const status = ref<'new'|'ai'|'stopped'|'handoff'|'agent'|'closed'>('new');
const threadId = ref<string | null>(null);
const topic = ref<string | null>(null);
const messages = ref<ServerMessage[]>([]);
let pollTimer: any = null;
let es: EventSource | null = null;
let esBackoff = 1000;
const notificationSound = ref<HTMLAudioElement | null>(null);
// --- Draggable support ---
const dragPos = ref({ right: 20, bottom: 20 });
const dragging = ref(false);
const wasDragged = ref(false);
let dragStart = { x: 0, y: 0, right: 20, bottom: 20 };
function onDragStart(e: MouseEvent | TouchEvent) {
// Don't prevent default here so clicks still work
const clientX = e instanceof MouseEvent ? e.clientX : e.touches[0].clientX;
const clientY = e instanceof MouseEvent ? e.clientY : e.touches[0].clientY;
dragging.value = true;
wasDragged.value = false;
dragStart = { x: clientX, y: clientY, right: dragPos.value.right, bottom: dragPos.value.bottom };
document.addEventListener('mousemove', onDragMove);
document.addEventListener('mouseup', onDragEnd);
document.addEventListener('touchmove', onDragMove, { passive: false });
document.addEventListener('touchend', onDragEnd);
}
function onDragMove(e: MouseEvent | TouchEvent) {
if (!dragging.value) return;
if (e instanceof TouchEvent) e.preventDefault();
const clientX = e instanceof MouseEvent ? e.clientX : e.touches[0].clientX;
const clientY = e instanceof MouseEvent ? e.clientY : e.touches[0].clientY;
const dx = dragStart.x - clientX;
const dy = dragStart.y - clientY;
// Only count as drag if moved more than 5px
if (Math.abs(dx) > 5 || Math.abs(dy) > 5) wasDragged.value = true;
const newRight = Math.max(4, Math.min(window.innerWidth - 64, dragStart.right + dx));
const newBottom = Math.max(4, Math.min(window.innerHeight - 64, dragStart.bottom + dy));
dragPos.value = { right: newRight, bottom: newBottom };
}
function onDragEnd() {
dragging.value = false;
document.removeEventListener('mousemove', onDragMove);
document.removeEventListener('mouseup', onDragEnd);
document.removeEventListener('touchmove', onDragMove);
document.removeEventListener('touchend', onDragEnd);
// Reset wasDragged after click event fires
if (wasDragged.value) setTimeout(() => { wasDragged.value = false; }, 50);
}
// --- End draggable ---
const getUserAvatar = (u: any) => {
if (!u) return null;
return u.avatar_url || u.avatar || u.profile_photo_url || null;
};
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}`;
};
function requestCloseChat() {
showCloseConfirm.value = true;
}
async function confirmClose() {
if (isClosing.value) return;
isClosing.value = true;
try {
await csrfFetch('/api/support/close', { method: 'POST', headers: { 'Accept': 'application/json' } });
stopEventStream();
stopPolling();
status.value = 'new';
messages.value = [];
threadId.value = null;
topic.value = null;
} catch {}
finally {
isClosing.value = false;
showCloseConfirm.value = false;
}
}
const topics = [
'Konto', 'Einzahlung', 'Auszahlung', 'Bonus/Promo', 'Technisches Problem'
];
function readUiState() {
try {
const ds = localStorage.getItem('supportchat:dismissed');
isDismissed.value = ds === '1';
} catch {}
}
function saveUiState() {
try {
localStorage.setItem('supportchat:dismissed', isDismissed.value ? '1' : '0');
} catch {}
}
function toggle() {
if (isDismissed.value) {
isDismissed.value = false;
saveUiState();
}
isOpen.value = !isOpen.value;
if (isOpen.value) {
checkStatus();
} else {
stopEventStream();
stopPolling();
}
nextTick(() => { if (window.lucide) window.lucide.createIcons(); });
}
async function checkStatus() {
if (loading.value) return;
try {
const res = await fetch('/api/support/status', { headers: { 'Accept': 'application/json' } });
if (res.ok) {
const json = await res.json();
const tid = json.thread_id || json.id;
if (tid && json.status !== 'closed') {
mapFromServer(json);
await nextTick();
scrollToBottom();
startEventStream(); // Always start listening for updates
if (!supportsSSE()) startPolling();
} else {
threadId.value = null;
messages.value = [];
status.value = 'new';
}
}
} catch {}
}
function dismiss() {
isDismissed.value = true;
isOpen.value = false;
saveUiState();
}
function restore() {
isDismissed.value = false;
saveUiState();
nextTick(() => { if ((window as any).lucide) (window as any).lucide.createIcons(); });
}
// Re-initialize Lucide icons whenever FAB or restore button visibility changes
watch([isOpen, isDismissed], () => {
nextTick(() => { if ((window as any).lucide) (window as any).lucide.createIcons(); });
});
function scrollToBottom() {
const el = document.getElementById('support-chat-list');
if (el) el.scrollTop = el.scrollHeight;
}
function mapFromServer(resp: any) {
const oldMessagesCount = messages.value.length;
const newMessages = Array.isArray(resp.messages) ? resp.messages : [];
threadId.value = resp.thread_id || resp.id || null;
status.value = resp.status || 'new';
topic.value = resp.topic || null;
// Filter AI messages if agent is active
if (status.value === 'agent' || status.value === 'handoff') {
messages.value = newMessages.filter((m: ServerMessage) => m.sender !== 'ai');
} else {
messages.value = newMessages;
}
// Sound Logic: Check if we have MORE messages than before AND the last one is from agent
if (messages.value.length > oldMessagesCount) {
const lastMessage = messages.value[messages.value.length - 1];
if (lastMessage && lastMessage.sender === 'agent' && notificationSound.value) {
notificationSound.value.play().catch(() => {});
}
}
}
function typeAiMessage(fullText: string) {
if (status.value === 'agent' || status.value === 'handoff') return;
const messageId = String(Date.now());
const aiMessage = {
id: messageId,
sender: 'ai' as Sender,
body: '',
at: new Date().toISOString()
};
messages.value.push(aiMessage);
let i = 0;
const typingInterval = setInterval(() => {
if (status.value === 'agent' || status.value === 'handoff') {
clearInterval(typingInterval);
messages.value = messages.value.filter(m => m.id !== messageId);
return;
}
const targetMessage = messages.value.find(m => m.id === messageId);
if (targetMessage && i < fullText.length) {
targetMessage.body += fullText.charAt(i);
i++;
scrollToBottom();
} else {
clearInterval(typingInterval);
}
}, 20);
}
async function handleAiReply(state: any) {
if (state.status === 'agent' || state.status === 'handoff') {
mapFromServer(state);
await nextTick();
scrollToBottom();
return;
}
const aiReply = state.messages.findLast((m: ServerMessage) => m.sender === 'ai');
if (aiReply) {
state.messages = state.messages.filter((m: ServerMessage) => m.id !== aiReply.id);
}
mapFromServer(state);
await nextTick();
if (aiReply) {
typeAiMessage(aiReply.body);
} else {
scrollToBottom();
}
}
async function ensureStarted(t?: string) {
if (loading.value) return;
loading.value = true;
try {
const res = await csrfFetch('/api/support/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({ topic: t || topic.value })
});
if (!res.ok) throw new Error(`Server Fehler: ${res.status}`);
const json = await res.json();
// Process response first so threadId.value is set before startEventStream checks it
await handleAiReply(json);
await nextTick();
scrollToBottom();
startEventStream();
if (!supportsSSE()) startPolling();
} catch (e: any) {
messages.value.push({
id: String(Date.now()),
sender: 'system',
body: `Fehler beim Starten des Chats.`,
at: new Date().toISOString()
});
} finally {
loading.value = false;
}
}
async function send() {
const text = input.value.trim();
if (!text || sending.value) return;
sending.value = true;
const userMessage = {
id: String(Date.now()),
sender: 'user' as Sender,
body: text,
at: new Date().toISOString()
};
messages.value.push(userMessage);
input.value = '';
await nextTick();
scrollToBottom();
try {
const res = await csrfFetch('/api/support/message', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({ text })
});
if (!res.ok) throw new Error(`Server Fehler: ${res.status}`);
const json = await res.json();
handleAiReply(json);
} catch (e: any) {
messages.value.push({
id: String(Date.now() + 1),
sender: 'system',
body: `Nachricht konnte nicht gesendet werden.`,
at: new Date().toISOString()
});
} finally {
sending.value = false;
}
}
function startPolling(){
if (pollTimer || es) return;
pollTimer = setInterval(async () => {
try {
const res = await fetch('/api/support/status', { headers: { 'Accept': 'application/json' } });
if (!res.ok) return;
const json = await res.json();
const prevLen = messages.value.length;
mapFromServer(json);
if (messages.value.length !== prevLen) nextTick(scrollToBottom);
} catch {}
}, 5000); // Faster polling (5s) for better responsiveness
}
function stopPolling(){ if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } }
function supportsSSE(){ return typeof window !== 'undefined' && 'EventSource' in window; }
function startEventStream() {
if (!supportsSSE() || es || !threadId.value) return;
try {
es = new EventSource('/api/support/stream', { withCredentials: true } as any);
es.onmessage = (ev) => {
try {
const data = JSON.parse(ev.data);
const prevLen = messages.value.length;
mapFromServer(data);
if (messages.value.length !== prevLen) nextTick(scrollToBottom);
} catch {}
};
es.onerror = () => {
stopEventStream();
setTimeout(() => { if (isOpen.value && threadId.value) startEventStream(); else startPolling(); }, esBackoff);
esBackoff = Math.min(esBackoff * 2, 15000);
};
es.onopen = () => { esBackoff = 1000; if (pollTimer) stopPolling(); };
} catch { startPolling(); }
}
function stopEventStream(){ try { if (es) { es.close(); } } catch {} es = null; }
async function stopAi(){
try {
const res = await csrfFetch('/api/support/stop', { method: 'POST', headers: { 'Accept': 'application/json' } });
const json = await res.json().catch(() => ({}));
if (res.ok) { mapFromServer(json); nextTick(scrollToBottom); }
} catch {}
}
async function handoff(){
try {
const res = await csrfFetch('/api/support/handoff', { method: 'POST', headers: { 'Accept': 'application/json' } });
const json = await res.json().catch(() => ({}));
if (res.ok) { mapFromServer(json.state || json); nextTick(scrollToBottom); }
} catch {}
}
function formatTime(isoString: string) {
return new Date(isoString).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
onMounted(() => {
readUiState();
nextTick(() => { if (window.lucide) window.lucide.createIcons(); });
document.addEventListener('hide-support-chat', _hideSupport);
document.addEventListener('show-support-chat', _showSupport);
});
const _hideSupport = () => { hiddenByGame.value = true; };
const _showSupport = () => { hiddenByGame.value = false; };
onUnmounted(() => {
stopPolling();
stopEventStream();
document.removeEventListener('hide-support-chat', _hideSupport);
document.removeEventListener('show-support-chat', _showSupport);
});
</script>
<template>
<div class="sc-wrap" v-show="!hiddenByGame" aria-live="polite" :style="{ right: dragPos.right + 'px', bottom: dragPos.bottom + 'px' }">
<audio ref="notificationSound" src="/sounds/notification.mp3" preload="auto"></audio>
<button
v-if="!isDismissed && !isOpen"
class="sc-fab"
:class="{ dragging: dragging }"
title="Support Chat öffnen (ziehen zum Verschieben)"
@mousedown="onDragStart"
@touchstart.passive="onDragStart"
@click="!wasDragged && toggle()"
>
<i data-lucide="message-circle"></i>
<span class="sc-fab-pulse"></span>
</button>
<div v-show="isOpen && !isDismissed" class="sc-panel" role="dialog" aria-modal="false" aria-label="Support chat" @keydown.esc="isOpen=false">
<header class="sc-head" @mousedown="onDragStart" @touchstart="onDragStart">
<div class="drag-handle" title="Verschieben">
<i data-lucide="grip-horizontal"></i>
</div>
<div class="title">
<div class="avatar-wrapper">
<img src="https://ui-avatars.com/api/?name=Support&background=00f2ff&color=000" alt="Support" class="avatar-img" />
<span class="status-dot"></span>
</div>
<div class="info">
<span class="name">Kundensupport</span>
<span class="status-text">Online</span>
</div>
</div>
<div class="actions">
<button class="icon" title="Minimieren" @click.stop="isOpen=false"><i data-lucide="chevron-down"></i></button>
<button class="icon close-btn" title="Chat beenden" @click.stop="requestCloseChat"><i data-lucide="power"></i></button>
<button class="icon" title="Ausblenden" @click.stop="dismiss"><i data-lucide="x"></i></button>
</div>
</header>
<div id="support-chat-list" class="sc-list">
<div v-if="!threadId" class="empty-state">
<div class="welcome-msg">
<h3>Willkommen, {{ user.name || 'Gast' }}! 👋</h3>
<p>Wie können wir dir heute helfen? Wähle ein Thema:</p>
</div>
<div class="topics">
<button
v-for="t in topics"
:key="t"
class="topic-btn"
:disabled="loading"
@click.stop.prevent="ensureStarted(t)"
>
<span v-if="loading !== true">{{ t }}</span>
<span v-else>Lade...</span>
<i data-lucide="chevron-right"></i>
</button>
</div>
</div>
<div v-else v-for="m in messages" :key="m.id" class="message-row" :class="m.sender">
<div class="message-avatar" v-if="m.sender !== 'user'">
<img v-if="m.sender === 'ai'" src="https://ui-avatars.com/api/?name=AI&background=00f2ff&color=000" alt="AI" />
<img v-else src="https://ui-avatars.com/api/?name=S&background=333&color=fff" alt="Support" />
</div>
<div class="message-content">
<div class="bubble" :class="m.sender">
<div class="text">{{ m.body }}</div>
<span v-if="m.sender === 'ai' && loading" class="typing-cursor"></span>
</div>
<div class="message-meta">
<span class="time">{{ formatTime(m.at) }}</span>
</div>
</div>
<div class="message-avatar" v-if="m.sender === 'user'">
<img :src="getUserAvatar(user) || getAvatarFallback(user.name, 'ff007a')" :alt="user.name" />
</div>
</div>
</div>
<footer class="sc-compose" v-if="threadId && status !== 'closed'">
<div class="input-wrapper">
<textarea class="input" rows="1" placeholder="Schreibe eine Nachricht..." v-model="input" @keydown.enter.exact.prevent="send"></textarea>
<button class="send-btn" :disabled="!input.trim() || sending" @click.stop.prevent="send"><i data-lucide="send"></i></button>
</div>
<div class="quick-actions" v-if="status==='ai' || status==='stopped'">
<button class="action-btn stop" v-if="status==='ai'" title="KI stoppen" @click="stopAi"><i data-lucide="stop-circle"></i> KI Stoppen</button>
<button class="action-btn handoff" v-if="status==='stopped'" title="Mitarbeiter hinzuziehen" @click="handoff"><i data-lucide="users"></i> Mitarbeiter anfordern</button>
</div>
</footer>
</div>
<div v-if="showCloseConfirm" class="confirm-overlay">
<div class="confirm-dialog">
<h4>Chat beenden?</h4>
<p>Möchtest du diesen Chat wirklich beenden? Du kannst jederzeit einen neuen starten.</p>
<div class="confirm-actions">
<button class="btn-secondary" @click="showCloseConfirm = false">Abbrechen</button>
<button class="btn-danger" @click="confirmClose">Ja, beenden</button>
</div>
</div>
</div>
<button v-if="isDismissed" class="sc-restore" title="Chat wiederherstellen" @click="restore">
<i data-lucide="message-circle"></i>
</button>
</div>
</template>
<style scoped>
/* --- Base Styles --- */
.sc-wrap {
position: fixed; z-index: 2147483647;
font-family: 'Inter', sans-serif;
}
:deep(svg) { pointer-events: none; }
button, input, textarea { pointer-events: auto; }
/* --- FAB Button --- */
.sc-fab {
position: relative;
width: 60px;
height: 60px;
border-radius: 50%;
background: linear-gradient(135deg, var(--primary, #df006a), #9b0052);
border: none;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
cursor: grab;
box-shadow: 0 4px 24px rgba(223,0,106,0.5), 0 0 0 0 rgba(223,0,106,0.4);
transition: transform 0.2s ease, box-shadow 0.2s ease;
animation: fab-enter 0.3s cubic-bezier(0.16, 1, 0.3, 1);
user-select: none;
}
.sc-fab:hover {
transform: scale(1.08);
box-shadow: 0 6px 30px rgba(223,0,106,0.7);
}
.sc-fab.dragging {
cursor: grabbing;
transform: scale(1.12);
box-shadow: 0 8px 36px rgba(223,0,106,0.8);
transition: none;
}
.sc-fab i { width: 26px; height: 26px; }
/* Pulsing dot on FAB */
.sc-fab-pulse {
position: absolute;
top: 4px;
right: 4px;
width: 12px;
height: 12px;
border-radius: 50%;
background: #00f2ff;
border: 2px solid #111;
animation: sc-pulse 2s ease-in-out infinite;
}
@keyframes sc-pulse {
0%, 100% { transform: scale(1); box-shadow: 0 0 0 0 rgba(0,242,255,0.7); }
50% { transform: scale(1.1); box-shadow: 0 0 0 4px rgba(0,242,255,0); }
}
@keyframes fab-enter {
from { opacity: 0; transform: scale(0.6) translateY(20px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
/* --- Restore Button --- */
.sc-restore {
width: 56px;
height: 56px;
border-radius: 50%;
background: var(--primary, #df006a);
border: none;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
cursor: pointer;
box-shadow: 0 0 20px rgba(223,0,106,0.5);
transition: transform .2s;
}
.sc-restore:hover {
transform: scale(1.05);
}
/* --- Panel & Header --- */
.sc-panel {
width: 400px; height: 600px; max-height: 80vh;
background: #0a0a0a; border: 1px solid #1f1f1f; border-radius: 16px;
overflow: hidden; box-shadow: 0 20px 60px rgba(0,0,0,0.8);
display: flex; flex-direction: column;
animation: slideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1);
isolation: isolate;
}
.sc-head {
background: #111; padding: 12px 16px; display: flex;
justify-content: space-between; align-items: center;
border-bottom: 1px solid #1f1f1f; flex-shrink: 0;
cursor: grab; user-select: none;
}
.sc-head:active { cursor: grabbing; }
.drag-handle { color: #444; display: flex; align-items: center; margin-right: 8px; flex-shrink: 0; }
.drag-handle i { width: 16px; height: 16px; }
.title { display: flex; align-items: center; gap: 12px; }
.avatar-wrapper { position: relative; }
.avatar-img { width: 40px; height: 40px; border-radius: 50%; object-fit: cover; border: 2px solid #222; }
.status-dot { position: absolute; bottom: 0; right: 0; width: 10px; height: 10px; background: #00f2ff; border-radius: 50%; border: 2px solid #111; box-shadow: 0 0 5px #00f2ff; }
.info { display: flex; flex-direction: column; }
.info .name { color: #fff; font-weight: 700; font-size: 15px; }
.info .status-text { color: #00f2ff; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; }
.actions { display: flex; gap: 8px; }
.icon { background: transparent; border: none; color: #666; cursor: pointer; padding: 6px; border-radius: 8px; transition: 0.2s; display: grid; place-items: center; }
.icon:hover { background: rgba(255,255,255,0.1); color: #fff; }
.close-btn:hover { color: #ff3e3e; background: rgba(255, 62, 62, 0.1); }
/* --- Chat List & Messages --- */
.sc-list { flex: 1; padding: 20px; overflow-y: auto; background: #050505; display: flex; flex-direction: column; gap: 16px; }
.message-row { display: flex; gap: 12px; align-items: flex-end; max-width: 85%; }
.message-row.user { align-self: flex-end; flex-direction: row-reverse; }
.message-row.support, .message-row.ai, .message-row.system, .message-row.agent { align-self: flex-start; }
.message-row.system { max-width: 100%; justify-content: center; }
/* FIX: Avatar size and fit */
.message-avatar { flex-shrink: 0; }
.message-avatar img { width: 32px; height: 32px; border-radius: 50%; object-fit: cover; border: 1px solid #222; display: block; }
.message-content { display: flex; flex-direction: column; }
.bubble { padding: 12px 16px; border-radius: 16px; font-size: 13px; line-height: 1.5; position: relative; box-shadow: 0 2px 5px rgba(0,0,0,0.2); }
.user .bubble { background: #ff007a; color: #fff; border-bottom-right-radius: 2px; }
.support .bubble, .ai .bubble { background: #1a1a1a; color: #eee; border: 1px solid #222; border-bottom-left-radius: 2px; }
.agent .bubble { background: rgba(0, 242, 255, 0.1); color: #fff; border: 1px solid rgba(0, 242, 255, 0.3); border-bottom-left-radius: 2px; }
.system .bubble { background: transparent; color: #666; font-style: italic; font-size: 12px; text-align: center; box-shadow: none; padding: 4px; }
.message-meta { text-align: right; font-size: 10px; color: #666; margin-top: 4px; font-weight: 600; }
/* --- Typing Effect --- */
.typing-cursor {
display: inline-block; width: 6px; height: 12px; background-color: #00f2ff;
animation: blink 1s infinite; margin-left: 2px; vertical-align: middle;
}
@keyframes blink { 50% { opacity: 0; } }
/* --- Empty State & Topics --- */
.empty-state { text-align: center; margin: auto 0; padding: 20px; }
.welcome-msg h3 { color: #fff; font-size: 18px; font-weight: 700; margin-bottom: 8px; }
.welcome-msg p { color: #888; font-size: 14px; margin-bottom: 24px; }
.topics { display: flex; flex-direction: column; gap: 10px; }
.topic-btn {
background: #1a1a1a; border: 1px solid #222; color: #ccc; padding: 14px 16px;
border-radius: 12px; cursor: pointer; display: flex; justify-content: space-between;
align-items: center; transition: all 0.2s; font-size: 14px; font-weight: 600;
}
.topic-btn:hover { background: #222; border-color: #333; color: #fff; transform: translateX(2px); }
.topic-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
/* --- Footer & Composer --- */
.sc-compose { background: #111; padding: 16px; border-top: 1px solid #1f1f1f; }
.input-wrapper { display: flex; gap: 10px; background: #0a0a0a; padding: 6px; border-radius: 24px; border: 1px solid #222; align-items: flex-end; transition: border-color 0.2s; }
.input-wrapper:focus-within { border-color: #ff007a; }
.input { flex: 1; background: transparent; border: none; color: #fff; padding: 10px 14px; resize: none; max-height: 100px; outline: none; font-size: 14px; }
.send-btn {
width: 40px; height: 40px; border-radius: 50%;
background: #222; color: #666;
border: none; display: grid; place-items: center;
cursor: not-allowed; transition: all 0.2s;
}
.send-btn:not(:disabled) { background: #ff007a; color: #fff; box-shadow: 0 0 15px rgba(255,0,122,0.4); }
.send-btn:not(:disabled):hover { background: #d60068; transform: scale(1.05); }
/* --- Quick Actions --- */
.quick-actions { display: flex; gap: 10px; margin-top: 12px; justify-content: center; }
.action-btn {
display: flex; align-items: center; gap: 6px; padding: 8px 14px; border-radius: 20px;
font-size: 11px; font-weight: 600; cursor: pointer; border: 1px solid; background: transparent; transition: 0.2s;
}
.action-btn.stop { color: #ff3e3e; border-color: rgba(255, 62, 62, 0.3); background: rgba(255, 62, 62, 0.05); }
.action-btn.stop:hover { background: rgba(255, 62, 62, 0.15); border-color: #ff3e3e; }
.action-btn.handoff { color: #00f2ff; border-color: rgba(0, 242, 255, 0.3); background: rgba(0, 242, 255, 0.05); }
.action-btn.handoff:hover { background: rgba(0, 242, 255, 0.15); border-color: #00f2ff; }
/* --- Confirmation Dialog --- */
.confirm-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.8); backdrop-filter: blur(4px); display: grid; place-items: center; z-index: 2147483648; animation: fadeIn 0.2s; }
.confirm-dialog { background: #1a1a1a; padding: 24px; border-radius: 20px; width: 90%; max-width: 320px; text-align: center; box-shadow: 0 20px 50px rgba(0,0,0,0.6); border: 1px solid #333; }
.confirm-dialog h4 { color: #fff; font-size: 18px; margin: 0 0 10px; font-weight: 700; }
.confirm-dialog p { color: #888; font-size: 14px; margin: 0 0 24px; line-height: 1.5; }
.confirm-actions { display: flex; justify-content: center; gap: 12px; }
.confirm-actions button { padding: 10px 20px; border-radius: 10px; border: none; font-weight: 600; cursor: pointer; transition: all 0.2s; font-size: 13px; }
.btn-danger { background: #ff3e3e; color: #fff; }
.btn-danger:hover { background: #d43434; box-shadow: 0 0 15px rgba(255, 62, 62, 0.4); }
.btn-secondary { background: #222; color: #ccc; border: 1px solid #333; }
.btn-secondary:hover { background: #333; color: #fff; }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
</style>

View File

@@ -0,0 +1,126 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import { router } from '@inertiajs/vue3';
const isLoading = ref(false);
const progress = ref(0);
let progressTimer: number | undefined;
let hideTimer: number | undefined;
function startLoading() {
clearTimeout(hideTimer);
clearInterval(progressTimer);
isLoading.value = true;
progress.value = 5;
// Simulate progress ticking up to ~85% while waiting
progressTimer = window.setInterval(() => {
if (progress.value < 85) {
progress.value += Math.random() * 8;
if (progress.value > 85) progress.value = 85;
}
}, 200);
}
function stopLoading() {
clearInterval(progressTimer);
progress.value = 100;
hideTimer = window.setTimeout(() => {
isLoading.value = false;
progress.value = 0;
}, 350);
}
let offStart: (() => void) | undefined;
let offFinish: (() => void) | undefined;
onMounted(() => {
offStart = router.on('start', startLoading);
offFinish = router.on('finish', stopLoading);
});
onUnmounted(() => {
offStart?.();
offFinish?.();
clearTimeout(hideTimer);
clearInterval(progressTimer);
});
</script>
<template>
<transition name="app-loading-fade">
<div v-if="isLoading" class="app-loading-overlay" aria-hidden="true">
<div class="al-bar" :style="{ width: progress + '%' }"></div>
<div class="al-spinner">
<div class="al-ring"></div>
<div class="al-brand">Beti<span>X</span></div>
</div>
</div>
</transition>
</template>
<style scoped>
.app-loading-overlay {
position: fixed;
inset: 0;
z-index: 9999;
background: rgba(5, 5, 5, 0.75);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.al-bar {
position: absolute;
top: 0;
left: 0;
height: 3px;
background: linear-gradient(90deg, var(--primary, #df006a), #ff8800);
border-radius: 0 2px 2px 0;
transition: width 0.2s ease;
box-shadow: 0 0 12px var(--primary, #df006a);
}
.al-spinner {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.al-ring {
width: 52px;
height: 52px;
border: 3px solid rgba(255, 255, 255, 0.08);
border-top-color: var(--primary, #df006a);
border-radius: 50%;
animation: al-spin 0.75s linear infinite;
}
.al-brand {
font-size: 1.3rem;
font-weight: 900;
color: #fff;
letter-spacing: 0.02em;
}
.al-brand span {
color: var(--primary, #df006a);
}
@keyframes al-spin {
to { transform: rotate(360deg); }
}
/* Fade transition */
.app-loading-fade-enter-active,
.app-loading-fade-leave-active {
transition: opacity 0.25s ease;
}
.app-loading-fade-enter-from,
.app-loading-fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,104 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
const props = defineProps<{
isOpen: boolean;
title: string;
message: string;
confirmText?: string;
cancelText?: string;
requireHold?: boolean; // If true, user must hold button for 3s
}>();
const emit = defineEmits(['close', 'confirm']);
const holdProgress = ref(0);
let holdInterval: number | undefined;
const startHold = () => {
if (!props.requireHold) return;
holdProgress.value = 0;
holdInterval = window.setInterval(() => {
holdProgress.value += 2; // 50 * 2 = 100% in ~2.5s (adjusted for UX)
if (holdProgress.value >= 100) {
stopHold();
emit('confirm');
}
}, 50); // Update every 50ms
};
const stopHold = () => {
if (holdInterval) {
clearInterval(holdInterval);
holdInterval = undefined;
}
if (holdProgress.value < 100) {
holdProgress.value = 0;
}
};
const onConfirmClick = () => {
if (!props.requireHold) {
emit('confirm');
}
};
watch(() => props.isOpen, (val) => {
if (!val) {
stopHold();
holdProgress.value = 0;
}
});
</script>
<template>
<transition name="fade">
<div v-if="isOpen" class="modal-overlay">
<div class="modal-card">
<div class="modal-head">{{ title }}</div>
<div class="modal-body">{{ message }}</div>
<div class="modal-actions">
<button class="btn-cancel" @click="$emit('close')">{{ cancelText || 'Cancel' }}</button>
<button
class="btn-confirm"
:class="{ 'holding': requireHold }"
@mousedown="startHold"
@mouseup="stopHold"
@mouseleave="stopHold"
@touchstart.prevent="startHold"
@touchend.prevent="stopHold"
@click="onConfirmClick"
>
<div v-if="requireHold" class="hold-fill" :style="{ width: `${holdProgress}%` }"></div>
<span class="btn-text">{{ confirmText || 'Confirm' }}</span>
<span v-if="requireHold && holdProgress > 0" class="hold-hint">Hold...</span>
</button>
</div>
</div>
</div>
</transition>
</template>
<style scoped>
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.8); backdrop-filter: blur(5px); z-index: 9999; display: flex; align-items: center; justify-content: center; }
.modal-card { background: #0a0a0a; border: 1px solid #222; border-radius: 16px; padding: 24px; width: 90%; max-width: 400px; box-shadow: 0 20px 50px rgba(0,0,0,0.8); animation: popIn 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); }
.modal-head { font-size: 16px; font-weight: 900; color: #fff; margin-bottom: 10px; text-transform: uppercase; letter-spacing: 1px; }
.modal-body { font-size: 13px; color: #bbb; margin-bottom: 24px; line-height: 1.5; }
.modal-actions { display: flex; gap: 12px; justify-content: flex-end; }
.btn-cancel { background: transparent; border: 1px solid #333; color: #888; padding: 10px 16px; border-radius: 8px; font-weight: 700; cursor: pointer; transition: 0.2s; }
.btn-cancel:hover { color: #fff; border-color: #555; }
.btn-confirm { background: #ff007a; border: none; color: #fff; padding: 10px 20px; border-radius: 8px; font-weight: 900; cursor: pointer; position: relative; overflow: hidden; transition: 0.2s; }
.btn-confirm:hover { box-shadow: 0 0 15px rgba(255,0,122,0.4); }
.btn-confirm.holding { background: #33001a; border: 1px solid #ff007a; }
.hold-fill { position: absolute; top: 0; left: 0; height: 100%; background: #ff007a; transition: width 0.05s linear; z-index: 0; }
.btn-text { position: relative; z-index: 1; }
.hold-hint { position: absolute; right: 10px; font-size: 9px; opacity: 0.7; z-index: 1; top: 50%; transform: translateY(-50%); }
@keyframes popIn { from { opacity: 0; transform: scale(0.9); } to { opacity: 1; transform: scale(1); } }
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>

View File

@@ -0,0 +1,142 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { ChevronDown, Search, Check } from 'lucide-vue-next';
const props = defineProps<{
modelValue: string;
placeholder?: string;
error?: boolean;
}>();
const emit = defineEmits(['update:modelValue']);
const isOpen = ref(false);
const searchQuery = ref('');
const containerRef = ref<HTMLElement | null>(null);
// Full Country List with Codes
const countries = [
{ code: 'DE', name: 'Germany' }, { code: 'AT', name: 'Austria' }, { code: 'CH', name: 'Switzerland' },
{ code: 'US', name: 'United States' }, { code: 'GB', name: 'United Kingdom' }, { code: 'FR', name: 'France' },
{ code: 'IT', name: 'Italy' }, { code: 'ES', name: 'Spain' }, { code: 'NL', name: 'Netherlands' },
{ code: 'BE', name: 'Belgium' }, { code: 'PL', name: 'Poland' }, { code: 'CZ', name: 'Czech Republic' },
{ code: 'DK', name: 'Denmark' }, { code: 'SE', name: 'Sweden' }, { code: 'NO', name: 'Norway' },
{ code: 'FI', name: 'Finland' }, { code: 'PT', name: 'Portugal' }, { code: 'GR', name: 'Greece' },
{ code: 'TR', name: 'Turkey' }, { code: 'RU', name: 'Russia' }, { code: 'UA', name: 'Ukraine' },
{ code: 'CA', name: 'Canada' }, { code: 'AU', name: 'Australia' }, { code: 'JP', name: 'Japan' },
{ code: 'CN', name: 'China' }, { code: 'BR', name: 'Brazil' }, { code: 'MX', name: 'Mexico' },
{ code: 'AR', name: 'Argentina' }, { code: 'IN', name: 'India' }, { code: 'ZA', name: 'South Africa' },
{ code: 'AE', name: 'United Arab Emirates' }, { code: 'KR', name: 'South Korea' }, { code: 'SG', name: 'Singapore' }
];
const filteredCountries = computed(() => {
if (!searchQuery.value) return countries;
return countries.filter(c => c.name.toLowerCase().includes(searchQuery.value.toLowerCase()));
});
const selectedCountry = computed(() => {
return countries.find(c => c.code === props.modelValue);
});
const toggle = () => isOpen.value = !isOpen.value;
const select = (code: string) => {
emit('update:modelValue', code);
isOpen.value = false;
searchQuery.value = '';
};
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.value && !containerRef.value.contains(e.target as Node)) {
isOpen.value = false;
}
};
onMounted(() => document.addEventListener('click', handleClickOutside));
onUnmounted(() => document.removeEventListener('click', handleClickOutside));
</script>
<template>
<div class="relative" ref="containerRef">
<!-- Trigger Button -->
<div
@click="toggle"
class="flex items-center justify-between h-10 w-full rounded-md border bg-[#0a0a0a] px-3 py-2 text-sm cursor-pointer transition-all duration-200"
:class="[
error ? 'border-red-500' : isOpen ? 'border-[#00f2ff] ring-1 ring-[#00f2ff]' : 'border-[#151515] hover:border-[#333]'
]"
>
<div class="flex items-center gap-2" v-if="selectedCountry">
<img :src="`https://flagcdn.com/w20/${selectedCountry.code.toLowerCase()}.png`" class="w-5 h-3.5 object-cover rounded-sm" />
<span class="text-white font-medium">{{ selectedCountry.name }}</span>
</div>
<span v-else class="text-[#888]">{{ placeholder || 'Select Country' }}</span>
<ChevronDown class="w-4 h-4 text-[#666] transition-transform duration-200" :class="{ 'rotate-180': isOpen }" />
</div>
<!-- Dropdown Menu -->
<transition name="dropdown">
<div v-if="isOpen" class="absolute z-50 mt-2 w-full rounded-md border border-[#151515] bg-[#0a0a0a] shadow-[0_10px_40px_rgba(0,0,0,0.8)] overflow-hidden">
<!-- Search -->
<div class="p-2 border-b border-[#151515]">
<div class="relative">
<Search class="absolute left-2 top-2.5 w-3.5 h-3.5 text-[#666]" />
<input
v-model="searchQuery"
type="text"
placeholder="Search..."
class="w-full bg-[#111] border border-[#222] rounded-md py-1.5 pl-8 pr-3 text-xs text-white focus:outline-none focus:border-[#00f2ff] transition-colors"
autofocus
/>
</div>
</div>
<!-- List -->
<div class="max-h-60 overflow-y-auto custom-scrollbar">
<div
v-for="country in filteredCountries"
:key="country.code"
@click="select(country.code)"
class="flex items-center justify-between px-3 py-2 cursor-pointer hover:bg-[#151515] transition-colors group"
:class="{ 'bg-[#151515]': modelValue === country.code }"
>
<div class="flex items-center gap-3">
<img :src="`https://flagcdn.com/w20/${country.code.toLowerCase()}.png`" class="w-5 h-3.5 object-cover rounded-sm opacity-80 group-hover:opacity-100 transition-opacity" />
<span class="text-sm text-[#ccc] group-hover:text-white transition-colors">{{ country.name }}</span>
</div>
<Check v-if="modelValue === country.code" class="w-3.5 h-3.5 text-[#00f2ff]" />
</div>
<div v-if="filteredCountries.length === 0" class="px-3 py-4 text-center text-xs text-[#666]">
No country found.
</div>
</div>
</div>
</transition>
</div>
</template>
<style scoped>
.dropdown-enter-active,
.dropdown-leave-active {
transition: all 0.2s ease;
}
.dropdown-enter-from,
.dropdown-leave-to {
opacity: 0;
transform: translateY(-10px);
}
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: #0a0a0a;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #333;
border-radius: 2px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #555;
}
</style>

View File

@@ -0,0 +1,72 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
const props = defineProps<{
modelValue: string; // ISO Date String (YYYY-MM-DD)
error?: boolean;
}>();
const emit = defineEmits(['update:modelValue']);
// Internal state for raw input
const inputValue = ref('');
// Format: YYYY-MM-DD -> DD.MM.YYYY for display
const formatDate = (iso: string) => {
if (!iso) return '';
const [y, m, d] = iso.split('-');
return `${d}.${m}.${y}`;
};
// Initialize
watch(() => props.modelValue, (val) => {
// Only update if the formatted value is different to avoid cursor jumping
const formatted = formatDate(val);
if (inputValue.value !== formatted && val.length === 10) {
inputValue.value = formatted;
}
}, { immediate: true });
const onInput = (e: Event) => {
let val = (e.target as HTMLInputElement).value.replace(/\D/g, ''); // Remove non-digits
// Auto-insert dots
if (val.length > 2) val = val.slice(0, 2) + '.' + val.slice(2);
if (val.length > 5) val = val.slice(0, 5) + '.' + val.slice(5);
if (val.length > 10) val = val.slice(0, 10);
inputValue.value = val;
// Only emit if full date is entered
if (val.length === 10) {
const [d, m, y] = val.split('.');
// Basic validation
const day = parseInt(d);
const month = parseInt(m);
const year = parseInt(y);
if (day > 0 && day <= 31 && month > 0 && month <= 12 && year > 1900 && year < 2100) {
emit('update:modelValue', `${y}-${m}-${d}`);
}
} else if (val.length === 0) {
emit('update:modelValue', '');
}
};
</script>
<template>
<div class="relative w-full">
<input
type="text"
:value="inputValue"
@input="onInput"
placeholder="DD.MM.YYYY"
class="flex h-10 w-full rounded-md border bg-[#0a0a0a] px-3 py-2 text-sm text-white shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[#00f2ff] disabled:cursor-not-allowed disabled:opacity-50 placeholder:text-[#444]"
:class="error ? 'border-red-500' : 'border-[#151515] hover:border-[#333]'"
maxlength="10"
/>
<div class="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none text-[#666]">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="4" rx="2" ry="2"/><line x1="16" x2="16" y1="2" y2="6"/><line x1="8" x2="8" y1="2" y2="6"/><line x1="3" x2="21" y1="10" y2="10"/></svg>
</div>
</div>
</template>

View File

@@ -0,0 +1,258 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
</script>
<template>
<footer class="main-footer">
<div class="bg-grid"></div>
<div class="footer-content">
<div class="footer-top">
<div class="footer-section about">
<h3 class="footer-title">Beti<span class="highlight">X</span></h3>
<p>The ultimate crypto gaming protocol. Experience fair play, instant withdrawals, and exclusive rewards. Join the revolution of decentralized gambling.</p>
</div>
<div class="footer-section links">
<h3 class="footer-title">Quick Links</h3>
<Link href="/vip-levels">VIP Club</Link>
<Link href="/bonuses">Promotions</Link>
<Link href="/guilds">Guilds</Link>
<Link href="/trophy">Trophy Room</Link>
</div>
<div class="footer-section links">
<h3 class="footer-title">Help & Support</h3>
<Link href="/faq">Help Center</Link>
<Link href="/self-exclusion">Responsible Gaming</Link>
<Link href="/legal/aml">Fairness & AML</Link>
<Link href="/legal/disputes">Disputes</Link>
</div>
<div class="footer-section links">
<h3 class="footer-title">Legal</h3>
<Link href="/legal/terms">{{ $t('footer.legal.terms') }}</Link>
<Link href="/legal/cookies">{{ $t('footer.legal.cookies') }}</Link>
<Link href="/legal/privacy">{{ $t('footer.legal.privacy') }}</Link>
<Link href="/legal/bonus-policy">{{ $t('footer.legal.bonusPolicy') }}</Link>
<Link href="/legal/disputes">{{ $t('footer.legal.disputes') }}</Link>
<Link href="/legal/responsible-gaming">{{ $t('footer.legal.responsible') }}</Link>
<Link href="/legal/aml">{{ $t('footer.legal.aml') }}</Link>
<Link href="/legal/risk-warnings">{{ $t('footer.legal.risks') }}</Link>
</div>
</div>
<div class="footer-middle">
<h3 class="footer-title text-center">Our Partners & Providers</h3>
<div class="partner-logos">
<div class="logo-item">Pragmatic Play</div>
<div class="logo-item">Nolimit City</div>
<div class="logo-item">Hacksaw</div>
<div class="logo-item">Push Gaming</div>
<div class="logo-item">Evolution</div>
<div class="logo-item">Play'n GO</div>
</div>
<div class="crypto-logos">
<span class="crypto-icon"></span>
<span class="crypto-icon">Ξ</span>
<span class="crypto-icon">Ł</span>
<span class="crypto-icon"></span>
</div>
</div>
<div class="footer-bottom">
<div class="licenses">
<div class="license-badge">18+</div>
<div class="license-badge">GC</div>
<div class="license-badge">RNG Certified</div>
<div class="license-text">
Betix.io is operated by Betix Group N.V., registered under No. 123456, Curacao. Licensed and regulated by the Government of Curacao.
<br>Gambling can be addictive. Please play responsibly. <a href="#" class="text-link">BeGambleAware.org</a>
</div>
</div>
<div class="copyright">
&copy; {{ new Date().getFullYear() }} Betix Protocol. All rights reserved.
</div>
</div>
</div>
</footer>
</template>
<style scoped>
.main-footer {
background: #050505;
border-top: 1px solid #151515;
padding: 60px 30px 30px;
margin-top: 80px;
font-size: 13px;
color: #888;
position: relative;
overflow: hidden;
}
.bg-grid {
position: absolute;
inset: 0;
background-image: linear-gradient(rgba(255, 255, 255, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.03) 1px, transparent 1px);
background-size: 50px 50px;
mask-image: radial-gradient(circle at center, black 40%, transparent 80%);
pointer-events: none;
}
.footer-content {
max-width: 1400px;
margin: 0 auto;
position: relative;
z-index: 1;
}
.footer-top {
display: grid;
grid-template-columns: 2fr 1fr 1fr 1fr;
gap: 40px;
margin-bottom: 60px;
}
.footer-title {
font-size: 14px;
font-weight: 900;
color: #fff;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 20px;
}
.highlight { color: #ff007a; }
.footer-section p {
line-height: 1.6;
max-width: 300px;
}
.footer-section a {
display: block;
color: #888;
text-decoration: none;
margin-bottom: 10px;
transition: all 0.2s;
}
.footer-section a:hover {
color: #00f2ff;
transform: translateX(5px);
}
.footer-middle {
border-top: 1px solid #151515;
border-bottom: 1px solid #151515;
padding: 40px 0;
margin-bottom: 40px;
text-align: center;
}
.partner-logos {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 30px;
margin-bottom: 30px;
}
.logo-item {
font-weight: 800;
text-transform: uppercase;
font-size: 16px;
color: #555;
cursor: pointer;
transition: all 0.3s;
}
.logo-item:hover {
color: #fff;
transform: scale(1.1);
text-shadow: 0 0 10px rgba(255,255,255,0.5);
}
.crypto-logos {
display: flex;
justify-content: center;
gap: 20px;
}
.crypto-icon {
font-size: 20px;
color: #fff;
opacity: 0.5;
transition: all 0.3s;
cursor: pointer;
}
.crypto-icon:hover {
opacity: 1;
transform: translateY(-3px);
text-shadow: 0 0 10px #fff;
}
.footer-bottom {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
text-align: center;
}
.licenses {
display: flex;
flex-direction: column;
align-items: center;
gap: 15px;
}
.license-badge {
display: inline-block;
border: 1px solid #333;
padding: 5px 10px;
border-radius: 4px;
font-size: 10px;
font-weight: 900;
color: #666;
margin: 0 5px;
transition: all 0.3s;
cursor: default;
}
.license-badge:hover {
border-color: #666;
color: #fff;
}
.license-text {
font-size: 11px;
color: #555;
line-height: 1.5;
max-width: 600px;
}
.text-link {
color: #888;
text-decoration: underline;
}
.copyright {
font-size: 11px;
color: #333;
}
@media (max-width: 1000px) {
.footer-top {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 600px) {
.footer-top {
grid-template-columns: 1fr;
text-align: center;
}
.footer-section p {
margin: 0 auto 20px;
}
}
</style>

View File

@@ -0,0 +1,67 @@
<script setup>
import { ref, nextTick, onMounted, watch } from 'vue';
const props = defineProps({
toasts: {
type: Array,
required: true
}
});
const emit = defineEmits(['close']);
const closeToast = (id) => {
emit('close', id);
};
// Re-run lucide icons when toasts change
watch(() => props.toasts, () => {
nextTick(() => {
if (window.lucide) window.lucide.createIcons();
});
}, { deep: true });
onMounted(() => {
nextTick(() => {
if (window.lucide) window.lucide.createIcons();
});
});
</script>
<template>
<teleport to="body">
<div id="notif-container">
<div v-for="toast in toasts" :key="toast.id"
:class="['toast', toast.type, { active: toast.active, 'fly-out': toast.flyingOut }]"
@click="closeToast(toast.id)">
<div class="toast-icon"><i :data-lucide="toast.icon"></i></div>
<div style="flex:1">
<div class="toast-title">{{ toast.title }}</div>
<div class="toast-desc">{{ toast.desc }}</div>
</div>
<div class="toast-progress" :style="{ transform: `scaleX(${toast.progress/100})` }"></div>
</div>
</div>
</teleport>
</template>
<style scoped>
#notif-container { position: fixed; top: 85px; right: 20px; display: flex; flex-direction: column; gap: 12px; z-index: 5000; pointer-events: auto; backdrop-filter: none; }
.toast { background: rgba(13,13,13,0.95); border: 1px solid #222; padding: 14px 20px; border-radius: 12px; display: flex; align-items: center; gap: 14px; width: 300px; transform: translateX(120%); transition: 0.4s cubic-bezier(0.2, 0, 0, 1); box-shadow: 0 10px 40px rgba(0,0,0,0.9); position: relative; overflow: hidden; cursor: pointer; backdrop-filter: none; }
.toast.active { transform: translateX(0); }
.toast.fly-out { transform: translate(100px, -200px) scale(0); opacity: 0; }
.toast-icon { padding: 8px; border-radius: 10px; color: white; display: flex; align-items: center; justify-content: center; }
.toast-progress { position: absolute; bottom: 0; left: 0; height: 3px; background: rgba(255,255,255,0.1); width: 100%; transform-origin: left; }
.toast:hover { transform: scale(1.05) translateX(-5px); z-index: 1001; }
.toast.green { border-left: 4px solid var(--green); }
.toast.green .toast-icon { background: rgba(0,255,157,0.1); color: var(--green); }
.toast.magenta { border-left: 4px solid var(--magenta); }
.toast.magenta .toast-icon { background: rgba(255,0,122,0.1); color: var(--magenta); }
.toast-title { font-size: 11px; font-weight: 900; color: #fff; letter-spacing: 0.5px; margin-bottom: 2px; text-transform: uppercase; }
.toast-desc { font-size: 11px; color: #bbb; font-weight: 500; }
:global(:root) {
--green: #00ff9d;
--magenta: #ff007a;
}
</style>

View File

@@ -0,0 +1,589 @@
<script setup lang="ts">
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue';
import { Link } from '@inertiajs/vue3';
import {
Search, X, Clock, Gamepad2, AtSign, Layers,
Play, ChevronRight, Loader2, SearchX, UserX, Sparkles
} from 'lucide-vue-next';
const emit = defineEmits<{ (e: 'close'): void }>();
const query = ref('');
const inputRef = ref<HTMLInputElement | null>(null);
const gameResults = ref<any[]>([]);
const userResults = ref<any[]>([]);
const providerResults = ref<any[]>([]);
const allGames = ref<any[]>([]);
const loading = ref(false);
const mode = computed(() => {
const q = query.value;
if (q.startsWith('@')) return 'users';
if (q.toLowerCase().startsWith('p:') || q.toLowerCase().startsWith('p ')) return 'providers';
return 'games';
});
const searchTerm = computed(() => {
if (mode.value === 'users') return query.value.slice(1).trim();
if (mode.value === 'providers') return query.value.slice(2).trim();
return query.value.trim();
});
onMounted(async () => {
nextTick(() => inputRef.value?.focus());
try {
const res = await fetch('/api/games');
if (res.ok) {
const data = await res.json();
const list = Array.isArray(data) ? data : (data?.games || data?.items || []);
allGames.value = list.map((g: any, idx: number) => ({
slug: g.slug ?? g.id ?? String(idx),
name: g.name ?? g.title ?? `Game ${idx}`,
provider: g.provider ?? 'BetiX',
image: g.image ?? g.thumbnail ?? '',
type: g.type ?? 'slot',
}));
}
} catch {}
});
let debounceTimer: any;
watch(query, () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => doSearch(), 250);
});
async function doSearch() {
const term = searchTerm.value;
if (!term) {
gameResults.value = [];
userResults.value = [];
providerResults.value = [];
return;
}
if (mode.value === 'games') {
const lc = term.toLowerCase();
gameResults.value = allGames.value.filter(g => g.name.toLowerCase().includes(lc)).slice(0, 12);
providerResults.value = [];
userResults.value = [];
} else if (mode.value === 'providers') {
const lc = term.toLowerCase();
const map: Record<string, any[]> = {};
for (const g of allGames.value) {
if (g.provider.toLowerCase().includes(lc)) {
if (!map[g.provider]) map[g.provider] = [];
map[g.provider].push(g);
}
}
providerResults.value = Object.entries(map).slice(0, 8).map(([name, games]) => ({ name, count: games.length }));
gameResults.value = [];
userResults.value = [];
} else if (mode.value === 'users') {
if (term.length < 1) { userResults.value = []; return; }
loading.value = true;
try {
const res = await fetch(`/api/users/search?q=${encodeURIComponent(term)}`);
if (res.ok) userResults.value = (await res.json()).slice(0, 8);
} catch {}
loading.value = false;
gameResults.value = [];
providerResults.value = [];
}
}
// Recent searches
const RECENT_KEY = 'betix_recent_searches';
const recentSearches = ref<string[]>([]);
onMounted(() => {
try { recentSearches.value = JSON.parse(localStorage.getItem(RECENT_KEY) || '[]'); } catch {}
});
function addRecent(term: string) {
const list = [term, ...recentSearches.value.filter(r => r !== term)].slice(0, 6);
recentSearches.value = list;
try { localStorage.setItem(RECENT_KEY, JSON.stringify(list)); } catch {}
}
function clearRecent() {
recentSearches.value = [];
try { localStorage.removeItem(RECENT_KEY); } catch {}
}
function applyRecent(term: string) { query.value = term; }
// Keyboard
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') emit('close');
}
onMounted(() => document.addEventListener('keydown', handleKeydown));
onUnmounted(() => document.removeEventListener('keydown', handleKeydown));
function playGame(slug: string, name: string, provider?: string) {
addRecent(name);
emit('close');
const prov = (provider ?? 'betix').toLowerCase().replace(/\s+/g, '-');
window.location.href = `/games/play/${encodeURIComponent(prov)}/${encodeURIComponent(slug)}`;
}
function goProfile(username: string) {
addRecent('@' + username);
emit('close');
}
function goProvider(name: string) {
addRecent('P:' + name);
emit('close');
window.location.href = `/dashboard?provider=${encodeURIComponent(name)}`;
}
// Quick shortcuts (shown on empty state)
const shortcuts = [
{ label: '@Username', hint: 'Spieler suchen', prefix: '@', icon: AtSign },
{ label: 'P:Provider', hint: 'Anbieter suchen', prefix: 'P:', icon: Layers },
];
</script>
<template>
<teleport to="body">
<div class="sm-overlay" @click.self="$emit('close')">
<div class="sm-modal">
<!-- Search input row -->
<div class="sm-input-row">
<div class="sm-input-wrap">
<Search :size="18" class="sm-search-icon" />
<input
ref="inputRef"
v-model="query"
class="sm-input"
placeholder="Spiele, @User, P:Provider..."
autocomplete="off"
spellcheck="false"
/>
<button v-if="query" class="sm-clear-btn" @click="query = ''" title="Löschen">
<X :size="15" />
</button>
</div>
<button class="sm-esc-btn" @click="$emit('close')">
<kbd>ESC</kbd>
</button>
</div>
<!-- Mode tabs -->
<div class="sm-tabs">
<button class="sm-tab" :class="{ active: mode === 'games' }" @click="query = query.startsWith('@') || query.toLowerCase().startsWith('p') ? '' : query">
<Gamepad2 :size="13" /> Spiele
</button>
<button class="sm-tab" :class="{ active: mode === 'users' }" @click="query = '@'">
<AtSign :size="13" /> @Spieler
</button>
<button class="sm-tab" :class="{ active: mode === 'providers' }" @click="query = 'P:'">
<Layers :size="13" /> Provider
</button>
<div class="sm-tab-indicator" :style="{ left: mode === 'games' ? '4px' : mode === 'users' ? 'calc(33.3% + 2px)' : 'calc(66.6% + 2px)', width: 'calc(33.3% - 4px)' }"></div>
</div>
<!-- Body -->
<div class="sm-body">
<!-- Empty state -->
<div v-if="!query || (!searchTerm && mode !== 'games')">
<!-- Recent searches -->
<div v-if="recentSearches.length" class="sm-block">
<div class="sm-block-head">
<span><Clock :size="12" /> Zuletzt gesucht</span>
<button class="sm-text-btn" @click="clearRecent">Löschen</button>
</div>
<div class="sm-tags">
<button
v-for="r in recentSearches"
:key="r"
class="sm-tag"
@click="applyRecent(r)"
>{{ r }}</button>
</div>
</div>
<!-- Shortcuts -->
<div class="sm-block">
<div class="sm-block-head"><span><Sparkles :size="12" /> Schnellsuche</span></div>
<div class="sm-shortcuts">
<button
v-for="s in shortcuts"
:key="s.prefix"
class="sm-shortcut"
@click="query = s.prefix"
>
<component :is="s.icon" :size="15" class="sm-sc-icon" />
<div>
<div class="sm-sc-label">{{ s.label }}</div>
<div class="sm-sc-hint">{{ s.hint }}</div>
</div>
<ChevronRight :size="14" class="sm-sc-arrow" />
</button>
</div>
</div>
<!-- Empty hint -->
<div v-if="!recentSearches.length" class="sm-empty">
<Search :size="32" class="sm-empty-icon" />
<p>Tippe um zu suchen</p>
<small>Spiele, Spieler oder Provider</small>
</div>
</div>
<!-- Game results -->
<div v-if="mode === 'games' && searchTerm && gameResults.length" class="sm-block">
<div class="sm-block-head">
<span><Gamepad2 :size="12" /> Spiele</span>
<span class="sm-count">{{ gameResults.length }} Treffer</span>
</div>
<div class="sm-games-grid">
<button
v-for="g in gameResults"
:key="g.slug"
class="sm-game"
@click="playGame(g.slug, g.name, g.provider)"
>
<div class="sg-thumb" :style="g.image ? { backgroundImage: `url(${g.image})` } : {}">
<span v-if="!g.image" class="sg-letter">{{ g.name[0] }}</span>
<div class="sg-overlay"><Play :size="18" /></div>
</div>
<div class="sg-name">{{ g.name }}</div>
<div class="sg-prov">{{ g.provider }}</div>
</button>
</div>
</div>
<!-- No game results -->
<div v-if="mode === 'games' && searchTerm && !gameResults.length" class="sm-no-results">
<SearchX :size="36" class="sm-nr-icon" />
<p>Keine Spiele für <strong>"{{ searchTerm }}"</strong></p>
</div>
<!-- Provider results -->
<div v-if="mode === 'providers' && searchTerm && providerResults.length" class="sm-block">
<div class="sm-block-head"><span><Layers :size="12" /> Provider</span></div>
<div class="sm-providers">
<button
v-for="p in providerResults"
:key="p.name"
class="sm-provider"
@click="goProvider(p.name)"
>
<div class="sp-icon"><Layers :size="18" /></div>
<div class="sp-info">
<div class="sp-name">{{ p.name }}</div>
<div class="sp-count">{{ p.count }} Spiele</div>
</div>
<ChevronRight :size="15" class="sp-arrow" />
</button>
</div>
</div>
<!-- No provider results -->
<div v-if="mode === 'providers' && searchTerm && !providerResults.length" class="sm-no-results">
<SearchX :size="36" class="sm-nr-icon" />
<p>Keine Provider für <strong>"{{ searchTerm }}"</strong></p>
</div>
<!-- User results -->
<div v-if="mode === 'users' && searchTerm && userResults.length" class="sm-block">
<div class="sm-block-head"><span><AtSign :size="12" /> Spieler</span></div>
<div class="sm-users">
<Link
v-for="u in userResults"
:key="u.id"
:href="`/profile/${u.username}`"
class="sm-user"
@click="goProfile(u.username)"
>
<div class="su-avatar">
<img v-if="u.avatar || u.avatar_url" :src="u.avatar || u.avatar_url" alt="" />
<span v-else>{{ u.username[0].toUpperCase() }}</span>
</div>
<div class="su-info">
<div class="su-name">{{ u.username }}</div>
<div class="su-vip">VIP {{ u.vip_level ?? 0 }}</div>
</div>
<ChevronRight :size="14" class="su-arrow" />
</Link>
</div>
</div>
<!-- Loading users -->
<div v-if="mode === 'users' && loading" class="sm-loading">
<Loader2 :size="24" class="sm-spin" />
</div>
<!-- No user results -->
<div v-if="mode === 'users' && searchTerm && !loading && !userResults.length" class="sm-no-results">
<UserX :size="36" class="sm-nr-icon" />
<p>Keine Spieler gefunden</p>
</div>
</div>
<!-- Footer hint -->
<div class="sm-footer">
<span><kbd></kbd> Navigieren</span>
<span><kbd></kbd> Öffnen</span>
<span><kbd>ESC</kbd> Schließen</span>
</div>
</div>
</div>
</teleport>
</template>
<style scoped>
/* ── Overlay ─────────────────────────────────────────────── */
.sm-overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,.7);
backdrop-filter: blur(8px);
z-index: 9999;
display: flex; align-items: flex-start; justify-content: center;
padding-top: clamp(48px, 9vh, 110px);
animation: sm-overlay-in .15s ease;
}
@keyframes sm-overlay-in { from { opacity: 0; } to { opacity: 1; } }
/* ── Modal ───────────────────────────────────────────────── */
.sm-modal {
width: min(700px, calc(100vw - 32px));
background: #0b0b0e;
border: 1px solid rgba(255,255,255,.08);
border-radius: 20px;
overflow: hidden;
box-shadow:
0 0 0 1px rgba(255,255,255,.04),
0 40px 100px rgba(0,0,0,.9),
0 0 60px rgba(223,0,106,.05);
animation: sm-modal-in .22s cubic-bezier(.16,1,.3,1);
}
@keyframes sm-modal-in {
from { opacity: 0; transform: translateY(-18px) scale(.97); }
to { opacity: 1; transform: none; }
}
/* ── Input row ───────────────────────────────────────────── */
.sm-input-row {
display: flex; align-items: center; gap: 10px;
padding: 14px 16px 14px 20px;
border-bottom: 1px solid rgba(255,255,255,.05);
}
.sm-input-wrap {
flex: 1; display: flex; align-items: center; gap: 12px;
background: rgba(255,255,255,.04);
border: 1px solid rgba(255,255,255,.07);
border-radius: 12px; padding: 0 12px;
transition: border-color .2s, box-shadow .2s;
}
.sm-input-wrap:focus-within {
border-color: rgba(223,0,106,.35);
box-shadow: 0 0 0 3px rgba(223,0,106,.08);
}
.sm-search-icon { color: #555; flex-shrink: 0; transition: color .2s; }
.sm-input-wrap:focus-within .sm-search-icon { color: var(--primary, #df006a); }
.sm-input {
flex: 1; background: transparent; border: none; outline: none;
color: #fff; font-size: 15px; font-weight: 500; font-family: inherit;
height: 46px; min-width: 0;
}
.sm-input::placeholder { color: #333; }
.sm-clear-btn {
background: rgba(255,255,255,.06); border: none; cursor: pointer;
color: #666; border-radius: 8px; width: 28px; height: 28px;
display: flex; align-items: center; justify-content: center; transition: .15s;
}
.sm-clear-btn:hover { color: #fff; background: rgba(255,255,255,.1); }
.sm-esc-btn {
background: rgba(255,255,255,.05); border: 1px solid rgba(255,255,255,.09);
border-radius: 8px; padding: 4px 10px; cursor: pointer; transition: .15s; flex-shrink: 0;
}
.sm-esc-btn kbd { font-size: 11px; color: #555; font-family: monospace; letter-spacing: .5px; }
.sm-esc-btn:hover kbd { color: #aaa; }
/* ── Tabs ────────────────────────────────────────────────── */
.sm-tabs {
display: flex; position: relative;
border-bottom: 1px solid rgba(255,255,255,.05);
padding: 6px 8px;
gap: 0;
}
.sm-tab {
flex: 1; display: flex; align-items: center; justify-content: center; gap: 6px;
height: 34px; background: none; border: none; cursor: pointer;
font-size: 12px; font-weight: 700; color: #444;
border-radius: 8px; transition: color .2s; position: relative; z-index: 1;
letter-spacing: .5px;
}
.sm-tab.active { color: #fff; }
.sm-tab-indicator {
position: absolute; bottom: 6px; height: 34px;
background: rgba(255,255,255,.07);
border: 1px solid rgba(255,255,255,.08);
border-radius: 8px;
transition: left .25s cubic-bezier(.16,1,.3,1);
z-index: 0;
}
/* ── Body ────────────────────────────────────────────────── */
.sm-body {
padding: 14px; max-height: 58vh; overflow-y: auto;
scrollbar-width: thin; scrollbar-color: #222 transparent;
}
.sm-body::-webkit-scrollbar { width: 4px; }
.sm-body::-webkit-scrollbar-thumb { background: #222; border-radius: 2px; }
/* ── Block ───────────────────────────────────────────────── */
.sm-block { margin-bottom: 18px; }
.sm-block-head {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 10px;
}
.sm-block-head > span {
display: flex; align-items: center; gap: 5px;
font-size: 10px; font-weight: 800; color: #444;
text-transform: uppercase; letter-spacing: 1px;
}
.sm-count { font-size: 10px; color: #333; font-weight: 700; }
.sm-text-btn { background: none; border: none; color: #444; font-size: 11px; cursor: pointer; font-weight: 700; transition: color .15s; }
.sm-text-btn:hover { color: #888; }
/* ── Recent tags ─────────────────────────────────────────── */
.sm-tags { display: flex; flex-wrap: wrap; gap: 6px; }
.sm-tag {
background: rgba(255,255,255,.05); border: 1px solid rgba(255,255,255,.08);
color: #777; padding: 5px 12px; border-radius: 20px;
font-size: 12px; font-weight: 600; cursor: pointer; transition: .15s;
}
.sm-tag:hover { background: rgba(223,0,106,.1); border-color: rgba(223,0,106,.25); color: #fff; }
/* ── Shortcuts ───────────────────────────────────────────── */
.sm-shortcuts { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.sm-shortcut {
display: flex; align-items: center; gap: 10px; padding: 10px 12px;
background: rgba(255,255,255,.03); border: 1px solid rgba(255,255,255,.06);
border-radius: 10px; cursor: pointer; transition: .15s; text-align: left;
}
.sm-shortcut:hover { border-color: rgba(223,0,106,.2); background: rgba(223,0,106,.04); }
.sm-sc-icon { color: var(--primary, #df006a); flex-shrink: 0; }
.sm-sc-label { font-size: 12px; font-weight: 700; color: #aaa; }
.sm-sc-hint { font-size: 11px; color: #444; margin-top: 1px; }
.sm-sc-arrow { color: #333; margin-left: auto; flex-shrink: 0; }
/* ── Empty state ─────────────────────────────────────────── */
.sm-empty { text-align: center; padding: 32px 0 16px; color: #333; }
.sm-empty-icon { margin: 0 auto 12px; opacity: .3; }
.sm-empty p { font-size: 14px; font-weight: 600; color: #444; margin: 0 0 4px; }
.sm-empty small { font-size: 12px; color: #2a2a2a; }
/* ── Games grid ──────────────────────────────────────────── */
.sm-games-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 8px;
}
.sm-game {
background: none; border: 1px solid rgba(255,255,255,.05);
border-radius: 10px; overflow: hidden; cursor: pointer;
transition: border-color .15s, transform .15s; text-align: left;
padding: 0;
}
.sm-game:hover { border-color: rgba(223,0,106,.3); transform: translateY(-2px); }
.sg-thumb {
width: 100%; aspect-ratio: 4/3;
background: #1a1a1e; background-size: cover; background-position: center;
position: relative; display: flex; align-items: center; justify-content: center;
}
.sg-letter { font-size: 24px; font-weight: 900; color: #333; }
.sg-overlay {
position: absolute; inset: 0; background: rgba(0,0,0,.6);
display: flex; align-items: center; justify-content: center;
color: var(--primary, #df006a); opacity: 0; transition: opacity .15s;
}
.sm-game:hover .sg-overlay { opacity: 1; }
.sg-name {
font-size: 11px; font-weight: 700; color: #ccc;
padding: 6px 8px 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.sg-prov { font-size: 10px; color: #444; padding: 0 8px 6px; }
/* ── Providers ───────────────────────────────────────────── */
.sm-providers { display: flex; flex-direction: column; gap: 4px; }
.sm-provider {
display: flex; align-items: center; gap: 12px; padding: 10px 12px;
background: rgba(255,255,255,.03); border: 1px solid rgba(255,255,255,.05);
border-radius: 10px; cursor: pointer; transition: .15s; text-align: left; width: 100%;
}
.sm-provider:hover { border-color: rgba(223,0,106,.25); background: rgba(223,0,106,.04); }
.sp-icon { color: #444; }
.sm-provider:hover .sp-icon { color: var(--primary, #df006a); }
.sp-info { flex: 1; }
.sp-name { font-size: 14px; font-weight: 700; color: #ddd; }
.sp-count { font-size: 11px; color: #555; margin-top: 1px; }
.sp-arrow { color: #333; }
/* ── Users ───────────────────────────────────────────────── */
.sm-users { display: flex; flex-direction: column; gap: 3px; }
.sm-user {
display: flex; align-items: center; gap: 12px; padding: 8px 10px;
border-radius: 10px; text-decoration: none; transition: background .15s;
}
.sm-user:hover { background: rgba(255,255,255,.04); }
.su-avatar {
width: 38px; height: 38px; border-radius: 50%;
background: #1a1a1e; overflow: hidden; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
font-weight: 900; color: #444; font-size: 16px;
border: 1px solid rgba(255,255,255,.08);
}
.su-avatar img { width: 100%; height: 100%; object-fit: cover; }
.su-info { flex: 1; }
.su-name { font-size: 14px; font-weight: 700; color: #ddd; }
.su-vip { font-size: 11px; color: #444; margin-top: 1px; }
.su-arrow { color: #333; }
/* ── Loading ─────────────────────────────────────────────── */
.sm-loading { display: flex; justify-content: center; padding: 24px; }
.sm-spin { color: var(--primary, #df006a); animation: spin .8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
/* ── No results ──────────────────────────────────────────── */
.sm-no-results { text-align: center; padding: 32px 0; }
.sm-nr-icon { color: #2a2a2a; margin: 0 auto 12px; }
.sm-no-results p { font-size: 13px; color: #444; }
.sm-no-results strong { color: #666; }
/* ── Footer ──────────────────────────────────────────────── */
.sm-footer {
display: flex; align-items: center; gap: 16px; justify-content: center;
padding: 10px 16px;
border-top: 1px solid rgba(255,255,255,.04);
background: rgba(0,0,0,.3);
}
.sm-footer span {
display: flex; align-items: center; gap: 5px;
font-size: 11px; color: #333;
}
.sm-footer kbd {
background: rgba(255,255,255,.06); border: 1px solid rgba(255,255,255,.08);
border-radius: 4px; padding: 1px 5px; font-family: monospace;
font-size: 10px; color: #555;
}
/* ── Mobile ──────────────────────────────────────────────── */
@media (max-width: 480px) {
.sm-overlay { align-items: flex-end; padding-top: 0; }
.sm-modal { border-radius: 20px 20px 0 0; }
.sm-footer { display: none; }
.sm-games-grid { grid-template-columns: repeat(3, 1fr); }
.sm-shortcuts { grid-template-columns: 1fr; }
}
</style>

View File

@@ -0,0 +1,3 @@
<template>
<hr class="my-6 border-t border-muted" />
</template>

View File

@@ -0,0 +1 @@
export { default as Button } from './button.vue';

View File

@@ -0,0 +1,36 @@
<script setup lang="ts">
import { computed } from 'vue';
const props = defineProps<{
variant?: 'primary' | 'secondary' | 'ghost' | 'destructive' | 'outline';
size?: 'sm' | 'md' | 'lg';
disabled?: boolean;
type?: 'button' | 'submit' | 'reset';
}>();
const classes = computed(() => {
const base = "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50";
const variants = {
primary: "bg-[#ff007a] text-white hover:bg-[#ff007a]/90 shadow-[0_0_15px_rgba(255,0,122,0.4)]",
secondary: "bg-[#00f2ff] text-black hover:bg-[#00f2ff]/80 shadow-[0_0_15px_rgba(0,242,255,0.4)]",
ghost: "hover:bg-accent hover:text-accent-foreground",
destructive: "bg-red-500 text-white hover:bg-red-500/90",
outline: "border border-input bg-transparent hover:bg-accent hover:text-accent-foreground text-white"
};
const sizes = {
sm: "h-8 px-3 text-xs",
md: "h-9 px-4 py-2",
lg: "h-10 px-8"
};
return `${base} ${variants[props.variant || 'primary']} ${sizes[props.size || 'md']}`;
});
</script>
<template>
<button :type="type || 'button'" :class="classes" :disabled="disabled">
<slot />
</button>
</template>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
defineProps<{
checked?: boolean;
disabled?: boolean;
id?: string;
name?: string;
}>();
defineEmits(['update:checked']);
</script>
<template>
<input
type="checkbox"
:id="id"
:name="name"
:checked="checked"
:disabled="disabled"
@change="$emit('update:checked', ($event.target as HTMLInputElement).checked)"
class="h-4 w-4 rounded border-[#151515] bg-[#0a0a0a] text-[#ff007a] focus:ring-[#ff007a] focus:ring-offset-[#020202] disabled:cursor-not-allowed disabled:opacity-50"
/>
</template>

View File

@@ -0,0 +1,41 @@
<script setup lang="ts">
import { computed } from 'vue';
const props = withDefaults(defineProps<{
modelValue: string;
maxlength?: number;
disabled?: boolean;
autofocus?: boolean;
id?: string;
}>(), {
modelValue: '',
maxlength: 6,
disabled: false,
autofocus: false,
});
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void;
}>();
const value = computed({
get: () => props.modelValue,
set: (v: string) => emit('update:modelValue', v.slice(0, props.maxlength)),
});
</script>
<template>
<div class="w-full flex justify-center">
<input
:id="id"
v-model="value"
type="text"
inputmode="numeric"
:maxlength="maxlength"
:disabled="disabled"
:autofocus="autofocus"
class="sr-only"
/>
<slot />
</div>
</template>

View File

@@ -0,0 +1,5 @@
<template>
<div class="flex gap-2">
<slot />
</div>
</template>

View File

@@ -0,0 +1,9 @@
<script setup lang="ts">
const props = withDefaults(defineProps<{ index: number }>(), {});
</script>
<template>
<div class="h-10 w-10 rounded-md border border-input bg-background text-center leading-10">
<slot />
</div>
</template>

View File

@@ -0,0 +1,3 @@
export { default as InputOTP } from './InputOTP.vue';
export { default as InputOTPGroup } from './InputOTPGroup.vue';
export { default as InputOTPSlot } from './InputOTPSlot.vue';

View File

@@ -0,0 +1 @@
export { default as Input } from './input.vue';

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
defineProps<{
modelValue?: string | number;
type?: string;
placeholder?: string;
disabled?: boolean;
required?: boolean;
id?: string;
name?: string;
autocomplete?: string;
autofocus?: boolean;
}>();
defineEmits(['update:modelValue']);
</script>
<template>
<input
:id="id"
:name="name"
:type="type || 'text'"
:value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
:required="required"
:autocomplete="autocomplete"
:autofocus="autofocus"
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
class="flex h-9 w-full rounded-md border border-[#151515] bg-[#0a0a0a] px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-[#888888] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[#00f2ff] disabled:cursor-not-allowed disabled:opacity-50 text-white"
/>
</template>

View File

@@ -0,0 +1 @@
export { default as Label } from './label.vue';

View File

@@ -0,0 +1,11 @@
<script setup lang="ts">
defineProps<{
for?: string;
}>();
</script>
<template>
<label :for="for" class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-white">
<slot />
</label>
</template>

View File

@@ -0,0 +1,129 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, nextTick, watch } from 'vue';
import { ChevronDown, Check } from 'lucide-vue-next';
const props = defineProps<{
modelValue?: string | number;
options?: { label: string; value: string | number; icon?: string }[];
placeholder?: string;
id?: string;
required?: boolean;
disabled?: boolean;
}>();
const emit = defineEmits(['update:modelValue']);
const isOpen = ref(false);
const containerRef = ref<HTMLElement | null>(null);
const toggle = () => {
if (!props.disabled) {
isOpen.value = !isOpen.value;
}
};
const select = (value: string | number) => {
emit('update:modelValue', value);
isOpen.value = false;
};
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.value && !containerRef.value.contains(event.target as Node)) {
isOpen.value = false;
}
};
// Re-init icons when dropdown opens
watch(isOpen, (val) => {
if (val) {
nextTick(() => {
if ((window as any).lucide) (window as any).lucide.createIcons();
});
}
});
onMounted(() => {
document.addEventListener('click', handleClickOutside);
});
onBeforeUnmount(() => {
document.removeEventListener('click', handleClickOutside);
});
// Helper to get label for current value
const currentLabel = () => {
if (!props.options) return props.modelValue;
const opt = props.options.find(o => o.value === props.modelValue);
return opt ? opt.label : props.placeholder || 'Select...';
};
// Helper to get icon for current value (optional, to show icon in trigger)
const currentIcon = () => {
if (!props.options) return null;
const opt = props.options.find(o => o.value === props.modelValue);
return opt ? opt.icon : null;
};
// Watch modelValue to update trigger icon
watch(() => props.modelValue, () => {
nextTick(() => {
if ((window as any).lucide) (window as any).lucide.createIcons();
});
});
</script>
<template>
<div class="relative w-full" ref="containerRef">
<!-- Trigger -->
<div
class="flex h-10 w-full items-center justify-between rounded-md border border-[#151515] bg-[#0a0a0a] px-3 py-2 text-sm text-white shadow-sm cursor-pointer transition-all hover:border-[#333]"
:class="{ 'ring-1 ring-[#00f2ff] border-[#00f2ff]': isOpen, 'opacity-50 cursor-not-allowed': disabled }"
@click="toggle"
>
<span class="flex items-center gap-2" :class="{ 'text-[#666]': !modelValue }">
<i v-if="currentIcon()" :data-lucide="currentIcon()" class="w-4 h-4"></i>
{{ currentLabel() }}
</span>
<ChevronDown class="h-4 w-4 text-[#666] transition-transform duration-200" :class="{ 'rotate-180': isOpen }" />
</div>
<!-- Dropdown -->
<transition name="fade-scale">
<div v-if="isOpen" class="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md border border-[#222] bg-[#0a0a0a] py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none custom-scrollbar">
<div v-if="!options || options.length === 0" class="px-4 py-2 text-sm text-[#666]">No options</div>
<div
v-for="opt in options"
:key="opt.value"
class="relative flex cursor-pointer select-none items-center py-2 pl-3 pr-9 text-sm text-[#ccc] hover:bg-[#151515] hover:text-white transition-colors"
:class="{ 'bg-[#111] text-white': modelValue === opt.value }"
@click="select(opt.value)"
>
<span class="flex items-center gap-2 truncate">
<i v-if="opt.icon" :data-lucide="opt.icon" class="w-4 h-4"></i>
{{ opt.label }}
</span>
<span v-if="modelValue === opt.value" class="absolute inset-y-0 right-0 flex items-center pr-4 text-[#00f2ff]">
<Check class="h-4 w-4" />
</span>
</div>
</div>
</transition>
<!-- Hidden Native Select for Form Submission/Validation if needed -->
<select :id="id" :value="modelValue" class="sr-only" :required="required" :disabled="disabled">
<option v-for="opt in options" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
</div>
</template>
<style scoped>
.custom-scrollbar::-webkit-scrollbar { width: 6px; }
.custom-scrollbar::-webkit-scrollbar-track { background: #0a0a0a; }
.custom-scrollbar::-webkit-scrollbar-thumb { background: #222; border-radius: 3px; }
.custom-scrollbar::-webkit-scrollbar-thumb:hover { background: #333; }
.fade-scale-enter-active, .fade-scale-leave-active { transition: all 0.15s ease-out; }
.fade-scale-enter-from, .fade-scale-leave-to { opacity: 0; transform: scale(0.95) translateY(-5px); }
</style>

View File

@@ -0,0 +1 @@
export { default as Separator } from './Separator.vue';

View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
defineProps<{
size?: 'sm' | 'md' | 'lg';
}>();
</script>
<template>
<svg
class="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</template>

View File

@@ -0,0 +1,718 @@
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch } from 'vue';
import {
Shield, X, Lock, Unlock, ArrowDownToLine, ArrowUpFromLine, Delete, ChevronRight, Check,
Wallet as WalletIcon, Bitcoin, Coins, Gem, Layers, Zap, CircleDollarSign, BadgeCent,
CircleDot, Triangle, Hexagon, Bone, PawPrint, Waypoints,
} from 'lucide-vue-next';
const COIN_ICONS: Record<string, any> = {
'bitcoin': Bitcoin,
'coins': Coins,
'gem': Gem,
'layers': Layers,
'zap': Zap,
'circle-dollar-sign': CircleDollarSign,
'circle-dot': CircleDot,
'triangle': Triangle,
'hexagon': Hexagon,
'badge-cent': BadgeCent,
'bone': Bone,
'paw-print': PawPrint,
'waypoints': Waypoints,
};
function coinIcon(iconName: string) {
return COIN_ICONS[iconName] ?? Gem;
}
import { useNotifications } from '@/composables/useNotifications';
import { useVault } from '@/composables/useVault';
const props = defineProps<{
open: boolean;
coins: { currency: string; name: string; amount: number; icon: string; color: string }[]
}>();
const emit = defineEmits<{ (e: 'close'): void }>();
const { notify } = useNotifications();
const {
loading,
error,
balances,
pinRequired,
lockedUntil,
load,
deposit,
withdraw,
verifyPin,
setPin,
clearSessionPin
} = useVault();
// ── Currency ────────────────────────────────────────────────
const selectedCurrency = ref('BTX');
const allCoins = computed(() => {
if (!props.coins?.length) return [{ currency: 'BTX', name: 'BetiX', amount: 0, icon: 'gem', color: 'var(--primary)' }];
return props.coins.filter(c => {
if (c.currency === 'BTX') return true;
if (c.amount > 0) return true;
const vaultAmt = parseFloat((balances.value?.vault_balances || {})[c.currency] || '0');
return vaultAmt > 0;
});
});
const activeCoin = computed(() => allCoins.value.find(c => c.currency === selectedCurrency.value) || allCoins.value[0]);
function selectCurrency(c: string) {
selectedCurrency.value = c;
amount.value = '';
}
// ── Balances ────────────────────────────────────────────────
const walletBal = computed(() => {
if (selectedCurrency.value === 'BTX') return parseFloat(balances.value?.balance || '0');
return activeCoin.value?.amount ?? 0;
});
const vaultBal = computed(() => {
if (!balances.value) return 0;
const map = balances.value.vault_balances || {};
return parseFloat(map[selectedCurrency.value] || '0');
});
// ── State ────────────────────────────────────────────────────
const direction = ref<'to' | 'from'>('to');
const amount = ref('');
const pin = ref('');
const hasPin = ref(true);
const gateOpen = ref(false);
const unlocking = ref(false);
const isTransferring = ref(false);
const successState = ref(false);
// ── Watchers ────────────────────────────────────────────────
watch(() => props.open, async (v) => {
if (v) {
gateOpen.value = true;
unlocking.value = false;
pin.value = '';
amount.value = '';
successState.value = false;
selectedCurrency.value = 'BTX';
await load();
} else {
try { clearSessionPin(); } catch {}
}
});
// ── Keyboard ─────────────────────────────────────────────────
function onKeydown(e: KeyboardEvent) {
if (!props.open) return;
if (gateOpen.value) {
if (e.key === 'Enter') { e.preventDefault(); doVerifyPin(); return; }
if (e.key === 'Escape') { e.preventDefault(); close(); return; }
if (e.key === 'Backspace') { e.preventDefault(); backspacePin(); return; }
if (/^[0-9]$/.test(e.key)){ e.preventDefault(); appendDigit(e.key); }
return;
}
if (e.key === 'Escape') close();
}
function close() { emit('close'); }
// ── Amount Logic ─────────────────────────────────────────────
function setPercentage(pct: number) {
const bal = direction.value === 'to' ? walletBal.value : vaultBal.value;
amount.value = (bal * pct).toFixed(4);
}
function onAmountInput(e: Event) {
let s = ((e.target as HTMLInputElement).value || '').replace(/,/g, '.').replace(/[^0-9.]/g, '');
const parts = s.split('.');
if (parts.length > 2) s = parts.shift()! + '.' + parts.join('');
const [intPart, fracRaw] = s.split('.') as [string, string?];
const intSan = (intPart || '').replace(/^0+(?=\d)/, '');
const frac = (fracRaw ?? '').slice(0, 4);
amount.value = fracRaw !== undefined ? `${intSan || '0'}.${frac}` : (intSan || '');
}
// ── Transfer ─────────────────────────────────────────────────
async function submit() {
try {
if (!/^\d+(?:\.\d{1,4})?$/.test(amount.value) || parseFloat(amount.value) <= 0) {
notify({ type: 'red', title: 'Ungültiger Betrag', desc: 'Bitte gib einen gültigen Betrag ein.', icon: 'alert-circle' });
return;
}
const a = amount.value;
const isTo = direction.value === 'to';
isTransferring.value = true;
await new Promise(r => setTimeout(r, 300));
await (isTo ? deposit(a, selectedCurrency.value) : withdraw(a, selectedCurrency.value));
successState.value = true;
animateCoin();
notify({
type: 'green',
title: isTo ? 'Eingezahlt' : 'Ausgezahlt',
desc: `${a} ${selectedCurrency.value} ${isTo ? '→ Vault' : '← Wallet'}`,
icon: 'check-circle'
});
amount.value = '';
setTimeout(() => { successState.value = false; isTransferring.value = false; }, 1500);
} catch (e: any) {
isTransferring.value = false;
const msg = String(e?.message || 'Aktion fehlgeschlagen');
if (msg.includes('No PIN set')) hasPin.value = false;
if (pinRequired.value || msg.includes('PIN') || msg.includes('locked')) {
gateOpen.value = true;
notify({ type: 'magenta', title: 'PIN benötigt', desc: 'Bitte PIN erneut eingeben.', icon: 'lock' });
} else {
notify({ type: 'red', title: 'Fehler', desc: msg, icon: 'alert-triangle' });
}
}
}
// ── PIN Logic ────────────────────────────────────────────────
function appendDigit(d: string) { if (pin.value.length < 8) pin.value += d; }
function backspacePin() { pin.value = pin.value.slice(0, -1); }
async function doVerifyPin() {
try {
if (!/^\d{4,8}$/.test(pin.value)) { shakeGate(); return; }
await verifyPin(pin.value);
unlocking.value = true;
setTimeout(() => { gateOpen.value = false; pin.value = ''; unlocking.value = false; }, 600);
} catch (e: any) {
shakeGate();
notify({ type: 'red', title: 'Falscher PIN', desc: e.message, icon: 'lock' });
}
}
async function doSetPin() {
try {
if (!/^\d{4,8}$/.test(pin.value)) { shakeGate(); return; }
await setPin(pin.value);
notify({ type: 'green', title: 'PIN erstellt', desc: 'Vault ist gesichert.', icon: 'check' });
hasPin.value = true;
unlocking.value = true;
setTimeout(() => { gateOpen.value = false; pin.value = ''; unlocking.value = false; }, 600);
} catch (e: any) {
shakeGate();
notify({ type: 'red', title: 'Fehler', desc: e.message, icon: 'alert-triangle' });
}
}
function shakeGate() {
const el = document.querySelector('.vm-gate-pad');
if (el) { el.classList.remove('shake'); void (el as HTMLElement).offsetWidth; el.classList.add('shake'); }
}
// ── Coin animation ────────────────────────────────────────────
function animateCoin() {
const startEl = document.querySelector('.vm-bal-card.source .vm-bal-icon');
const endEl = document.querySelector('.vm-bal-card.dest .vm-bal-icon');
if (!startEl || !endEl) return;
const sr = startEl.getBoundingClientRect();
const er = endEl.getBoundingClientRect();
for (let i = 0; i < 6; i++) {
setTimeout(() => {
const g = document.createElement('div');
g.className = 'vm-particle';
g.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="${activeCoin.value.color}" stroke="none"><circle cx="12" cy="12" r="10"/></svg>`;
document.body.appendChild(g);
const ox = (Math.random() - 0.5) * 24, oy = (Math.random() - 0.5) * 24;
g.style.cssText = `position:fixed;left:${sr.left + sr.width/2 - 7 + ox}px;top:${sr.top + sr.height/2 - 7 + oy}px;opacity:0;transform:scale(.4);pointer-events:none;z-index:10000;`;
requestAnimationFrame(() => {
g.style.transition = 'all .7s cubic-bezier(.2,0,.2,1)';
g.style.opacity = '1';
g.style.transform = `translate(${er.left - sr.left - ox}px,${er.top - sr.top - oy}px) scale(1)`;
setTimeout(() => { g.style.opacity = '0'; g.style.transform += ' scale(1.8)'; }, 500);
});
setTimeout(() => g.remove(), 800);
}, i * 70);
}
}
onMounted(() => { window.addEventListener('keydown', onKeydown); });
onBeforeUnmount(() => { window.removeEventListener('keydown', onKeydown); });
</script>
<template>
<Teleport to="body">
<Transition name="vm-overlay-fade">
<div v-if="open" class="vm-overlay" @click.self="close">
<Transition name="vm-pop" appear>
<div class="vm-card" :class="{ 'is-unlocking': unlocking }">
<!-- HEADER -->
<div class="vm-header">
<div class="vm-title">
<div class="vm-title-icon">
<Shield :size="16" />
</div>
<span>Vault</span>
</div>
<button class="vm-close" @click="close" type="button">
<X :size="16" />
</button>
</div>
<!-- CURRENCY TABS -->
<div class="vm-currency-bar">
<button
v-for="coin in allCoins"
:key="coin.currency"
class="vm-cur-tab"
:class="{ active: selectedCurrency === coin.currency }"
:style="selectedCurrency === coin.currency ? { '--tab-color': coin.color } : {}"
@click="selectCurrency(coin.currency)"
type="button"
>
<component :is="coinIcon(coin.icon)" :size="13" :style="{ color: coin.color }" />
<span class="vm-cur-code">{{ coin.currency }}</span>
<span class="vm-cur-amt">{{ coin.amount.toFixed(coin.currency === 'BTC' || coin.currency === 'ETH' ? 6 : 4) }}</span>
</button>
</div>
<!-- CONTENT -->
<div class="vm-body">
<Transition name="vm-slide" mode="out-in">
<!-- GATE -->
<div v-if="gateOpen" class="vm-gate" key="gate">
<div class="vm-gate-icon" :class="{ open: unlocking }">
<Lock v-if="!unlocking" :size="28" />
<Unlock v-else :size="28" />
</div>
<h2 class="vm-gate-title">Sicherheitssperre</h2>
<p class="vm-gate-sub">{{ hasPin ? 'PIN eingeben um den Tresor zu öffnen.' : 'Erstelle einen neuen PIN.' }}</p>
<div class="vm-gate-pad">
<div class="vm-pin-dots">
<div v-for="i in 8" :key="i" class="vm-dot" :class="{ active: i <= pin.length }"></div>
</div>
<div class="vm-numpad">
<button v-for="n in 9" :key="n" @click="appendDigit(n.toString())" class="vm-num" type="button">{{ n }}</button>
<button class="vm-num action" @click="pin = ''" type="button">C</button>
<button class="vm-num" @click="appendDigit('0')" type="button">0</button>
<button class="vm-num action" @click="backspacePin" type="button"><Delete :size="16" /></button>
</div>
<p v-if="lockedUntil" class="vm-lock-msg">Gesperrt bis: {{ lockedUntil }}</p>
<button
class="vm-gate-submit"
@click="hasPin ? doVerifyPin() : doSetPin()"
:disabled="loading || pin.length < 4"
type="button"
>
<span v-if="loading" class="vm-spin"></span>
<span v-else>{{ hasPin ? 'Entsperren' : 'PIN erstellen' }}</span>
</button>
<div class="vm-pin-toggle">
<button v-if="hasPin" type="button" @click="hasPin = false">Noch keinen PIN?</button>
<button v-else type="button" @click="hasPin = true">Ich habe bereits einen PIN</button>
</div>
</div>
</div>
<!-- TRANSFER -->
<div v-else class="vm-transfer" key="transfer">
<!-- Direction Toggle -->
<div class="vm-direction">
<button
class="vm-dir-btn"
:class="{ active: direction === 'to' }"
@click="direction = 'to'; amount = ''"
type="button"
>
<ArrowDownToLine :size="14" />
Einzahlen
</button>
<button
class="vm-dir-btn"
:class="{ active: direction === 'from' }"
@click="direction = 'from'; amount = ''"
type="button"
>
<ArrowUpFromLine :size="14" />
Auszahlen
</button>
<div class="vm-dir-glider" :style="{ transform: direction === 'to' ? 'translateX(0)' : 'translateX(100%)' }"></div>
</div>
<!-- Balance Cards -->
<div class="vm-bal-row">
<div class="vm-bal-card source">
<div class="vm-bal-icon" :style="{ '--coin-color': activeCoin.color }">
<WalletIcon v-if="direction === 'to'" :size="17" />
<Lock v-else :size="17" />
</div>
<div class="vm-bal-info">
<div class="vm-bal-label">{{ direction === 'to' ? 'Wallet' : 'Vault' }}</div>
<div class="vm-bal-value">
{{ (direction === 'to' ? walletBal : vaultBal).toFixed(4) }}
<small>{{ selectedCurrency }}</small>
</div>
</div>
</div>
<div class="vm-bal-arrow">
<ChevronRight :size="18" />
</div>
<div class="vm-bal-card dest">
<div class="vm-bal-info right">
<div class="vm-bal-label">{{ direction === 'to' ? 'Vault' : 'Wallet' }}</div>
<div class="vm-bal-value">
{{ (direction === 'to' ? vaultBal : walletBal).toFixed(4) }}
<small>{{ selectedCurrency }}</small>
</div>
</div>
<div class="vm-bal-icon" :style="{ '--coin-color': activeCoin.color }">
<Lock v-if="direction === 'to'" :size="17" />
<WalletIcon v-else :size="17" />
</div>
</div>
</div>
<!-- Amount Input -->
<div class="vm-input-section">
<div class="vm-input-header">
<span class="vm-input-label">Betrag</span>
<button class="vm-avail" type="button" @click="setPercentage(1)">
Verfügbar: {{ (direction === 'to' ? walletBal : vaultBal).toFixed(4) }} {{ selectedCurrency }}
</button>
</div>
<div class="vm-input-wrap" :class="{ focused: !!amount }">
<input
class="vm-input"
type="text"
v-model="amount"
@input="onAmountInput"
placeholder="0.0000"
inputmode="decimal"
autocomplete="off"
/>
<span class="vm-input-suffix" :style="{ color: activeCoin.color }">{{ selectedCurrency }}</span>
</div>
<div class="vm-pct-row">
<button type="button" @click="setPercentage(.25)">25%</button>
<button type="button" @click="setPercentage(.5)">50%</button>
<button type="button" @click="setPercentage(.75)">75%</button>
<button type="button" @click="setPercentage(1)">MAX</button>
</div>
</div>
<!-- Submit -->
<button
class="vm-submit"
:class="{ success: successState }"
:disabled="loading || isTransferring || !amount"
@click="submit"
type="button"
>
<span v-if="successState" class="vm-submit-inner">
<Check :size="18" /> Transferiert
</span>
<span v-else-if="loading || isTransferring" class="vm-submit-inner">
<span class="vm-spin"></span>
</span>
<span v-else class="vm-submit-inner">
{{ direction === 'to' ? '↓ In Vault einzahlen' : '↑ Aus Vault auszahlen' }}
</span>
</button>
</div>
</Transition>
</div>
</div>
</Transition>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
/* ── Reset ── */
*, *::before, *::after { box-sizing: border-box; }
/* ── Overlay ── */
.vm-overlay {
position: fixed; inset: 0; z-index: 9999;
background: rgba(0,0,0,.75);
backdrop-filter: blur(14px);
display: flex; align-items: center; justify-content: center;
padding: 16px;
}
/* ── Card ── */
.vm-card {
width: 100%; max-width: 500px;
background: #0c0c0e;
border: 1px solid #222;
border-radius: 24px;
box-shadow: 0 40px 80px -20px rgba(0,0,0,.9), 0 0 0 1px rgba(255,255,255,.04);
display: flex; flex-direction: column;
overflow: hidden;
position: relative;
transition: transform .5s cubic-bezier(.16,1,.3,1), border-color .3s;
}
.vm-card::before {
content: ''; position: absolute; top: 0; left: 0; right: 0; height: 1px;
background: linear-gradient(90deg, transparent, rgba(223,0,106,.4), transparent);
}
.vm-card.is-unlocking { transform: scale(1.02); border-color: rgba(223,0,106,.4); }
/* ── Header ── */
.vm-header {
display: flex; align-items: center; justify-content: space-between;
padding: 20px 24px;
border-bottom: 1px solid #1a1a1e;
background: linear-gradient(to bottom, #101012, #0c0c0e);
}
.vm-title { display: flex; align-items: center; gap: 10px; font-size: 13px; font-weight: 900; letter-spacing: 2px; text-transform: uppercase; color: #fff; }
.vm-title-icon {
width: 30px; height: 30px; background: rgba(223,0,106,.12); border-radius: 9px;
display: flex; align-items: center; justify-content: center; color: #df006a;
box-shadow: 0 0 12px rgba(223,0,106,.1);
}
.vm-close {
width: 30px; height: 30px; background: #1a1a1e; border: 1px solid #2a2a2e; color: #666;
border-radius: 9px; display: flex; align-items: center; justify-content: center;
cursor: pointer; transition: .2s;
}
.vm-close:hover { background: #2a2a2e; color: #fff; border-color: #444; }
/* ── Currency Bar ── */
.vm-currency-bar {
display: flex; gap: 6px; padding: 12px 20px;
border-bottom: 1px solid #1a1a1e;
background: #0a0a0c;
overflow-x: auto;
scrollbar-width: none;
}
.vm-currency-bar::-webkit-scrollbar { display: none; }
.vm-cur-tab {
display: flex; align-items: center; gap: 6px;
padding: 7px 12px;
background: #141418; border: 1px solid #222; border-radius: 10px;
cursor: pointer; transition: .2s; white-space: nowrap; flex-shrink: 0;
color: #666;
}
.vm-cur-tab.active {
border-color: var(--tab-color, #df006a);
background: rgba(255,255,255,.04);
color: #fff;
box-shadow: 0 0 14px -4px var(--tab-color, #df006a);
}
.vm-cur-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
.vm-cur-code { font-size: 11px; font-weight: 900; letter-spacing: .5px; }
.vm-cur-amt { font-size: 10px; color: #555; font-weight: 600; }
.vm-cur-tab.active .vm-cur-amt { color: #888; }
/* ── Body ── */
.vm-body { position: relative; min-height: 380px; display: flex; flex-direction: column; }
/* ── Gate ── */
.vm-gate {
flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center;
padding: 28px 24px;
}
.vm-gate-icon {
width: 64px; height: 64px; border-radius: 20px;
display: flex; align-items: center; justify-content: center;
border: 1px solid #2a2a2e; color: #555;
transition: all .5s cubic-bezier(.34,1.56,.64,1);
margin-bottom: 16px;
background: #141418;
}
.vm-gate-icon.open { background: #df006a; color: #fff; border-color: #df006a; transform: scale(1.1) rotate(-8deg); box-shadow: 0 0 30px rgba(223,0,106,.4); }
.vm-gate-title { font-size: 18px; font-weight: 800; color: #fff; margin: 0 0 6px; text-align: center; }
.vm-gate-sub { font-size: 12px; color: #666; margin: 0 0 24px; text-align: center; max-width: 240px; }
.vm-gate-pad { width: 100%; max-width: 280px; }
.vm-gate-pad.shake { animation: shake .4s cubic-bezier(.36,.07,.19,.97) both; }
.vm-pin-dots { display: flex; justify-content: center; gap: 10px; margin-bottom: 24px; }
.vm-dot { width: 10px; height: 10px; border-radius: 50%; background: #222; transition: all .25s cubic-bezier(.16,1,.3,1); }
.vm-dot.active { background: #df006a; box-shadow: 0 0 10px rgba(223,0,106,.6); transform: scale(1.2); }
.vm-numpad { display: grid; grid-template-columns: repeat(3,1fr); gap: 10px; margin-bottom: 16px; }
.vm-num {
background: #141418; border: 1px solid #222; color: #fff;
font-size: 18px; font-weight: 600; padding: 16px 0; border-radius: 12px;
cursor: pointer; transition: .15s; box-shadow: 0 3px 0 rgba(0,0,0,.3);
}
.vm-num:hover { background: #1e1e22; transform: translateY(-1px); }
.vm-num:active { transform: translateY(2px); box-shadow: none; }
.vm-num.action { color: #555; background: transparent; border-color: transparent; box-shadow: none; font-size: 14px; }
.vm-num.action:hover { color: #fff; background: #1e1e22; }
.vm-lock-msg { font-size: 11px; color: #ff6b6b; text-align: center; margin: 0 0 12px; }
.vm-gate-submit {
width: 100%; background: #df006a; color: #fff; border: none;
padding: 15px; border-radius: 12px; font-weight: 800; font-size: 14px;
cursor: pointer; transition: .2s; display: flex; align-items: center; justify-content: center;
box-shadow: 0 0 20px rgba(223,0,106,.2);
}
.vm-gate-submit:hover:not(:disabled) { filter: brightness(1.1); transform: translateY(-2px); box-shadow: 0 8px 24px rgba(223,0,106,.35); }
.vm-gate-submit:disabled { opacity: .45; cursor: not-allowed; background: #222; color: #555; box-shadow: none; }
.vm-pin-toggle { text-align: center; margin-top: 14px; }
.vm-pin-toggle button { background: none; border: none; color: #555; font-size: 11px; cursor: pointer; transition: .2s; text-decoration: underline; }
.vm-pin-toggle button:hover { color: #aaa; }
/* ── Transfer ── */
.vm-transfer { padding: 20px; display: flex; flex-direction: column; gap: 18px; flex: 1; }
.vm-direction {
display: flex; background: #101012; padding: 4px; border-radius: 12px;
border: 1px solid #1e1e22; position: relative;
}
.vm-dir-btn {
flex: 1; background: transparent; border: none; color: #555;
padding: 11px 12px; font-weight: 700; font-size: 12px; cursor: pointer;
z-index: 2; display: flex; align-items: center; justify-content: center; gap: 6px;
transition: color .3s; border-radius: 9px;
}
.vm-dir-btn.active { color: #fff; }
.vm-dir-glider {
position: absolute; top: 4px; left: 4px; width: calc(50% - 4px); height: calc(100% - 8px);
background: #df006a; border-radius: 9px; z-index: 1;
transition: transform .35s cubic-bezier(.16,1,.3,1);
box-shadow: 0 2px 12px rgba(223,0,106,.3);
}
.vm-bal-row {
display: flex; align-items: center; gap: 12px;
background: #101012; border: 1px solid #1e1e22; border-radius: 16px; padding: 16px;
}
.vm-bal-card { flex: 1; display: flex; align-items: center; gap: 10px; }
.vm-bal-card.dest { flex-direction: row-reverse; }
.vm-bal-icon {
width: 40px; height: 40px; background: #1a1a1e; border-radius: 12px;
border: 1px solid #2a2a2e; display: flex; align-items: center; justify-content: center;
color: #666; flex-shrink: 0;
}
.vm-bal-info { display: flex; flex-direction: column; gap: 2px; }
.vm-bal-info.right { text-align: right; }
.vm-bal-label { font-size: 10px; font-weight: 700; text-transform: uppercase; color: #555; letter-spacing: .5px; }
.vm-bal-value { font-size: 14px; color: #fff; font-weight: 700; }
.vm-bal-value small { font-size: 10px; color: #444; margin-left: 3px; }
.vm-bal-arrow {
width: 28px; height: 28px; background: rgba(223,0,106,.1); border: 1px solid rgba(223,0,106,.2);
border-radius: 50%; display: flex; align-items: center; justify-content: center;
color: #df006a; flex-shrink: 0;
}
.vm-input-section { display: flex; flex-direction: column; gap: 10px; }
.vm-input-header { display: flex; justify-content: space-between; align-items: center; }
.vm-input-label { font-size: 11px; font-weight: 700; text-transform: uppercase; color: #555; letter-spacing: .5px; }
.vm-avail { background: none; border: none; font-size: 11px; color: #df006a; cursor: pointer; font-weight: 600; }
.vm-avail:hover { text-decoration: underline; }
.vm-input-wrap {
display: flex; align-items: center;
background: #101012; border: 1px solid #222; border-radius: 14px; padding: 0 16px;
transition: all .3s;
}
.vm-input-wrap.focused { border-color: #df006a; box-shadow: 0 0 0 3px rgba(223,0,106,.1); }
.vm-input {
flex: 1; background: transparent; border: none; color: #fff;
font-size: 26px; font-weight: 700; padding: 16px 0; outline: none; font-family: monospace;
}
.vm-input-suffix { font-size: 13px; font-weight: 800; letter-spacing: .5px; }
.vm-pct-row { display: flex; gap: 8px; }
.vm-pct-row button {
flex: 1; background: #141418; border: 1px solid #222; color: #888;
padding: 8px 0; border-radius: 9px; font-size: 11px; font-weight: 700; cursor: pointer; transition: .2s;
}
.vm-pct-row button:hover { background: #1e1e22; color: #fff; border-color: #444; }
.vm-submit {
width: 100%; background: linear-gradient(135deg, #df006a, #b8005a);
color: #fff; border: none; padding: 16px; border-radius: 14px;
font-weight: 800; font-size: 14px; cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: all .3s cubic-bezier(.16,1,.3,1);
box-shadow: 0 8px 24px -4px rgba(223,0,106,.3);
}
.vm-submit.success { background: #10b981; box-shadow: 0 8px 24px -4px rgba(16,185,129,.4); }
.vm-submit:hover:not(:disabled) { transform: translateY(-2px); box-shadow: 0 12px 32px -4px rgba(223,0,106,.4); }
.vm-submit:disabled { opacity: .45; cursor: not-allowed; background: #1e1e22; color: #444; box-shadow: none; }
.vm-submit-inner { display: flex; align-items: center; gap: 8px; }
/* ── Animations ── */
@keyframes shake { 10%,90% { transform: translateX(-2px); } 20%,80% { transform: translateX(3px); } 30%,50%,70% { transform: translateX(-5px); } 40%,60% { transform: translateX(5px); } }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes pop-in { from { transform: scale(.85); opacity: 0; } to { transform: scale(1); opacity: 1; } }
.vm-spin { width: 18px; height: 18px; border: 2px solid rgba(255,255,255,.2); border-top-color: #fff; border-radius: 50%; animation: spin .8s linear infinite; }
.vm-overlay-fade-enter-active, .vm-overlay-fade-leave-active { transition: opacity .25s ease; }
.vm-overlay-fade-enter-from, .vm-overlay-fade-leave-to { opacity: 0; }
.vm-pop-enter-active { transition: all .4s cubic-bezier(.16,1,.3,1); }
.vm-pop-leave-active { transition: all .25s ease-in; }
.vm-pop-enter-from { opacity: 0; transform: scale(.94) translateY(16px); }
.vm-pop-leave-to { opacity: 0; transform: scale(.94) translateY(16px); }
.vm-slide-enter-active, .vm-slide-leave-active { transition: all .3s cubic-bezier(.16,1,.3,1); }
.vm-slide-enter-from { opacity: 0; transform: translateX(16px); }
.vm-slide-leave-to { opacity: 0; transform: translateX(-16px); }
/* ── Mobile ── */
@media (max-width: 560px) {
.vm-overlay { align-items: flex-end; padding: 0; }
.vm-card { border-radius: 24px 24px 0 0; max-width: 100%; border-bottom: none; }
.vm-body { min-height: auto; }
.vm-header { padding: 16px 20px; }
/* currency bar: slightly smaller tabs so more fit */
.vm-currency-bar { padding: 10px 16px; gap: 5px; }
.vm-cur-tab { padding: 6px 10px; gap: 5px; }
.vm-cur-code { font-size: 10px; }
.vm-cur-amt { font-size: 9px; }
/* transfer panel */
.vm-transfer { padding: 14px 16px; gap: 12px; }
.vm-input { font-size: 22px; padding: 13px 0; }
.vm-input-wrap { padding: 0 14px; }
/* balance row: stack vertically if very tight */
.vm-bal-row { padding: 12px; gap: 8px; }
.vm-bal-icon { width: 34px; height: 34px; border-radius: 10px; }
.vm-bal-value { font-size: 13px; }
/* numpad: smaller for more breathing room */
.vm-num { font-size: 16px; padding: 13px 0; border-radius: 10px; }
.vm-gate { padding: 20px 16px; }
.vm-gate-pad { max-width: 100%; }
/* pct row */
.vm-pct-row button { padding: 7px 0; font-size: 10px; }
.vm-submit { padding: 14px; font-size: 13px; }
}
@media (max-width: 380px) {
.vm-cur-tab { padding: 5px 8px; gap: 4px; }
.vm-cur-amt { display: none; }
.vm-num { font-size: 15px; padding: 12px 0; }
.vm-bal-row { flex-wrap: wrap; }
.vm-bal-card { min-width: 0; }
.vm-bal-arrow { display: none; }
}
</style>
<style>
.vm-particle { pointer-events: none; filter: drop-shadow(0 0 6px currentColor); }
</style>

View File

@@ -0,0 +1,124 @@
import type { ComputedRef, Ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
import type { Appearance, ResolvedAppearance } from '@/types';
export type { Appearance, ResolvedAppearance };
export type UseAppearanceReturn = {
appearance: Ref<Appearance>;
resolvedAppearance: ComputedRef<ResolvedAppearance>;
updateAppearance: (value: Appearance) => void;
};
export function updateTheme(value: Appearance): void {
if (typeof window === 'undefined') {
return;
}
if (value === 'system') {
const mediaQueryList = window.matchMedia(
'(prefers-color-scheme: dark)',
);
const systemTheme = mediaQueryList.matches ? 'dark' : 'light';
document.documentElement.classList.toggle(
'dark',
systemTheme === 'dark',
);
} else {
document.documentElement.classList.toggle('dark', value === 'dark');
}
}
const setCookie = (name: string, value: string, days = 365) => {
if (typeof document === 'undefined') {
return;
}
const maxAge = days * 24 * 60 * 60;
document.cookie = `${name}=${value};path=/;max-age=${maxAge};SameSite=Lax`;
};
const mediaQuery = () => {
if (typeof window === 'undefined') {
return null;
}
return window.matchMedia('(prefers-color-scheme: dark)');
};
const getStoredAppearance = () => {
if (typeof window === 'undefined') {
return null;
}
return localStorage.getItem('appearance') as Appearance | null;
};
const prefersDark = (): boolean => {
if (typeof window === 'undefined') {
return false;
}
return window.matchMedia('(prefers-color-scheme: dark)').matches;
};
const handleSystemThemeChange = () => {
const currentAppearance = getStoredAppearance();
updateTheme(currentAppearance || 'system');
};
export function initializeTheme(): void {
if (typeof window === 'undefined') {
return;
}
// Initialize theme from saved preference or default to system...
const savedAppearance = getStoredAppearance();
updateTheme(savedAppearance || 'system');
// Set up system theme change listener...
mediaQuery()?.addEventListener('change', handleSystemThemeChange);
}
const appearance = ref<Appearance>('system');
export function useAppearance(): UseAppearanceReturn {
onMounted(() => {
const savedAppearance = localStorage.getItem(
'appearance',
) as Appearance | null;
if (savedAppearance) {
appearance.value = savedAppearance;
}
});
const resolvedAppearance = computed<ResolvedAppearance>(() => {
if (appearance.value === 'system') {
return prefersDark() ? 'dark' : 'light';
}
return appearance.value;
});
function updateAppearance(value: Appearance) {
appearance.value = value;
// Store in localStorage for client-side persistence...
localStorage.setItem('appearance', value);
// Store in cookie for SSR...
setCookie('appearance', value);
updateTheme(value);
}
return {
appearance,
resolvedAppearance,
updateAppearance,
};
}

View File

@@ -0,0 +1,59 @@
import type { InertiaLinkProps } from '@inertiajs/vue3';
import { usePage } from '@inertiajs/vue3';
import type { ComputedRef, DeepReadonly } from 'vue';
import { computed, readonly } from 'vue';
import { toUrl } from '@/lib/utils';
export type UseCurrentUrlReturn = {
currentUrl: DeepReadonly<ComputedRef<string>>;
isCurrentUrl: (
urlToCheck: NonNullable<InertiaLinkProps['href']>,
currentUrl?: string,
) => boolean;
whenCurrentUrl: <T, F = null>(
urlToCheck: NonNullable<InertiaLinkProps['href']>,
ifTrue: T,
ifFalse?: F,
) => T | F;
};
const page = usePage();
const currentUrlReactive = computed(
() => new URL(page.url, window?.location.origin).pathname,
);
export function useCurrentUrl(): UseCurrentUrlReturn {
function isCurrentUrl(
urlToCheck: NonNullable<InertiaLinkProps['href']>,
currentUrl?: string,
) {
const urlToCompare = currentUrl ?? currentUrlReactive.value;
const urlString = toUrl(urlToCheck);
if (!urlString.startsWith('http')) {
return urlString === urlToCompare;
}
try {
const absoluteUrl = new URL(urlString);
return absoluteUrl.pathname === urlToCompare;
} catch {
return false;
}
}
function whenCurrentUrl(
urlToCheck: NonNullable<InertiaLinkProps['href']>,
ifTrue: any,
ifFalse: any = null,
) {
return isCurrentUrl(urlToCheck) ? ifTrue : ifFalse;
}
return {
currentUrl: readonly(currentUrlReactive),
isCurrentUrl,
whenCurrentUrl,
};
}

View File

@@ -0,0 +1,18 @@
export type UseInitialsReturn = {
getInitials: (fullName?: string) => string;
};
export function getInitials(fullName?: string): string {
if (!fullName) return '';
const names = fullName.trim().split(' ');
if (names.length === 0) return '';
if (names.length === 1) return names[0].charAt(0).toUpperCase();
return `${names[0].charAt(0)}${names[names.length - 1].charAt(0)}`.toUpperCase();
}
export function useInitials(): UseInitialsReturn {
return { getInitials };
}

View File

@@ -0,0 +1,90 @@
import { ref } from 'vue';
export type ToastType = 'green' | 'magenta' | 'blue' | 'gold' | 'red';
export interface NotificationData {
type: ToastType;
title: string;
desc: string;
icon: string;
}
interface Toast extends NotificationData {
id: number;
active: boolean;
flyingOut: boolean;
progress: number;
}
interface HistoryItem extends NotificationData {
id: number;
timestamp: Date;
read: boolean;
}
const toasts = ref<Toast[]>([]);
const history = ref<HistoryItem[]>([]);
const unreadCount = ref(0);
let toastId = 0;
export function useNotifications() {
const notify = (data: NotificationData) => {
// Add to toasts (popup)
const id = toastId++;
const toast: Toast = { id, ...data, active: false, flyingOut: false, progress: 100 };
toasts.value.push(toast);
// Animation logic for toast
setTimeout(() => {
const t = toasts.value.find(x => x.id === id);
if(t) t.active = true;
// Trigger lucide icons update if needed in component
}, 50);
const interval = setInterval(() => {
const t = toasts.value.find(x => x.id === id);
if (!t) { clearInterval(interval); return; }
t.progress -= 0.5;
if (t.progress <= 0) { clearInterval(interval); closeToast(id); }
}, 40);
// Add to history
history.value.unshift({
id: Date.now() + Math.random(),
...data,
timestamp: new Date(),
read: false
});
unreadCount.value++;
};
const closeToast = (id: number) => {
const t = toasts.value.find(x => x.id === id);
if (t) {
t.flyingOut = true;
setTimeout(() => {
toasts.value = toasts.value.filter(x => x.id !== id);
}, 600);
}
};
const markAllRead = () => {
history.value.forEach(n => n.read = true);
unreadCount.value = 0;
};
const clearAll = () => {
history.value = [];
unreadCount.value = 0;
};
return {
toasts,
history,
unreadCount,
notify,
closeToast,
markAllRead,
clearAll
};
}

View File

@@ -0,0 +1,129 @@
import { ref, onMounted } from 'vue';
export type UsePrimaryColorReturn = {
primaryColor: ReturnType<typeof ref<string>>;
updatePrimaryColor: (value: string) => void;
};
const STORAGE_KEY = 'primaryColor';
function setCookie(name: string, value: string, days = 365) {
if (typeof document === 'undefined') return;
const maxAge = days * 24 * 60 * 60;
document.cookie = `${name}=${value};path=/;max-age=${maxAge};SameSite=Lax`;
}
function getStoredPrimaryColor(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem(STORAGE_KEY);
}
function parseColorToRGB(color: string): { r: number; g: number; b: number } | null {
// Supports #rgb, #rrggbb, rgb(), hsl()
color = color.trim().toLowerCase();
// Hex
if (color.startsWith('#')) {
let hex = color.slice(1);
if (hex.length === 3) {
hex = hex.split('').map((c) => c + c).join('');
}
if (hex.length === 6) {
const num = parseInt(hex, 16);
return { r: (num >> 16) & 255, g: (num >> 8) & 255, b: num & 255 };
}
}
// rgb/rgba
if (color.startsWith('rgb')) {
const m = color.match(/rgba?\(([^)]+)\)/);
if (m) {
const [r, g, b] = m[1]
.split(',')
.slice(0, 3)
.map((v) => parseFloat(v.trim()));
return { r, g, b };
}
}
// hsl/hsla
if (color.startsWith('hsl')) {
const m = color.match(/hsla?\(([^)]+)\)/);
if (m) {
const [hStr, sStr, lStr] = m[1].split(',').map((v) => v.trim());
const h = parseFloat(hStr);
const s = parseFloat(sStr) / 100;
const l = parseFloat(lStr) / 100;
// convert HSL to RGB
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m2 = l - c / 2;
let r1 = 0, g1 = 0, b1 = 0;
if (0 <= h && h < 60) [r1, g1, b1] = [c, x, 0];
else if (60 <= h && h < 120) [r1, g1, b1] = [x, c, 0];
else if (120 <= h && h < 180) [r1, g1, b1] = [0, c, x];
else if (180 <= h && h < 240) [r1, g1, b1] = [0, x, c];
else if (240 <= h && h < 300) [r1, g1, b1] = [x, 0, c];
else [r1, g1, b1] = [c, 0, x];
return {
r: Math.round((r1 + m2) * 255),
g: Math.round((g1 + m2) * 255),
b: Math.round((b1 + m2) * 255),
};
}
}
return null;
}
function getContrastColor(bg: string): '#000000' | '#FFFFFF' {
const rgb = parseColorToRGB(bg) || { r: 0, g: 0, b: 0 };
// Relative luminance
const srgb = [rgb.r, rgb.g, rgb.b].map((v) => {
const c = v / 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
});
const L = 0.2126 * srgb[0] + 0.7152 * srgb[1] + 0.0722 * srgb[2];
return L > 0.179 ? '#000000' : '#FFFFFF';
}
export function applyPrimaryColor(value: string): void {
if (typeof document === 'undefined') return;
const root = document.documentElement;
// Apply to global primary variables used by Tailwind theme tokens
root.style.setProperty('--primary', value);
root.style.setProperty('--primary-foreground', getContrastColor(value));
// Align commonly used accent variables in user layout to the same main color
root.style.setProperty('--magenta', value);
root.style.setProperty('--sidebar-primary', value);
root.style.setProperty('--sidebar-primary-foreground', getContrastColor(value));
}
export function initializePrimaryColor(): void {
const saved = getStoredPrimaryColor();
if (saved) applyPrimaryColor(saved);
}
const primaryColorRef = ref<string>(getStoredPrimaryColor() || '#ff007a');
export function usePrimaryColor(): UsePrimaryColorReturn {
onMounted(() => {
const saved = getStoredPrimaryColor();
if (saved) {
primaryColorRef.value = saved;
}
});
function updatePrimaryColor(value: string) {
primaryColorRef.value = value;
if (typeof window !== 'undefined') {
localStorage.setItem(STORAGE_KEY, value);
}
setCookie(STORAGE_KEY, value);
applyPrimaryColor(value);
}
return { primaryColor: primaryColorRef, updatePrimaryColor };
}

View File

@@ -0,0 +1,120 @@
import type { ComputedRef, Ref } from 'vue';
import { computed, ref } from 'vue';
import { qrCode, recoveryCodes, secretKey } from '@/routes/two-factor';
export type UseTwoFactorAuthReturn = {
qrCodeSvg: Ref<string | null>;
manualSetupKey: Ref<string | null>;
recoveryCodesList: Ref<string[]>;
errors: Ref<string[]>;
hasSetupData: ComputedRef<boolean>;
clearSetupData: () => void;
clearErrors: () => void;
clearTwoFactorAuthData: () => void;
fetchQrCode: () => Promise<void>;
fetchSetupKey: () => Promise<void>;
fetchSetupData: () => Promise<void>;
fetchRecoveryCodes: () => Promise<void>;
};
const fetchJson = async <T>(url: string): Promise<T> => {
const response = await fetch(url, {
headers: { Accept: 'application/json' },
});
if (!response.ok) {
throw new Error(`Failed to fetch: ${response.status}`);
}
return response.json();
};
const errors = ref<string[]>([]);
const manualSetupKey = ref<string | null>(null);
const qrCodeSvg = ref<string | null>(null);
const recoveryCodesList = ref<string[]>([]);
const hasSetupData = computed<boolean>(
() => qrCodeSvg.value !== null && manualSetupKey.value !== null,
);
export const useTwoFactorAuth = (): UseTwoFactorAuthReturn => {
const fetchQrCode = async (): Promise<void> => {
try {
const { svg } = await fetchJson<{ svg: string; url: string }>(
qrCode.url(),
);
qrCodeSvg.value = svg;
} catch {
errors.value.push('Failed to fetch QR code');
qrCodeSvg.value = null;
}
};
const fetchSetupKey = async (): Promise<void> => {
try {
const { secretKey: key } = await fetchJson<{ secretKey: string }>(
secretKey.url(),
);
manualSetupKey.value = key;
} catch {
errors.value.push('Failed to fetch a setup key');
manualSetupKey.value = null;
}
};
const clearSetupData = (): void => {
manualSetupKey.value = null;
qrCodeSvg.value = null;
clearErrors();
};
const clearErrors = (): void => {
errors.value = [];
};
const clearTwoFactorAuthData = (): void => {
clearSetupData();
clearErrors();
recoveryCodesList.value = [];
};
const fetchRecoveryCodes = async (): Promise<void> => {
try {
clearErrors();
recoveryCodesList.value = await fetchJson<string[]>(
recoveryCodes.url(),
);
} catch {
errors.value.push('Failed to fetch recovery codes');
recoveryCodesList.value = [];
}
};
const fetchSetupData = async (): Promise<void> => {
try {
clearErrors();
await Promise.all([fetchQrCode(), fetchSetupKey()]);
} catch {
qrCodeSvg.value = null;
manualSetupKey.value = null;
}
};
return {
qrCodeSvg,
manualSetupKey,
recoveryCodesList,
errors,
hasSetupData,
clearSetupData,
clearErrors,
clearTwoFactorAuthData,
fetchQrCode,
fetchSetupKey,
fetchSetupData,
fetchRecoveryCodes,
};
};

View File

@@ -0,0 +1,180 @@
import { ref } from 'vue';
import { csrfFetch } from '@/utils/csrfFetch';
export interface VaultBalances {
balance: string;
vault_balance: string;
vault_balances: Record<string, string>;
currency: string | null;
}
export function useVault() {
const loading = ref(false);
const error = ref<string | null>(null);
const balances = ref<VaultBalances | null>(null);
const transfers = ref<any>(null);
const pinRequired = ref(false);
const lockedUntil = ref<string | null>(null);
const sessionPin = ref<string | null>(null);
async function load(perPage = 10) {
loading.value = true;
error.value = null;
try {
const resp = await fetch(`/api/wallet/vault?per_page=${perPage}`, {
headers: { 'Accept': 'application/json' }
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const json = await resp.json();
balances.value = {
balance: json.balance,
vault_balance: json.vault_balance,
vault_balances: json.vault_balances ?? { BTX: json.vault_balance },
currency: json.currency,
};
transfers.value = json.transfers;
} catch (e: any) {
error.value = e?.message || 'Failed to load vault';
} finally {
loading.value = false;
}
}
function uuidv4(): string {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return (crypto as any).randomUUID();
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
async function deposit(amount: string, currency = 'BTX', pin?: string) {
return doAction('/api/wallet/vault/deposit', amount, currency, pin);
}
async function withdraw(amount: string, currency = 'BTX', pin?: string) {
return doAction('/api/wallet/vault/withdraw', amount, currency, pin);
}
async function doAction(url: string, amount: string, currency: string, pin?: string) {
loading.value = true;
error.value = null;
pinRequired.value = false;
lockedUntil.value = null;
try {
const usePin = pin ?? sessionPin.value;
if (!usePin) {
pinRequired.value = true;
throw new Error('PIN required');
}
const body = { amount, currency, pin: usePin, idempotency_key: uuidv4() };
const resp = await csrfFetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify(body),
});
if (resp.status === 423) {
const j = await resp.json().catch(() => ({}));
pinRequired.value = true;
lockedUntil.value = j?.locked_until || null;
throw new Error(j?.message || 'PIN required');
}
if (!resp.ok) {
const j = await resp.json().catch(() => ({}));
throw new Error(j?.message || `HTTP ${resp.status}`);
}
const json = await resp.json();
if (json?.balances) {
balances.value = {
balance: json.balances.balance,
vault_balance: json.balances.vault_balance,
vault_balances: json.balances.vault_balances ?? { BTX: json.balances.vault_balance },
currency: balances.value?.currency || 'BTX',
};
} else {
await load();
}
return json;
} catch (e: any) {
error.value = e?.message || 'Vault action failed';
throw e;
} finally {
loading.value = false;
}
}
async function verifyPin(pin: string) {
error.value = null;
try {
const resp = await csrfFetch('/api/wallet/vault/pin/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({ pin }),
});
if (!resp.ok) {
const j = await resp.json().catch(() => ({}));
if (resp.status === 423) lockedUntil.value = j?.locked_until || null;
throw new Error(j?.message || `HTTP ${resp.status}`);
}
sessionPin.value = pin;
pinRequired.value = false;
lockedUntil.value = null;
return await resp.json();
} catch (e: any) {
error.value = e?.message || 'PIN verification failed';
throw e;
}
}
function clearSessionPin() {
sessionPin.value = null;
}
async function setPin(pin: string, current_pin?: string) {
error.value = null;
const payload: any = { pin };
if (current_pin) payload.current_pin = current_pin;
const resp = await csrfFetch('/api/wallet/vault/pin/set', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const j = await resp.json().catch(() => ({}));
throw new Error(j?.message || `HTTP ${resp.status}`);
}
sessionPin.value = pin;
return await resp.json();
}
return {
loading,
error,
balances,
transfers,
pinRequired,
lockedUntil,
load,
deposit,
withdraw,
verifyPin,
setPin,
clearSessionPin,
};
}

162
resources/js/i18n/de.json Normal file
View File

@@ -0,0 +1,162 @@
{
"nav": {
"lobby": "Lobby",
"liveCasino": "Live Casino",
"slots": "Slots",
"wallet": "Wallet",
"bonuses": "Boni",
"vip": "VIP Club",
"guilds": "Gilden",
"responsible": "Verantwortung",
"faq": "FAQ",
"settings": "Einstellungen",
"public_profile": "Öffentliches Profil",
"account_settings": "Kontoeinstellungen"
},
"topbar": {
"welcome_guest": "Willkommen bei BetiX",
"welcome_user": "Willkommen zurück, {name}",
"vault": "Vault",
"search": "Suche",
"deposit": "Einzahlen"
},
"footer": {
"legal": {
"terms": "Allgemeine Geschäftsbedingungen",
"cookies": "Cookie-Richtlinie",
"privacy": "Datenschutz",
"bonusPolicy": "Bonus-Richtlinie",
"disputes": "Streitbeilegung",
"responsible": "Verantwortungsvolles Spielen",
"aml": "AML-Richtlinie",
"risks": "Risikohinweise"
}
},
"wallet": { "title": "Wallet", "subtitle": "Verwalte dein Guthaben, Ein- und Auszahlungen." },
"vault": {
"title": "Vault",
"toVault": "In Vault",
"fromVault": "Aus Vault",
"amount": { "placeholder": "z. B. 6200 oder 6200.0000" }
},
"bonuses": {
"title": "Aktionen",
"tabs": { "available": "Verfügbar", "active": "Aktiv", "history": "Historie" },
"featured": "FEATURED",
"minDeposit": "Min. Einzahlung",
"claim": "Jetzt sichern",
"activate": "Aktivieren",
"errorLoad": "Boni konnten nicht geladen werden",
"noActive": "Keine aktiven Boni. Prüfe die verfügbaren Angebote!",
"noHistory": "Noch keine Historie.",
"ended": "Beendet",
"wagerProgress": "Umsatzfortschritt",
"wageredOf": "{wagered} / {total} umgesetzt",
"bonus": "Bonus"
},
"notif": { "success": "Erfolg", "error": "Fehler" },
"sections": {
"gaming": "Gaming",
"user": "Benutzer",
"supportInfo": "Support & Info"
},
"policy": {
"toc": "Inhalt",
"lastUpdated": "Zuletzt aktualisiert"
},
"common": {
"guest": "Gast",
"loginToPlay": "Bitte einloggen zum Spielen"
},
"auth": {
"login": "Einloggen",
"register": "Registrieren",
"logout": "Abmelden"
},
"search": {
"placeholder": "Spiele, @Nutzer, Anbieter suchen...",
"slots": "Spiele",
"users": "Spieler",
"providers": "Anbieter",
"noResults": "Keine Ergebnisse gefunden",
"hint": "@ für Nutzer, Anbietername für Provider"
},
"notifications": {
"title": "Benachrichtigungen",
"clearAll": "Alle löschen",
"empty": "Noch keine Benachrichtigungen",
"markRead": "Alle als gelesen markieren"
},
"dashboard": {
"welcome_offer": "Willkommensangebot",
"claim_now": "Jetzt sichern",
"read_terms": "AGB lesen",
"search_placeholder": "Spiele suchen...",
"originals": "Originals",
"loading": "Lädt...",
"reload": "Erneut laden",
"no_games": "Keine Spiele gefunden.",
"no_slots": "Keine Slots gefunden.",
"no_live": "Keine Live-Spiele gefunden.",
"popular_slots": "Beliebte Slots",
"show_all": "Alle anzeigen",
"live_wins": "Live-Gewinne",
"live_feed": "Live-Feed",
"won_in": "gewonnen in",
"new_releases": "Neuerscheinungen",
"live_casino": "Live Casino",
"hall_of_fame": "Ruhmeshalle",
"recently_played": "Zuletzt gespielt",
"error_loading": "Fehler beim Laden der Spiele."
},
"vip": {
"your_rank": "DEIN RANG",
"max": "MAX",
"unlocked": "Freigeschaltet",
"current": "Aktuell",
"locked": "Gesperrt",
"benefits": "Vorteile",
"requires": "Benötigt",
"reward_claimed": "Belohnung erhalten"
},
"bonus": {
"promo_title": "Promo Code einlösen",
"promo_placeholder": "Code eingeben (z. B. WELCOME10)",
"promo_redeem": "Einlösen",
"promo_redeeming": "Wird eingelöst…",
"unlock_at": "Freischaltung bei"
},
"guilds": {
"title": "Gilden",
"subtitle_new": "Erstelle eine Gilde oder tritt einer mit einem Einladungscode bei.",
"subtitle_member": "Verwalte deine Gilde, lade Mitglieder ein und sieh deine Mitgliederliste.",
"tab_active": "Aktiv",
"create": "Gilde erstellen",
"join": "Gilde beitreten",
"name": "Name",
"tag": "Kürzel",
"logo_url": "Logo-URL",
"description": "Beschreibung",
"invite_code": "Einladungscode",
"appearance": "Erscheinungsbild",
"members": "Mitglieder",
"sorted_by_wager": "Sortiert nach Einsatz",
"wagered": "Eingesetzt",
"kick": "Mitglied entfernen",
"leave": "Gilde verlassen",
"top_title": "Bestenliste",
"top_desc": "Die am höchsten bewerteten Gilden nach Punkten. Top-Gilden erhalten Boni.",
"rank": "Rang",
"guild": "Gilde",
"points": "Punkte",
"no_guilds": "Noch keine Gilden gefunden.",
"members_col": "Mitglieder"
},
"trophy": {
"title": "Trophäenraum",
"title_user": "Trophäenraum von {name}",
"achievements": "Errungenschaften",
"unlocked_label": "Freigeschaltet",
"locked_label": "Gesperrt"
}
}

162
resources/js/i18n/en.json Normal file
View File

@@ -0,0 +1,162 @@
{
"nav": {
"lobby": "Lobby",
"liveCasino": "Live Casino",
"slots": "Slots",
"wallet": "Wallet",
"bonuses": "Bonuses",
"vip": "VIP Club",
"guilds": "Guilds",
"responsible": "Responsible",
"faq": "FAQ",
"settings": "Settings",
"public_profile": "Public Profile",
"account_settings": "Account Settings"
},
"topbar": {
"welcome_guest": "Welcome to BetiX",
"welcome_user": "Welcome back, {name}",
"vault": "Vault",
"search": "Search",
"deposit": "Deposit"
},
"footer": {
"legal": {
"terms": "Terms and Conditions",
"cookies": "Cookie Policy",
"privacy": "Privacy Policy",
"bonusPolicy": "Bonus Policy",
"disputes": "Dispute Resolution Policy",
"responsible": "Responsible Gaming",
"aml": "AML Policy",
"risks": "Risk Warnings"
}
},
"wallet": { "title": "Wallet", "subtitle": "Manage your funds, deposit, and withdraw." },
"vault": {
"title": "Vault",
"toVault": "To Vault",
"fromVault": "From Vault",
"amount": { "placeholder": "e.g. 6200 or 6200.0000" }
},
"bonuses": {
"title": "Promotions",
"tabs": { "available": "Available", "active": "Active", "history": "History" },
"featured": "FEATURED",
"minDeposit": "Min. Deposit",
"claim": "Claim Now",
"activate": "Activate",
"errorLoad": "Failed to load bonuses",
"noActive": "No active bonuses. Check available offers!",
"noHistory": "No history yet.",
"ended": "Ended",
"wagerProgress": "Wager Progress",
"wageredOf": "{wagered} / {total} wagered",
"bonus": "Bonus"
},
"notif": { "success": "Success", "error": "Error", "dropdown": { "title": "Notifications", "clearAll": "Clear All", "empty": "No notifications yet" } },
"sections": {
"gaming": "Gaming",
"user": "User",
"supportInfo": "Support & Info"
},
"policy": {
"toc": "Contents",
"lastUpdated": "Last updated"
},
"common": {
"guest": "Guest",
"loginToPlay": "Please login to play"
},
"auth": {
"login": "Log In",
"register": "Sign Up",
"logout": "Logout"
},
"search": {
"placeholder": "Search games, @users, providers...",
"slots": "Games",
"users": "Players",
"providers": "Providers",
"noResults": "No results found",
"hint": "Type @ to search users, type provider name to filter"
},
"notifications": {
"title": "Notifications",
"clearAll": "Clear All",
"empty": "No notifications yet",
"markRead": "Mark all read"
},
"dashboard": {
"welcome_offer": "Welcome Offer",
"claim_now": "Claim Now",
"read_terms": "Read Terms",
"search_placeholder": "Search games...",
"originals": "Originals",
"loading": "Loading...",
"reload": "Retry",
"no_games": "No games found.",
"no_slots": "No slots found.",
"no_live": "No live games found.",
"popular_slots": "Popular Slots",
"show_all": "Show All",
"live_wins": "Live Wins",
"live_feed": "Live Feed",
"won_in": "won in",
"new_releases": "New Releases",
"live_casino": "Live Casino",
"hall_of_fame": "Hall of Fame",
"recently_played": "Recently Played",
"error_loading": "Error loading games."
},
"vip": {
"your_rank": "YOUR RANK",
"max": "MAX",
"unlocked": "Unlocked",
"current": "Current",
"locked": "Locked",
"benefits": "Benefits",
"requires": "Requires",
"reward_claimed": "Reward Claimed"
},
"bonus": {
"promo_title": "Redeem Promo Code",
"promo_placeholder": "Enter your code (e.g. WELCOME10)",
"promo_redeem": "Redeem",
"promo_redeeming": "Redeeming...",
"unlock_at": "Unlock at"
},
"guilds": {
"title": "Guilds",
"subtitle_new": "Create a guild or join one with an invite code.",
"subtitle_member": "Manage your guild, invite members, and see your roster.",
"tab_active": "Active",
"create": "Create a Guild",
"join": "Join a Guild",
"name": "Name",
"tag": "Tag",
"logo_url": "Logo URL",
"description": "Description",
"invite_code": "Invite code",
"appearance": "Appearance",
"members": "Members",
"sorted_by_wager": "Sorted by Wager",
"wagered": "Wagered",
"kick": "Kick Member",
"leave": "Leave Guild",
"top_title": "Leaderboard",
"top_desc": "Highest ranked guilds by points. Top guilds receive bonuses.",
"rank": "Rank",
"guild": "Guild",
"points": "Points",
"no_guilds": "No guilds found yet.",
"members_col": "Members"
},
"trophy": {
"title": "Trophy Room",
"title_user": "{name}'s Trophy Room",
"achievements": "Achievements",
"unlocked_label": "Unlocked",
"locked_label": "Locked"
}
}

68
resources/js/i18n/es.json Normal file
View File

@@ -0,0 +1,68 @@
{
"nav": {
"lobby": "Lobby",
"liveCasino": "Casino en Vivo",
"slots": "Tragamonedas",
"wallet": "Billetera",
"bonuses": "Bonos",
"vip": "Club VIP",
"guilds": "Gremios",
"responsible": "Juego responsable",
"faq": "FAQ"
},
"topbar": {
"welcome_guest": "Bienvenido a BetiX",
"welcome_user": "Bienvenido de nuevo, {name}",
"vault": "Vault",
"search": "Buscar",
"deposit": "Depositar"
},
"footer": {
"legal": {
"terms": "Términos y Condiciones",
"cookies": "Política de Cookies",
"privacy": "Política de Privacidad",
"bonusPolicy": "Política de Bonos",
"disputes": "Resolución de Disputas",
"responsible": "Juego Responsable",
"aml": "Política AML",
"risks": "Advertencias de Riesgo"
}
},
"wallet": { "title": "Billetera", "subtitle": "Administra tus fondos, depósitos y retiros." },
"vault": {
"title": "Vault",
"toVault": "A Vault",
"fromVault": "Desde Vault",
"amount": { "placeholder": "p. ej., 6200 o 6200.0000" }
},
"bonuses": {
"title": "Promociones",
"tabs": { "available": "Disponibles", "active": "Activos", "history": "Historial" },
"featured": "DESTACADO",
"minDeposit": "Depósito mín.",
"claim": "Reclamar",
"activate": "Activar",
"errorLoad": "No se pudieron cargar los bonos",
"noActive": "No hay bonos activos. ¡Revisa las ofertas disponibles!",
"noHistory": "Aún no hay historial.",
"ended": "Finalizado",
"wagerProgress": "Progreso de apuesta",
"wageredOf": "{wagered} / {total} apostado",
"bonus": "Bono"
},
"notif": { "success": "Éxito", "error": "Error", "dropdown": { "title": "Notificaciones", "clearAll": "Borrar todo", "empty": "Aún no hay notificaciones" } },
"sections": {
"gaming": "Juegos",
"user": "Usuario",
"supportInfo": "Soporte e Información"
},
"policy": {
"toc": "Contenido",
"lastUpdated": "Última actualización"
},
"common": {
"guest": "Invitado",
"loginToPlay": "Inicia sesión para jugar"
}
}

View File

@@ -0,0 +1,43 @@
import { createI18n } from 'vue-i18n';
// Start with English synchronously to avoid FOUC; other locales lazy-loaded
async function loadLocaleMessages(locale: string) {
const norm = locale.replace('-', '_');
switch (norm) {
case 'de':
return (await import('./de.json')).default;
case 'es':
return (await import('./es.json')).default;
case 'pt_BR':
case 'pt-br':
return (await import('./pt_BR.json')).default;
case 'tr':
return (await import('./tr.json')).default;
case 'pl':
return (await import('./pl.json')).default;
default:
return (await import('./en.json')).default;
}
}
export const i18n = createI18n({
legacy: false,
locale: 'en',
fallbackLocale: 'en',
messages: {},
});
export async function initI18n(startLocale: string) {
const msgs = await loadLocaleMessages(startLocale || 'en');
i18n.global.setLocaleMessage(startLocale || 'en', msgs);
i18n.global.locale.value = startLocale || 'en';
}
export async function setLocale(locale: string) {
const norm = locale.replace('-', '_');
if (!i18n.global.getLocaleMessage(norm) || Object.keys(i18n.global.getLocaleMessage(norm)).length === 0) {
const msgs = await loadLocaleMessages(norm);
i18n.global.setLocaleMessage(norm, msgs);
}
i18n.global.locale.value = norm;
}

40
resources/js/i18n/pl.json Normal file
View File

@@ -0,0 +1,40 @@
{
"nav": {
"lobby": "Lobby",
"liveCasino": "Kasyno na żywo",
"slots": "Sloty",
"wallet": "Portfel",
"bonuses": "Bonusy",
"vip": "Klub VIP",
"guilds": "Gildie",
"responsible": "Odpowiedzialna gra",
"faq": "FAQ"
},
"topbar": {
"welcome_guest": "Witamy w BetiX",
"welcome_user": "Witamy ponownie, {name}",
"vault": "Sejf",
"search": "Szukaj",
"deposit": "Wpłata"
},
"footer": {
"legal": {
"terms": "Regulamin",
"cookies": "Polityka Cookies",
"privacy": "Polityka Prywatności",
"bonusPolicy": "Polityka Bonusów",
"disputes": "Polityka Rozwiązywania Sporów",
"responsible": "Odpowiedzialna Gra",
"aml": "Polityka AML",
"risks": "Ostrzeżenia o Ryzyku"
}
},
"wallet": { "title": "Portfel", "subtitle": "Zarządzaj środkami, wpłatami i wypłatami." },
"vault": {
"title": "Sejf",
"toVault": "Do sejfu",
"fromVault": "Z sejfu",
"amount": { "placeholder": "np. 6200 lub 6200.0000" }
},
"notif": { "success": "Sukces", "error": "Błąd" }
}

View File

@@ -0,0 +1,55 @@
{
"nav": {
"lobby": "Lobby",
"liveCasino": "Cassino ao Vivo",
"slots": "Caçaníqueis",
"wallet": "Carteira",
"bonuses": "Bônus",
"vip": "Clube VIP",
"guilds": "Guildas",
"responsible": "Jogo responsável",
"faq": "FAQ"
},
"topbar": {
"welcome_guest": "Bem-vindo à BetiX",
"welcome_user": "Bem-vindo de volta, {name}",
"vault": "Cofre",
"search": "Buscar",
"deposit": "Depositar"
},
"footer": {
"legal": {
"terms": "Termos e Condições",
"cookies": "Política de Cookies",
"privacy": "Política de Privacidade",
"bonusPolicy": "Política de Bônus",
"disputes": "Política de Resolução de Disputas",
"responsible": "Jogo Responsável",
"aml": "Política AML",
"risks": "Avisos de Risco"
}
},
"wallet": { "title": "Carteira", "subtitle": "Gerencie seus fundos, depósitos e saques." },
"vault": {
"title": "Cofre",
"toVault": "Para o Cofre",
"fromVault": "Do Cofre",
"amount": { "placeholder": "ex.: 6200 ou 6200.0000" }
},
"bonuses": {
"title": "Promoções",
"tabs": { "available": "Disponíveis", "active": "Ativos", "history": "Histórico" },
"featured": "DESTAQUE",
"minDeposit": "Depósito mín.",
"claim": "Resgatar",
"activate": "Ativar",
"errorLoad": "Falha ao carregar os bônus",
"noActive": "Não há bônus ativos. Veja as ofertas disponíveis!",
"noHistory": "Ainda sem histórico.",
"ended": "Encerrado",
"wagerProgress": "Progresso de aposta",
"wageredOf": "{wagered} / {total} apostado",
"bonus": "Bônus"
},
"notif": { "success": "Sucesso", "error": "Erro" }
}

55
resources/js/i18n/tr.json Normal file
View File

@@ -0,0 +1,55 @@
{
"nav": {
"lobby": "Lobi",
"liveCasino": "Canlı Casino",
"slots": "Slotlar",
"wallet": "Cüzdan",
"bonuses": "Bonuslar",
"vip": "VIP Kulübü",
"guilds": "Loncalar",
"responsible": "Sorumlu Oyun",
"faq": "SSS"
},
"topbar": {
"welcome_guest": "BetiX'e Hoş Geldiniz",
"welcome_user": "Tekrar hoş geldin, {name}",
"vault": "Kasa",
"search": "Ara",
"deposit": "Yatır"
},
"footer": {
"legal": {
"terms": "Şartlar ve Koşullar",
"cookies": "Çerez Politikası",
"privacy": "Gizlilik Politikası",
"bonusPolicy": "Bonus Politikası",
"disputes": "Uyuşmazlık Çözümü Politikası",
"responsible": "Sorumlu Oyun",
"aml": "AML Politikası",
"risks": "Risk Uyarıları"
}
},
"wallet": { "title": "Cüzdan", "subtitle": "Bakiyeni yönet, para yatır ve çek." },
"vault": {
"title": "Kasa",
"toVault": "Kasaya",
"fromVault": "Kasadan",
"amount": { "placeholder": "örn. 6200 veya 6200.0000" }
},
"bonuses": {
"title": "Promosyonlar",
"tabs": { "available": "Mevcut", "active": "Aktif", "history": "Geçmiş" },
"featured": "ÖNE ÇIKAN",
"minDeposit": "Min. Yatırım",
"claim": "Hemen Al",
"activate": "Aktifleştir",
"errorLoad": "Bonuslar yüklenemedi",
"noActive": "Aktif bonus yok. Mevcut tekliflere göz at!",
"noHistory": "Henüz geçmiş yok.",
"ended": "Bitti",
"wagerProgress": "Çevrim İlerleme",
"wageredOf": "{wagered} / {total} çevrildi",
"bonus": "Bonus"
},
"notif": { "success": "Başarılı", "error": "Hata" }
}

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
import AppLayout from '@/layouts/app/AppSidebarLayout.vue';
import type { BreadcrumbItem } from '@/types';
type Props = {
breadcrumbs?: BreadcrumbItem[];
};
withDefaults(defineProps<Props>(), {
breadcrumbs: () => [],
});
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<slot />
</AppLayout>
</template>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import AuthLayout from '@/layouts/auth/AuthSimpleLayout.vue';
defineProps<{
title?: string;
description?: string;
}>();
</script>
<template>
<AuthLayout :title="title" :description="description">
<slot />
</AuthLayout>
</template>

View File

@@ -0,0 +1,88 @@
<script setup lang="ts">
import { Link, usePage } from '@inertiajs/vue3';
import { computed, onMounted, nextTick } from 'vue';
const page = usePage();
const user = computed(() => (page.props as any).auth?.user || null);
onMounted(() => {
nextTick(() => { if ((window as any).lucide) (window as any).lucide.createIcons(); });
});
</script>
<template>
<div class="admin-layout">
<aside class="sidebar">
<div class="brand">
<i data-lucide="shield"></i>
<span>Admin</span>
</div>
<nav class="nav">
<Link href="/admin" class="nav-item" :class="{ active: $page.url === '/admin' }">
<i data-lucide="layout-dashboard"></i>
<span>Dashboard</span>
</Link>
<div class="nav-label">Wallets</div>
<Link href="/admin/wallets/settings" class="nav-item" :class="{ active: $page.url.startsWith('/admin/wallets/settings') }">
<i data-lucide="settings"></i>
<span>Einstellungen</span>
</Link>
<Link href="/admin/payments/settings" class="nav-item" :class="{ active: $page.url.startsWith('/admin/payments/settings') }">
<i data-lucide="credit-card"></i>
<span>Zahlungen</span>
</Link>
<div class="nav-label">Weitere</div>
<Link href="/admin/promos" class="nav-item" :class="{ active: $page.url.startsWith('/admin/promos') }">
<i data-lucide="gift"></i>
<span>Promos</span>
</Link>
<Link href="/admin/support" class="nav-item" :class="{ active: $page.url.startsWith('/admin/support') }">
<i data-lucide="message-square"></i>
<span>Support</span>
</Link>
</nav>
<div class="sidebar-foot">
<div class="user">
<i data-lucide="user"></i>
<span>{{ user?.username || user?.name || 'Admin' }}</span>
</div>
<Link href="/" class="back-link"><i data-lucide="arrow-left"></i> Zurück</Link>
</div>
</aside>
<main class="main">
<header class="topbar">
<div class="title"><slot name="title">Admin</slot></div>
<div class="actions"><slot name="actions" /></div>
</header>
<div class="container">
<slot />
</div>
</main>
</div>
</template>
<style scoped>
.admin-layout { display: grid; grid-template-columns: 260px 1fr; min-height: 100vh; background: #0b0b0c; color: #e5e7eb; }
.sidebar { background: #0f0f10; border-right: 1px solid #1f1f22; display: flex; flex-direction: column; }
.brand { height: 56px; display: flex; align-items: center; gap: 10px; padding: 0 16px; font-weight: 800; letter-spacing: .5px; border-bottom: 1px solid #1f1f22; }
.brand i { width: 18px; height: 18px; }
.nav { padding: 12px; display: flex; flex-direction: column; gap: 6px; }
.nav-item { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 10px; color: #cbd5e1; text-decoration: none; }
.nav-item i { width: 18px; height: 18px; }
.nav-item:hover { background: #151518; color: #fff; }
.nav-item.active { background: #1b1b20; color: #fff; border: 1px solid #23232a; }
.nav-label { font-size: 11px; color: #9ca3af; font-weight: 700; padding: 8px 12px 0; text-transform: uppercase; letter-spacing: 1px; }
.sidebar-foot { margin-top: auto; padding: 12px; border-top: 1px solid #1f1f22; display: grid; gap: 8px; }
.user { display: flex; align-items: center; gap: 8px; color: #9ca3af; }
.back-link { color: #cbd5e1; text-decoration: none; display: inline-flex; align-items: center; gap: 8px; }
.back-link:hover { color: #fff; }
.main { display: flex; flex-direction: column; }
.topbar { height: 56px; display: flex; align-items: center; justify-content: space-between; padding: 0 16px; border-bottom: 1px solid #1f1f22; background: #0f0f10; }
.title { font-weight: 800; }
.container { padding: 16px; }
@media (max-width: 960px) {
.admin-layout { grid-template-columns: 1fr; }
.sidebar { display: none; }
}
</style>

View File

@@ -0,0 +1,729 @@
<script setup lang="ts">
import { Link, router, usePage } from '@inertiajs/vue3';
import { computed, onMounted, nextTick, ref, watch } from 'vue';
const page = usePage();
const user = computed(() => (page.props as any).auth?.user || null);
const mobileOpen = ref(false);
const isLoading = ref(false);
const loadingProgress = ref(0);
const pageVisible = ref(false);
let loadTimer: any = null;
const navSections = [
{
label: 'Overview',
items: [
{ label: 'Dashboard', icon: 'layout-dashboard', href: '/admin/casino', segment: 'casino' },
]
},
{
label: 'Users',
items: [
{ label: 'Nutzer', icon: 'users', href: '/admin/users', segment: 'users' },
]
},
{
label: 'Content',
items: [
{ label: 'Live Chat', icon: 'message-square', href: '/admin/chat', segment: 'chat' },
{ label: 'Promos', icon: 'ticket', href: '/admin/promos', segment: 'promos' },
{ label: 'Support', icon: 'life-buoy', href: '/admin/support', segment: 'support' },
]
},
{
label: 'Moderation',
items: [
{ label: 'Chat Reports', icon: 'flag', href: '/admin/reports/chat', segment: 'reports/chat' },
{ label: 'Profil Reports', icon: 'shield-alert', href: '/admin/reports/profiles', segment: 'reports/profiles' },
]
},
{
label: 'Payments',
items: [
{ label: 'Payments', icon: 'credit-card', href: '/admin/payments/settings', segment: 'payments' },
{ label: 'Wallets', icon: 'wallet', href: '/admin/wallets/settings', segment: 'wallets' },
]
},
{
label: 'Settings',
items: [
{ label: 'Site Settings', icon: 'settings', href: '/admin/settings/site', segment: 'settings/site' },
{ label: 'VPN & GeoBlock', icon: 'shield-ban', href: '/admin/settings/geo', segment: 'settings/geo' },
]
},
];
const isActive = (segment: string) => page.url.startsWith(`/admin/${segment}`);
const isAnyActive = (items: any[]) => items.some(i => isActive(i.segment));
function startLoading() {
isLoading.value = true;
loadingProgress.value = 0;
pageVisible.value = false;
loadTimer = setInterval(() => {
if (loadingProgress.value < 85) loadingProgress.value += Math.random() * 18;
}, 120);
}
function finishLoading() {
clearInterval(loadTimer);
loadingProgress.value = 100;
setTimeout(() => {
isLoading.value = false;
loadingProgress.value = 0;
pageVisible.value = true;
nextTick(() => { if ((window as any).lucide) (window as any).lucide.createIcons(); });
}, 300);
}
onMounted(() => {
pageVisible.value = true;
router.on('start', startLoading);
router.on('finish', finishLoading);
nextTick(() => { if ((window as any).lucide) (window as any).lucide.createIcons(); });
});
</script>
<template>
<div class="al-root" :class="{ 'mobile-open': mobileOpen }">
<!-- Animated background -->
<div class="al-bg" aria-hidden="true">
<div class="al-bg-blob al-bg-blob-1"></div>
<div class="al-bg-blob al-bg-blob-2"></div>
<div class="al-bg-blob al-bg-blob-3"></div>
<div class="al-bg-grid"></div>
</div>
<!-- Loading bar -->
<div class="al-progress-bar" :class="{ visible: isLoading }">
<div class="al-progress-fill" :style="{ width: loadingProgress + '%' }"></div>
<div class="al-progress-glow"></div>
</div>
<!-- Loading overlay -->
<Transition name="fade">
<div v-if="isLoading" class="al-loading-overlay">
<div class="al-spinner">
<div class="al-spinner-ring"></div>
<div class="al-spinner-ring al-spinner-ring-2"></div>
</div>
</div>
</Transition>
<!-- Mobile overlay -->
<Transition name="fade">
<div v-if="mobileOpen" class="al-overlay" @click="mobileOpen = false"></div>
</Transition>
<!-- Sidebar -->
<aside class="al-sidebar">
<div class="al-sidebar-inner">
<!-- Brand -->
<div class="al-brand">
<div class="al-brand-icon">
<i data-lucide="swords"></i>
<div class="al-brand-icon-glow"></div>
</div>
<div class="al-brand-text">
<span class="al-brand-name">Casino Admin</span>
<span class="al-brand-sub">Control Panel</span>
</div>
</div>
<!-- Nav -->
<nav class="al-nav">
<template v-for="section in navSections" :key="section.label">
<div class="al-nav-section">
<div class="al-nav-label">{{ section.label }}</div>
<Link
v-for="item in section.items"
:key="item.href"
:href="item.href"
class="al-nav-item"
:class="{ active: isActive(item.segment) }"
@click="mobileOpen = false"
>
<span class="al-nav-item-bg"></span>
<i :data-lucide="item.icon" class="al-nav-icon"></i>
<span class="al-nav-text">{{ item.label }}</span>
<span v-if="isActive(item.segment)" class="al-active-pip"></span>
</Link>
</div>
</template>
</nav>
<!-- Footer -->
<div class="al-foot">
<div class="al-user">
<div class="al-user-avatar">
<img v-if="user?.avatar_url" :src="user.avatar_url" alt="">
<span v-else>{{ (user?.username || 'A')[0].toUpperCase() }}</span>
</div>
<div class="al-user-info">
<span class="al-user-name">{{ user?.username || 'Admin' }}</span>
<span class="al-user-badge">Administrator</span>
</div>
<div class="al-user-status"></div>
</div>
<Link href="/" class="al-back-btn">
<i data-lucide="arrow-left"></i>
<span>Zurück zur Site</span>
</Link>
</div>
</div>
</aside>
<!-- Main -->
<div class="al-main">
<!-- Topbar -->
<header class="al-topbar">
<div class="al-topbar-left">
<button class="al-menu-btn" @click="mobileOpen = !mobileOpen">
<i data-lucide="menu"></i>
</button>
<div class="al-breadcrumb">
<span class="al-breadcrumb-icon"><i data-lucide="layout-dashboard"></i></span>
<span class="al-breadcrumb-sep">/</span>
<h1 class="al-page-title">
<slot name="title">Dashboard</slot>
</h1>
</div>
</div>
<div class="al-topbar-right">
<slot name="actions" />
<Link href="/" class="al-site-btn">
<i data-lucide="external-link"></i>
<span>Live Site</span>
</Link>
</div>
</header>
<!-- Page content -->
<Transition name="page">
<div v-if="pageVisible" class="al-content">
<slot />
</div>
</Transition>
</div>
</div>
</template>
<style scoped>
/* ─── Root ─── */
.al-root {
display: grid;
grid-template-columns: 260px 1fr;
min-height: 100vh;
background: #06060a;
color: #e4e4e7;
font-family: 'Inter', sans-serif;
position: relative;
overflow-x: hidden;
}
/* ─── Animated Background ─── */
.al-bg {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
overflow: hidden;
}
.al-bg-blob {
position: absolute;
border-radius: 50%;
filter: blur(80px);
opacity: 0.06;
}
.al-bg-blob-1 {
width: 600px; height: 600px;
background: radial-gradient(circle, #df006a, transparent);
top: -200px; left: -100px;
animation: blob-drift 18s ease-in-out infinite;
}
.al-bg-blob-2 {
width: 500px; height: 500px;
background: radial-gradient(circle, #7c3aed, transparent);
bottom: -100px; right: -100px;
animation: blob-drift 24s ease-in-out infinite reverse;
}
.al-bg-blob-3 {
width: 400px; height: 400px;
background: radial-gradient(circle, #0ea5e9, transparent);
top: 50%; left: 50%;
transform: translate(-50%, -50%);
animation: blob-drift 30s ease-in-out infinite 6s;
}
@keyframes blob-drift {
0%, 100% { transform: translate(0, 0) scale(1); }
33% { transform: translate(60px, -40px) scale(1.1); }
66% { transform: translate(-40px, 60px) scale(0.95); }
}
.al-bg-grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(255,255,255,0.015) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.015) 1px, transparent 1px);
background-size: 40px 40px;
}
/* ─── Progress Bar ─── */
.al-progress-bar {
position: fixed;
top: 0; left: 0; right: 0;
height: 2px;
z-index: 9999;
opacity: 0;
transition: opacity 0.2s;
background: transparent;
}
.al-progress-bar.visible { opacity: 1; }
.al-progress-fill {
height: 100%;
background: linear-gradient(90deg, #df006a, #f472b6, #df006a);
background-size: 200% 100%;
animation: shimmer-progress 1.2s linear infinite;
transition: width 0.15s ease;
border-radius: 0 2px 2px 0;
}
@keyframes shimmer-progress {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.al-progress-glow {
position: absolute;
right: 0; top: -2px;
width: 80px; height: 6px;
background: rgba(223,0,106,0.6);
filter: blur(6px);
border-radius: 50%;
}
/* ─── Loading Overlay ─── */
.al-loading-overlay {
position: fixed;
inset: 0;
z-index: 500;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.al-spinner {
position: relative;
width: 44px; height: 44px;
}
.al-spinner-ring {
position: absolute;
inset: 0;
border-radius: 50%;
border: 2px solid transparent;
border-top-color: #df006a;
animation: spin 0.9s linear infinite;
}
.al-spinner-ring-2 {
inset: 6px;
border-top-color: #f472b6;
animation-duration: 0.6s;
animation-direction: reverse;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ─── Sidebar ─── */
.al-sidebar {
position: sticky;
top: 0;
height: 100vh;
z-index: 200;
background: rgba(12, 12, 18, 0.95);
border-right: 1px solid rgba(255,255,255,0.06);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
display: flex;
flex-direction: column;
}
.al-sidebar::before {
content: '';
position: absolute;
top: 0; right: 0;
width: 1px; height: 100%;
background: linear-gradient(180deg, transparent, rgba(223,0,106,0.3) 50%, transparent);
pointer-events: none;
}
.al-sidebar-inner {
display: flex;
flex-direction: column;
height: 100%;
overflow-y: auto;
scrollbar-width: none;
}
.al-sidebar-inner::-webkit-scrollbar { display: none; }
/* Brand */
.al-brand {
height: 68px;
display: flex;
align-items: center;
gap: 12px;
padding: 0 20px;
border-bottom: 1px solid rgba(255,255,255,0.05);
flex-shrink: 0;
}
.al-brand-icon {
position: relative;
width: 38px; height: 38px;
background: linear-gradient(135deg, #df006a 0%, #7c0036 100%);
border-radius: 12px;
display: flex; align-items: center; justify-content: center;
color: #fff;
flex-shrink: 0;
box-shadow: 0 4px 16px rgba(223,0,106,0.5);
animation: icon-pulse 3s ease-in-out infinite;
}
@keyframes icon-pulse {
0%, 100% { box-shadow: 0 4px 16px rgba(223,0,106,0.5); }
50% { box-shadow: 0 4px 24px rgba(223,0,106,0.8), 0 0 40px rgba(223,0,106,0.2); }
}
.al-brand-icon i { width: 18px; height: 18px; position: relative; z-index: 1; }
.al-brand-icon-glow {
position: absolute;
inset: -2px;
border-radius: 14px;
background: linear-gradient(135deg, rgba(223,0,106,0.4), transparent);
filter: blur(4px);
opacity: 0;
transition: opacity 0.3s;
}
.al-brand:hover .al-brand-icon-glow { opacity: 1; }
.al-brand-text { display: flex; flex-direction: column; }
.al-brand-name { font-weight: 800; font-size: 14px; color: #fff; letter-spacing: 0.3px; }
.al-brand-sub {
font-size: 9px; color: #df006a; font-weight: 700;
text-transform: uppercase; letter-spacing: 1.5px;
}
/* Navigation */
.al-nav { padding: 12px; flex: 1; display: flex; flex-direction: column; gap: 2px; }
.al-nav-section { margin-bottom: 8px; }
.al-nav-label {
font-size: 9px;
font-weight: 800;
color: #3f3f46;
text-transform: uppercase;
letter-spacing: 1.5px;
padding: 8px 10px 4px;
}
.al-nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 10px;
color: #52525b;
text-decoration: none;
font-weight: 600;
font-size: 13px;
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
position: relative;
margin-bottom: 1px;
overflow: hidden;
}
.al-nav-item-bg {
position: absolute;
inset: 0;
border-radius: 10px;
opacity: 0;
background: rgba(255,255,255,0.04);
transition: opacity 0.2s;
}
.al-nav-item:hover { color: #d4d4d8; transform: translateX(2px); }
.al-nav-item:hover .al-nav-item-bg { opacity: 1; }
.al-nav-item.active {
color: #fff;
background: linear-gradient(135deg, rgba(223,0,106,0.18) 0%, rgba(223,0,106,0.08) 100%);
border: 1px solid rgba(223,0,106,0.25);
box-shadow: 0 2px 12px rgba(223,0,106,0.15), inset 0 1px 0 rgba(255,255,255,0.05);
}
.al-nav-icon { width: 16px; height: 16px; flex-shrink: 0; transition: transform 0.2s; }
.al-nav-item:hover .al-nav-icon { transform: scale(1.1); }
.al-nav-item.active .al-nav-icon { color: #f472b6; filter: drop-shadow(0 0 4px rgba(244,114,182,0.6)); }
.al-nav-text { flex: 1; }
.al-active-pip {
width: 6px; height: 6px;
border-radius: 50%;
background: #df006a;
flex-shrink: 0;
box-shadow: 0 0 8px rgba(223,0,106,0.9);
animation: pip-pulse 2s ease-in-out infinite;
}
@keyframes pip-pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.4); opacity: 0.7; }
}
/* Footer */
.al-foot {
padding: 12px;
border-top: 1px solid rgba(255,255,255,0.05);
display: flex;
flex-direction: column;
gap: 8px;
flex-shrink: 0;
}
.al-user {
display: flex; align-items: center; gap: 10px;
padding: 10px 12px;
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.06);
border-radius: 12px;
transition: all 0.2s;
}
.al-user:hover { background: rgba(255,255,255,0.05); border-color: rgba(255,255,255,0.1); }
.al-user-avatar {
width: 34px; height: 34px; border-radius: 10px;
background: linear-gradient(135deg, #df006a, #7c0036);
overflow: hidden; display: flex; align-items: center; justify-content: center;
font-weight: 800; font-size: 13px; color: #fff; flex-shrink: 0;
box-shadow: 0 2px 8px rgba(223,0,106,0.4);
}
.al-user-avatar img { width: 100%; height: 100%; object-fit: cover; }
.al-user-info { display: flex; flex-direction: column; flex: 1; min-width: 0; }
.al-user-name { font-weight: 700; font-size: 12px; color: #e4e4e7; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.al-user-badge {
display: inline-block; font-size: 9px; font-weight: 800;
color: #df006a; letter-spacing: 0.5px; text-transform: uppercase;
}
.al-user-status {
width: 8px; height: 8px; border-radius: 50%;
background: #00c864; flex-shrink: 0;
box-shadow: 0 0 6px rgba(0,200,100,0.7);
animation: pip-pulse 3s ease-in-out infinite;
}
.al-back-btn {
display: flex; align-items: center; gap: 8px;
color: #3f3f46; font-size: 12px; font-weight: 600; text-decoration: none;
padding: 8px 12px; border-radius: 10px;
border: 1px solid transparent;
transition: all 0.2s;
}
.al-back-btn:hover { color: #a1a1aa; background: rgba(255,255,255,0.04); border-color: rgba(255,255,255,0.06); }
.al-back-btn i { width: 14px; height: 14px; transition: transform 0.2s; }
.al-back-btn:hover i { transform: translateX(-2px); }
/* ─── Main ─── */
.al-main {
display: flex;
flex-direction: column;
min-width: 0;
position: relative;
z-index: 1;
}
/* Topbar */
.al-topbar {
height: 64px;
display: flex; align-items: center; justify-content: space-between;
padding: 0 28px;
border-bottom: 1px solid rgba(255,255,255,0.05);
background: rgba(10, 10, 16, 0.8);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
position: sticky; top: 0; z-index: 100;
gap: 16px;
}
.al-topbar-left { display: flex; align-items: center; gap: 14px; min-width: 0; }
.al-topbar-right { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
.al-menu-btn {
display: none;
width: 36px; height: 36px; border-radius: 10px;
background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.08);
cursor: pointer; color: #a1a1aa;
align-items: center; justify-content: center;
transition: all 0.2s;
}
.al-menu-btn:hover { background: rgba(255,255,255,0.1); color: #fff; transform: scale(1.05); }
.al-menu-btn i { width: 18px; height: 18px; }
.al-breadcrumb {
display: flex; align-items: center; gap: 8px;
}
.al-breadcrumb-icon { display: flex; color: #3f3f46; }
.al-breadcrumb-icon i { width: 14px; height: 14px; }
.al-breadcrumb-sep { color: #27272a; font-size: 14px; }
.al-page-title {
font-weight: 700; font-size: 16px; color: #e4e4e7;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
margin: 0;
}
.al-site-btn {
display: flex; align-items: center; gap: 6px;
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.08);
color: #71717a; padding: 7px 14px;
border-radius: 10px; font-size: 12px; font-weight: 600;
text-decoration: none;
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
}
.al-site-btn:hover {
background: rgba(255,255,255,0.08);
border-color: rgba(255,255,255,0.15);
color: #fff;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
}
.al-site-btn i { width: 13px; height: 13px; }
/* ─── Content ─── */
.al-content { padding: 28px; flex: 1; }
/* ─── Overlay ─── */
.al-overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,0.8);
backdrop-filter: blur(6px);
z-index: 150;
}
/* ─── Transitions ─── */
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s ease; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
.page-enter-active {
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
.page-leave-active {
transition: all 0.15s ease;
}
.page-enter-from {
opacity: 0;
transform: translateY(12px);
}
.page-leave-to {
opacity: 0;
transform: translateY(-8px);
}
/* ─── Global Admin Styles (passed via :deep) ─── */
:deep(.admin-card) {
background: rgba(15, 15, 20, 0.8);
border: 1px solid rgba(255,255,255,0.06);
border-radius: 16px;
overflow: hidden;
backdrop-filter: blur(10px);
transition: border-color 0.2s, box-shadow 0.2s;
}
:deep(.admin-card:hover) {
border-color: rgba(255,255,255,0.1);
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
}
:deep(.btn-primary) {
position: relative;
display: inline-flex; align-items: center; gap: 8px;
background: linear-gradient(135deg, #df006a 0%, #b8005a 100%);
border: none; color: #fff;
padding: 10px 20px; border-radius: 10px;
font-weight: 700; font-size: 13px;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
box-shadow: 0 4px 14px rgba(223,0,106,0.35);
overflow: hidden;
}
:deep(.btn-primary::before) {
content: '';
position: absolute;
top: 0; left: -100%;
width: 100%; height: 100%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.1), transparent);
transition: left 0.4s ease;
}
:deep(.btn-primary:hover) {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(223,0,106,0.5);
}
:deep(.btn-primary:hover::before) { left: 100%; }
:deep(.btn-primary:active) { transform: translateY(0); }
:deep(.btn-primary:disabled) { opacity: 0.5; cursor: not-allowed; transform: none; box-shadow: none; }
:deep(.btn-primary i) { width: 15px; height: 15px; }
:deep(.card) {
background: rgba(12, 12, 18, 0.9);
border: 1px solid rgba(255,255,255,0.06);
border-radius: 16px;
overflow: hidden;
transition: all 0.2s;
}
:deep(.card-head) {
padding: 20px 24px;
border-bottom: 1px solid rgba(255,255,255,0.05);
background: linear-gradient(180deg, rgba(255,255,255,0.02) 0%, transparent 100%);
}
:deep(.card-head h2) { font-size: 16px; font-weight: 700; color: #fff; margin: 0 0 2px; }
:deep(.card-subtitle) { color: #52525b; font-size: 12px; margin: 0; }
:deep(.card-body) { padding: 20px 24px; }
:deep(.field-input) {
background: rgba(255,255,255,0.04) !important;
border: 1px solid rgba(255,255,255,0.08) !important;
border-radius: 10px !important;
color: #e4e4e7 !important;
transition: border-color 0.2s, box-shadow 0.2s !important;
}
:deep(.field-input:focus) {
outline: none !important;
border-color: rgba(223,0,106,0.5) !important;
box-shadow: 0 0 0 3px rgba(223,0,106,0.1) !important;
}
:deep(.toggle-btn) {
transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1) !important;
}
:deep(.toggle-btn.active) {
box-shadow: 0 0 12px rgba(223,0,106,0.4) !important;
}
:deep(.toggle-knob) {
transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1) !important;
box-shadow: 0 2px 6px rgba(0,0,0,0.4) !important;
}
:deep(.alert-success) {
background: rgba(0, 200, 100, 0.08) !important;
border: 1px solid rgba(0, 200, 100, 0.2) !important;
border-radius: 12px !important;
animation: slide-in 0.3s cubic-bezier(0.16, 1, 0.3, 1) !important;
}
@keyframes slide-in {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
/* ─── Mobile ─── */
@media (max-width: 1024px) {
.al-root { grid-template-columns: 1fr; }
.al-sidebar {
position: fixed; left: -280px; top: 0; width: 260px; height: 100vh;
z-index: 300; transition: left 0.35s cubic-bezier(0.16, 1, 0.3, 1);
}
.al-root.mobile-open .al-sidebar { left: 0; }
.al-menu-btn { display: flex !important; }
.al-content { padding: 16px; }
.al-topbar { padding: 0 16px; }
}
@media (max-width: 480px) {
.al-site-btn span { display: none; }
.al-breadcrumb-icon { display: none; }
.al-breadcrumb-sep { display: none; }
}
</style>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import AppContent from '@/components/AppContent.vue';
import AppHeader from '@/components/AppHeader.vue';
import AppShell from '@/components/AppShell.vue';
import type { BreadcrumbItem } from '@/types';
type Props = {
breadcrumbs?: BreadcrumbItem[];
};
withDefaults(defineProps<Props>(), {
breadcrumbs: () => [],
});
</script>
<template>
<AppShell class="flex-col">
<AppHeader :breadcrumbs="breadcrumbs" />
<AppContent>
<slot />
</AppContent>
</AppShell>
</template>

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import AppContent from '@/components/AppContent.vue';
import AppShell from '@/components/AppShell.vue';
import AppSidebar from '@/components/AppSidebar.vue';
import AppSidebarHeader from '@/components/AppSidebarHeader.vue';
import type { BreadcrumbItem } from '@/types';
type Props = {
breadcrumbs?: BreadcrumbItem[];
};
withDefaults(defineProps<Props>(), {
breadcrumbs: () => [],
});
</script>
<template>
<AppShell variant="sidebar">
<AppSidebar />
<AppContent variant="sidebar" class="overflow-x-hidden">
<AppSidebarHeader :breadcrumbs="breadcrumbs" />
<slot />
</AppContent>
</AppShell>
</template>

View File

@@ -0,0 +1,50 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import AppLogoIcon from '@/components/AppLogoIcon.vue';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { home } from '@/routes';
defineProps<{
title?: string;
description?: string;
}>();
</script>
<template>
<div
class="flex min-h-svh flex-col items-center justify-center gap-6 bg-muted p-6 md:p-10"
>
<div class="flex w-full max-w-md flex-col gap-6">
<Link
:href="home()"
class="flex items-center gap-2 self-center font-medium"
>
<div class="flex h-9 w-9 items-center justify-center">
<AppLogoIcon
class="size-9 fill-current text-black dark:text-white"
/>
</div>
</Link>
<div class="flex flex-col gap-6">
<Card class="rounded-xl">
<CardHeader class="px-10 pt-8 pb-0 text-center">
<CardTitle class="text-xl">{{ title }}</CardTitle>
<CardDescription>
{{ description }}
</CardDescription>
</CardHeader>
<CardContent class="px-10 py-8">
<slot />
</CardContent>
</Card>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import AppLogoIcon from '@/components/AppLogoIcon.vue';
defineProps<{
title?: string;
description?: string;
}>();
</script>
<template>
<div
class="flex min-h-svh flex-col items-center justify-center gap-6 bg-[#020202] p-6 md:p-10 text-white"
>
<div class="w-full max-w-sm">
<div class="flex flex-col gap-8">
<div class="flex flex-col items-center gap-4">
<Link
href="/"
class="flex flex-col items-center gap-2 font-medium"
>
<div
class="mb-1 flex h-9 w-9 items-center justify-center rounded-md"
>
<AppLogoIcon class="size-9" />
</div>
<span class="sr-only">BetiX</span>
</Link>
<div class="space-y-2 text-center">
<h1 class="text-xl font-medium">{{ title }}</h1>
<p class="text-center text-sm text-[#888888]">
{{ description }}
</p>
</div>
</div>
<slot />
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,47 @@
<script setup lang="ts">
import { Link, usePage } from '@inertiajs/vue3';
import AppLogoIcon from '@/components/AppLogoIcon.vue';
import { home } from '@/routes';
const page = usePage();
const name = page.props.name;
defineProps<{
title?: string;
description?: string;
}>();
</script>
<template>
<div
class="relative grid h-dvh flex-col items-center justify-center px-8 sm:px-0 lg:max-w-none lg:grid-cols-2 lg:px-0"
>
<div
class="relative hidden h-full flex-col bg-muted p-10 text-white lg:flex dark:border-r"
>
<div class="absolute inset-0 bg-zinc-900" />
<Link
:href="home()"
class="relative z-20 flex items-center text-lg font-medium"
>
<AppLogoIcon class="mr-2 size-8 fill-current text-white" />
{{ name }}
</Link>
</div>
<div class="lg:p-8">
<div
class="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]"
>
<div class="flex flex-col space-y-2 text-center">
<h1 class="text-xl font-medium tracking-tight" v-if="title">
{{ title }}
</h1>
<p class="text-sm text-muted-foreground" v-if="description">
{{ description }}
</p>
</div>
<slot />
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,76 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import Heading from '@/components/Heading.vue';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { useCurrentUrl } from '@/composables/useCurrentUrl';
import { toUrl } from '@/lib/utils';
import { type NavItem } from '@/types';
import { edit as editAppearance } from '@/routes/appearance';
import { edit as editProfile } from '@/routes/profile';
import { show } from '@/routes/two-factor';
import { edit as editPassword } from '@/routes/user-password';
const sidebarNavItems: NavItem[] = [
{
title: 'Profile',
href: editProfile(),
},
{
title: 'Password',
href: editPassword(),
},
{
title: 'Two-Factor Auth',
href: show(),
},
{
title: 'Appearance',
href: editAppearance(),
},
];
const { isCurrentUrl } = useCurrentUrl();
</script>
<template>
<div class="px-4 py-6">
<Heading
title="Settings"
description="Manage your profile and account settings"
/>
<div class="flex flex-col lg:flex-row lg:space-x-12">
<aside class="w-full max-w-xl lg:w-48">
<nav
class="flex flex-col space-y-1 space-x-0"
aria-label="Settings"
>
<Button
v-for="item in sidebarNavItems"
:key="toUrl(item.href)"
variant="ghost"
:class="[
'w-full justify-start',
{ 'bg-muted': isCurrentUrl(item.href) },
]"
as-child
>
<Link :href="item.href">
<component :is="item.icon" class="h-4 w-4" />
{{ item.title }}
</Link>
</Button>
</nav>
</aside>
<Separator class="my-6 lg:hidden" />
<div class="flex-1 md:max-w-2xl">
<section class="max-w-xl space-y-12">
<slot />
</section>
</div>
</div>
</div>
</template>

File diff suppressed because it is too large Load Diff

11
resources/js/lib/utils.ts Normal file
View File

@@ -0,0 +1,11 @@
import type { InertiaLinkProps } from '@inertiajs/vue3';
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function toUrl(href: NonNullable<InertiaLinkProps['href']>) {
return typeof href === 'string' ? href : href?.url;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

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

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

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

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More