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

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

View File

@@ -0,0 +1,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>