Initialer Laravel Commit für BetiX
This commit is contained in:
172
resources/css/app.css
Normal file
172
resources/css/app.css
Normal file
@@ -0,0 +1,172 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@import 'tw-animate-css';
|
||||
|
||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||
@source '../../storage/framework/views/*.php';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--font-sans:
|
||||
Instrument Sans, ui-sans-serif, system-ui, sans-serif,
|
||||
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||||
'Noto Color Emoji';
|
||||
|
||||
--radius-lg: var(--radius);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
|
||||
--color-sidebar: var(--sidebar-background);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
/*
|
||||
The default border color has changed to `currentColor` in Tailwind CSS v4,
|
||||
so we've added these compatibility styles to make sure everything still
|
||||
looks the same as it did with Tailwind CSS v3.
|
||||
|
||||
If we ever want to remove these styles, we need to add an explicit border
|
||||
color utility to any element that depends on these defaults.
|
||||
*/
|
||||
@layer base {
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentColor);
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
body,
|
||||
html {
|
||||
--font-sans:
|
||||
'Instrument Sans', ui-sans-serif, system-ui, sans-serif,
|
||||
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||||
'Noto Color Emoji';
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: hsl(0 0% 100%);
|
||||
--foreground: hsl(0 0% 3.9%);
|
||||
--card: hsl(0 0% 100%);
|
||||
--card-foreground: hsl(0 0% 3.9%);
|
||||
--popover: hsl(0 0% 100%);
|
||||
--popover-foreground: hsl(0 0% 3.9%);
|
||||
--primary: hsl(0 0% 9%);
|
||||
--primary-foreground: hsl(0 0% 98%);
|
||||
--secondary: hsl(0 0% 92.1%);
|
||||
--secondary-foreground: hsl(0 0% 9%);
|
||||
--muted: hsl(0 0% 96.1%);
|
||||
--muted-foreground: hsl(0 0% 45.1%);
|
||||
--accent: hsl(0 0% 96.1%);
|
||||
--accent-foreground: hsl(0 0% 9%);
|
||||
--destructive: hsl(0 84.2% 60.2%);
|
||||
--destructive-foreground: hsl(0 0% 98%);
|
||||
--border: hsl(0 0% 92.8%);
|
||||
--input: hsl(0 0% 89.8%);
|
||||
--ring: hsl(0 0% 3.9%);
|
||||
--chart-1: hsl(12 76% 61%);
|
||||
--chart-2: hsl(173 58% 39%);
|
||||
--chart-3: hsl(197 37% 24%);
|
||||
--chart-4: hsl(43 74% 66%);
|
||||
--chart-5: hsl(27 87% 67%);
|
||||
--radius: 0.5rem;
|
||||
--sidebar-background: hsl(0 0% 98%);
|
||||
--sidebar-foreground: hsl(240 5.3% 26.1%);
|
||||
--sidebar-primary: hsl(0 0% 10%);
|
||||
--sidebar-primary-foreground: hsl(0 0% 98%);
|
||||
--sidebar-accent: hsl(0 0% 94%);
|
||||
--sidebar-accent-foreground: hsl(0 0% 30%);
|
||||
--sidebar-border: hsl(0 0% 91%);
|
||||
--sidebar-ring: hsl(217.2 91.2% 59.8%);
|
||||
--sidebar: hsl(0 0% 98%);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: hsl(0 0% 3.9%);
|
||||
--foreground: hsl(0 0% 98%);
|
||||
--card: hsl(0 0% 3.9%);
|
||||
--card-foreground: hsl(0 0% 98%);
|
||||
--popover: hsl(0 0% 3.9%);
|
||||
--popover-foreground: hsl(0 0% 98%);
|
||||
--primary: hsl(0 0% 98%);
|
||||
--primary-foreground: hsl(0 0% 9%);
|
||||
--secondary: hsl(0 0% 14.9%);
|
||||
--secondary-foreground: hsl(0 0% 98%);
|
||||
--muted: hsl(0 0% 16.08%);
|
||||
--muted-foreground: hsl(0 0% 63.9%);
|
||||
--accent: hsl(0 0% 14.9%);
|
||||
--accent-foreground: hsl(0 0% 98%);
|
||||
--destructive: hsl(0 84% 60%);
|
||||
--destructive-foreground: hsl(0 0% 98%);
|
||||
--border: hsl(0 0% 14.9%);
|
||||
--input: hsl(0 0% 14.9%);
|
||||
--ring: hsl(0 0% 83.1%);
|
||||
--chart-1: hsl(220 70% 50%);
|
||||
--chart-2: hsl(160 60% 45%);
|
||||
--chart-3: hsl(30 80% 55%);
|
||||
--chart-4: hsl(280 65% 60%);
|
||||
--chart-5: hsl(340 75% 55%);
|
||||
--sidebar-background: hsl(0 0% 7%);
|
||||
--sidebar-foreground: hsl(0 0% 95.9%);
|
||||
--sidebar-primary: hsl(360, 100%, 100%);
|
||||
--sidebar-primary-foreground: hsl(0 0% 100%);
|
||||
--sidebar-accent: hsl(0 0% 15.9%);
|
||||
--sidebar-accent-foreground: hsl(240 4.8% 95.9%);
|
||||
--sidebar-border: hsl(0 0% 15.9%);
|
||||
--sidebar-ring: hsl(217.2 91.2% 59.8%);
|
||||
--sidebar: hsl(240 5.9% 10%);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
49
resources/js/app.ts
Normal file
49
resources/js/app.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { createInertiaApp } from '@inertiajs/vue3';
|
||||
import axios from 'axios';
|
||||
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
|
||||
import type { DefineComponent } from 'vue';
|
||||
import { createApp, h } from 'vue';
|
||||
import '../css/app.css';
|
||||
import { initializeTheme } from './composables/useAppearance';
|
||||
import { initializePrimaryColor } from './composables/usePrimaryColor';
|
||||
import { i18n, initI18n } from './i18n';
|
||||
|
||||
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
|
||||
|
||||
// Configure Axios to include CSRF token
|
||||
axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
|
||||
const token = document.head.querySelector('meta[name="csrf-token"]');
|
||||
if (token) {
|
||||
axios.defaults.headers.common['X-CSRF-TOKEN'] = (token as HTMLMetaElement).content;
|
||||
}
|
||||
|
||||
createInertiaApp({
|
||||
title: (title) => (title ? `${title} - ${appName}` : appName),
|
||||
resolve: (name) =>
|
||||
resolvePageComponent(
|
||||
`./pages/${name}.vue`,
|
||||
import.meta.glob<DefineComponent>('./pages/**/*.vue', { eager: false }),
|
||||
),
|
||||
setup({ el, App, props, plugin }) {
|
||||
const initialLocale = ((props as any).initialPage?.props?.locale) || 'en';
|
||||
const app = createApp({ render: () => h(App, props) });
|
||||
|
||||
// Initialize i18n with the server-provided locale, then mount
|
||||
initI18n(initialLocale).finally(() => {
|
||||
app.use(plugin).use(i18n).mount(el);
|
||||
});
|
||||
},
|
||||
progress: {
|
||||
color: '#df006a',
|
||||
},
|
||||
});
|
||||
|
||||
// This will set light / dark mode on page load...
|
||||
initializeTheme();
|
||||
// Apply saved primary color (main accent) on page load...
|
||||
initializePrimaryColor();
|
||||
// Initialize casino data-theme before first paint to avoid flash
|
||||
try {
|
||||
const t = localStorage.getItem('casino-theme');
|
||||
document.documentElement.setAttribute('data-theme', t === 'light' ? 'light' : 'dark');
|
||||
} catch {}
|
||||
12
resources/js/components/AppContent.vue
Normal file
12
resources/js/components/AppContent.vue
Normal 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>
|
||||
26
resources/js/components/AppHeader.vue
Normal file
26
resources/js/components/AppHeader.vue
Normal 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>
|
||||
14
resources/js/components/AppLogoIcon.vue
Normal file
14
resources/js/components/AppLogoIcon.vue
Normal 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>
|
||||
12
resources/js/components/AppShell.vue
Normal file
12
resources/js/components/AppShell.vue
Normal 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>
|
||||
9
resources/js/components/AppSidebar.vue
Normal file
9
resources/js/components/AppSidebar.vue
Normal 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>
|
||||
24
resources/js/components/AppSidebarHeader.vue
Normal file
24
resources/js/components/AppSidebarHeader.vue
Normal 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>
|
||||
96
resources/js/components/AppearanceTabs.vue
Normal file
96
resources/js/components/AppearanceTabs.vue
Normal 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 HEX‑Wert 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>
|
||||
24
resources/js/components/Heading.vue
Normal file
24
resources/js/components/Heading.vue
Normal 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>
|
||||
13
resources/js/components/InputError.vue
Normal file
13
resources/js/components/InputError.vue
Normal 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>
|
||||
18
resources/js/components/TextLink.vue
Normal file
18
resources/js/components/TextLink.vue
Normal 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>
|
||||
107
resources/js/components/TwoFactorRecoveryCodes.vue
Normal file
107
resources/js/components/TwoFactorRecoveryCodes.vue
Normal 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>
|
||||
153
resources/js/components/TwoFactorSetupModal.vue
Normal file
153
resources/js/components/TwoFactorSetupModal.vue
Normal 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>
|
||||
323
resources/js/components/auth/AuthModals.vue
Normal file
323
resources/js/components/auth/AuthModals.vue
Normal 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>
|
||||
289
resources/js/components/auth/LoginForm.vue
Normal file
289
resources/js/components/auth/LoginForm.vue
Normal 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>
|
||||
567
resources/js/components/auth/RegisterForm.vue
Normal file
567
resources/js/components/auth/RegisterForm.vue
Normal 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>
|
||||
1557
resources/js/components/chat/GlobalChat.vue
Normal file
1557
resources/js/components/chat/GlobalChat.vue
Normal file
File diff suppressed because it is too large
Load Diff
694
resources/js/components/support/SupportChat.vue
Normal file
694
resources/js/components/support/SupportChat.vue
Normal 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>
|
||||
126
resources/js/components/ui/AppLoading.vue
Normal file
126
resources/js/components/ui/AppLoading.vue
Normal 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>
|
||||
104
resources/js/components/ui/ConfirmModal.vue
Normal file
104
resources/js/components/ui/ConfirmModal.vue
Normal 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>
|
||||
142
resources/js/components/ui/CountrySelect.vue
Normal file
142
resources/js/components/ui/CountrySelect.vue
Normal 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>
|
||||
72
resources/js/components/ui/DatePicker.vue
Normal file
72
resources/js/components/ui/DatePicker.vue
Normal 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>
|
||||
258
resources/js/components/ui/Footer.vue
Normal file
258
resources/js/components/ui/Footer.vue
Normal 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">
|
||||
© {{ 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>
|
||||
67
resources/js/components/ui/Notification.vue
Normal file
67
resources/js/components/ui/Notification.vue
Normal 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>
|
||||
589
resources/js/components/ui/SearchModal.vue
Normal file
589
resources/js/components/ui/SearchModal.vue
Normal 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>
|
||||
3
resources/js/components/ui/Separator.vue
Normal file
3
resources/js/components/ui/Separator.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<hr class="my-6 border-t border-muted" />
|
||||
</template>
|
||||
1
resources/js/components/ui/button.ts
Normal file
1
resources/js/components/ui/button.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Button } from './button.vue';
|
||||
36
resources/js/components/ui/button.vue
Normal file
36
resources/js/components/ui/button.vue
Normal 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>
|
||||
22
resources/js/components/ui/checkbox.vue
Normal file
22
resources/js/components/ui/checkbox.vue
Normal 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>
|
||||
41
resources/js/components/ui/input-otp/InputOTP.vue
Normal file
41
resources/js/components/ui/input-otp/InputOTP.vue
Normal 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>
|
||||
5
resources/js/components/ui/input-otp/InputOTPGroup.vue
Normal file
5
resources/js/components/ui/input-otp/InputOTPGroup.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div class="flex gap-2">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
9
resources/js/components/ui/input-otp/InputOTPSlot.vue
Normal file
9
resources/js/components/ui/input-otp/InputOTPSlot.vue
Normal 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>
|
||||
3
resources/js/components/ui/input-otp/index.ts
Normal file
3
resources/js/components/ui/input-otp/index.ts
Normal 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';
|
||||
1
resources/js/components/ui/input.ts
Normal file
1
resources/js/components/ui/input.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Input } from './input.vue';
|
||||
31
resources/js/components/ui/input.vue
Normal file
31
resources/js/components/ui/input.vue
Normal 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>
|
||||
1
resources/js/components/ui/label.ts
Normal file
1
resources/js/components/ui/label.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Label } from './label.vue';
|
||||
11
resources/js/components/ui/label.vue
Normal file
11
resources/js/components/ui/label.vue
Normal 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>
|
||||
129
resources/js/components/ui/select.vue
Normal file
129
resources/js/components/ui/select.vue
Normal 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>
|
||||
1
resources/js/components/ui/separator.ts
Normal file
1
resources/js/components/ui/separator.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Separator } from './Separator.vue';
|
||||
28
resources/js/components/ui/spinner.vue
Normal file
28
resources/js/components/ui/spinner.vue
Normal 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>
|
||||
718
resources/js/components/vault/VaultModal.vue
Normal file
718
resources/js/components/vault/VaultModal.vue
Normal 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>
|
||||
124
resources/js/composables/useAppearance.ts
Normal file
124
resources/js/composables/useAppearance.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import type { ComputedRef, Ref } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import type { Appearance, ResolvedAppearance } from '@/types';
|
||||
|
||||
export type { Appearance, ResolvedAppearance };
|
||||
|
||||
export type UseAppearanceReturn = {
|
||||
appearance: Ref<Appearance>;
|
||||
resolvedAppearance: ComputedRef<ResolvedAppearance>;
|
||||
updateAppearance: (value: Appearance) => void;
|
||||
};
|
||||
|
||||
export function updateTheme(value: Appearance): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (value === 'system') {
|
||||
const mediaQueryList = window.matchMedia(
|
||||
'(prefers-color-scheme: dark)',
|
||||
);
|
||||
const systemTheme = mediaQueryList.matches ? 'dark' : 'light';
|
||||
|
||||
document.documentElement.classList.toggle(
|
||||
'dark',
|
||||
systemTheme === 'dark',
|
||||
);
|
||||
} else {
|
||||
document.documentElement.classList.toggle('dark', value === 'dark');
|
||||
}
|
||||
}
|
||||
|
||||
const setCookie = (name: string, value: string, days = 365) => {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const maxAge = days * 24 * 60 * 60;
|
||||
|
||||
document.cookie = `${name}=${value};path=/;max-age=${maxAge};SameSite=Lax`;
|
||||
};
|
||||
|
||||
const mediaQuery = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return window.matchMedia('(prefers-color-scheme: dark)');
|
||||
};
|
||||
|
||||
const getStoredAppearance = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return localStorage.getItem('appearance') as Appearance | null;
|
||||
};
|
||||
|
||||
const prefersDark = (): boolean => {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
};
|
||||
|
||||
const handleSystemThemeChange = () => {
|
||||
const currentAppearance = getStoredAppearance();
|
||||
|
||||
updateTheme(currentAppearance || 'system');
|
||||
};
|
||||
|
||||
export function initializeTheme(): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize theme from saved preference or default to system...
|
||||
const savedAppearance = getStoredAppearance();
|
||||
updateTheme(savedAppearance || 'system');
|
||||
|
||||
// Set up system theme change listener...
|
||||
mediaQuery()?.addEventListener('change', handleSystemThemeChange);
|
||||
}
|
||||
|
||||
const appearance = ref<Appearance>('system');
|
||||
|
||||
export function useAppearance(): UseAppearanceReturn {
|
||||
onMounted(() => {
|
||||
const savedAppearance = localStorage.getItem(
|
||||
'appearance',
|
||||
) as Appearance | null;
|
||||
|
||||
if (savedAppearance) {
|
||||
appearance.value = savedAppearance;
|
||||
}
|
||||
});
|
||||
|
||||
const resolvedAppearance = computed<ResolvedAppearance>(() => {
|
||||
if (appearance.value === 'system') {
|
||||
return prefersDark() ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
return appearance.value;
|
||||
});
|
||||
|
||||
function updateAppearance(value: Appearance) {
|
||||
appearance.value = value;
|
||||
|
||||
// Store in localStorage for client-side persistence...
|
||||
localStorage.setItem('appearance', value);
|
||||
|
||||
// Store in cookie for SSR...
|
||||
setCookie('appearance', value);
|
||||
|
||||
updateTheme(value);
|
||||
}
|
||||
|
||||
return {
|
||||
appearance,
|
||||
resolvedAppearance,
|
||||
updateAppearance,
|
||||
};
|
||||
}
|
||||
59
resources/js/composables/useCurrentUrl.ts
Normal file
59
resources/js/composables/useCurrentUrl.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { InertiaLinkProps } from '@inertiajs/vue3';
|
||||
import { usePage } from '@inertiajs/vue3';
|
||||
import type { ComputedRef, DeepReadonly } from 'vue';
|
||||
import { computed, readonly } from 'vue';
|
||||
import { toUrl } from '@/lib/utils';
|
||||
|
||||
export type UseCurrentUrlReturn = {
|
||||
currentUrl: DeepReadonly<ComputedRef<string>>;
|
||||
isCurrentUrl: (
|
||||
urlToCheck: NonNullable<InertiaLinkProps['href']>,
|
||||
currentUrl?: string,
|
||||
) => boolean;
|
||||
whenCurrentUrl: <T, F = null>(
|
||||
urlToCheck: NonNullable<InertiaLinkProps['href']>,
|
||||
ifTrue: T,
|
||||
ifFalse?: F,
|
||||
) => T | F;
|
||||
};
|
||||
|
||||
const page = usePage();
|
||||
const currentUrlReactive = computed(
|
||||
() => new URL(page.url, window?.location.origin).pathname,
|
||||
);
|
||||
|
||||
export function useCurrentUrl(): UseCurrentUrlReturn {
|
||||
function isCurrentUrl(
|
||||
urlToCheck: NonNullable<InertiaLinkProps['href']>,
|
||||
currentUrl?: string,
|
||||
) {
|
||||
const urlToCompare = currentUrl ?? currentUrlReactive.value;
|
||||
const urlString = toUrl(urlToCheck);
|
||||
|
||||
if (!urlString.startsWith('http')) {
|
||||
return urlString === urlToCompare;
|
||||
}
|
||||
|
||||
try {
|
||||
const absoluteUrl = new URL(urlString);
|
||||
|
||||
return absoluteUrl.pathname === urlToCompare;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function whenCurrentUrl(
|
||||
urlToCheck: NonNullable<InertiaLinkProps['href']>,
|
||||
ifTrue: any,
|
||||
ifFalse: any = null,
|
||||
) {
|
||||
return isCurrentUrl(urlToCheck) ? ifTrue : ifFalse;
|
||||
}
|
||||
|
||||
return {
|
||||
currentUrl: readonly(currentUrlReactive),
|
||||
isCurrentUrl,
|
||||
whenCurrentUrl,
|
||||
};
|
||||
}
|
||||
18
resources/js/composables/useInitials.ts
Normal file
18
resources/js/composables/useInitials.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export type UseInitialsReturn = {
|
||||
getInitials: (fullName?: string) => string;
|
||||
};
|
||||
|
||||
export function getInitials(fullName?: string): string {
|
||||
if (!fullName) return '';
|
||||
|
||||
const names = fullName.trim().split(' ');
|
||||
|
||||
if (names.length === 0) return '';
|
||||
if (names.length === 1) return names[0].charAt(0).toUpperCase();
|
||||
|
||||
return `${names[0].charAt(0)}${names[names.length - 1].charAt(0)}`.toUpperCase();
|
||||
}
|
||||
|
||||
export function useInitials(): UseInitialsReturn {
|
||||
return { getInitials };
|
||||
}
|
||||
90
resources/js/composables/useNotifications.ts
Normal file
90
resources/js/composables/useNotifications.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { ref } from 'vue';
|
||||
|
||||
export type ToastType = 'green' | 'magenta' | 'blue' | 'gold' | 'red';
|
||||
|
||||
export interface NotificationData {
|
||||
type: ToastType;
|
||||
title: string;
|
||||
desc: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
interface Toast extends NotificationData {
|
||||
id: number;
|
||||
active: boolean;
|
||||
flyingOut: boolean;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
interface HistoryItem extends NotificationData {
|
||||
id: number;
|
||||
timestamp: Date;
|
||||
read: boolean;
|
||||
}
|
||||
|
||||
const toasts = ref<Toast[]>([]);
|
||||
const history = ref<HistoryItem[]>([]);
|
||||
const unreadCount = ref(0);
|
||||
let toastId = 0;
|
||||
|
||||
export function useNotifications() {
|
||||
const notify = (data: NotificationData) => {
|
||||
// Add to toasts (popup)
|
||||
const id = toastId++;
|
||||
const toast: Toast = { id, ...data, active: false, flyingOut: false, progress: 100 };
|
||||
toasts.value.push(toast);
|
||||
|
||||
// Animation logic for toast
|
||||
setTimeout(() => {
|
||||
const t = toasts.value.find(x => x.id === id);
|
||||
if(t) t.active = true;
|
||||
// Trigger lucide icons update if needed in component
|
||||
}, 50);
|
||||
|
||||
const interval = setInterval(() => {
|
||||
const t = toasts.value.find(x => x.id === id);
|
||||
if (!t) { clearInterval(interval); return; }
|
||||
t.progress -= 0.5;
|
||||
if (t.progress <= 0) { clearInterval(interval); closeToast(id); }
|
||||
}, 40);
|
||||
|
||||
// Add to history
|
||||
history.value.unshift({
|
||||
id: Date.now() + Math.random(),
|
||||
...data,
|
||||
timestamp: new Date(),
|
||||
read: false
|
||||
});
|
||||
unreadCount.value++;
|
||||
};
|
||||
|
||||
const closeToast = (id: number) => {
|
||||
const t = toasts.value.find(x => x.id === id);
|
||||
if (t) {
|
||||
t.flyingOut = true;
|
||||
setTimeout(() => {
|
||||
toasts.value = toasts.value.filter(x => x.id !== id);
|
||||
}, 600);
|
||||
}
|
||||
};
|
||||
|
||||
const markAllRead = () => {
|
||||
history.value.forEach(n => n.read = true);
|
||||
unreadCount.value = 0;
|
||||
};
|
||||
|
||||
const clearAll = () => {
|
||||
history.value = [];
|
||||
unreadCount.value = 0;
|
||||
};
|
||||
|
||||
return {
|
||||
toasts,
|
||||
history,
|
||||
unreadCount,
|
||||
notify,
|
||||
closeToast,
|
||||
markAllRead,
|
||||
clearAll
|
||||
};
|
||||
}
|
||||
129
resources/js/composables/usePrimaryColor.ts
Normal file
129
resources/js/composables/usePrimaryColor.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { ref, onMounted } from 'vue';
|
||||
|
||||
export type UsePrimaryColorReturn = {
|
||||
primaryColor: ReturnType<typeof ref<string>>;
|
||||
updatePrimaryColor: (value: string) => void;
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'primaryColor';
|
||||
|
||||
function setCookie(name: string, value: string, days = 365) {
|
||||
if (typeof document === 'undefined') return;
|
||||
const maxAge = days * 24 * 60 * 60;
|
||||
document.cookie = `${name}=${value};path=/;max-age=${maxAge};SameSite=Lax`;
|
||||
}
|
||||
|
||||
function getStoredPrimaryColor(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return localStorage.getItem(STORAGE_KEY);
|
||||
}
|
||||
|
||||
function parseColorToRGB(color: string): { r: number; g: number; b: number } | null {
|
||||
// Supports #rgb, #rrggbb, rgb(), hsl()
|
||||
color = color.trim().toLowerCase();
|
||||
|
||||
// Hex
|
||||
if (color.startsWith('#')) {
|
||||
let hex = color.slice(1);
|
||||
if (hex.length === 3) {
|
||||
hex = hex.split('').map((c) => c + c).join('');
|
||||
}
|
||||
if (hex.length === 6) {
|
||||
const num = parseInt(hex, 16);
|
||||
return { r: (num >> 16) & 255, g: (num >> 8) & 255, b: num & 255 };
|
||||
}
|
||||
}
|
||||
|
||||
// rgb/rgba
|
||||
if (color.startsWith('rgb')) {
|
||||
const m = color.match(/rgba?\(([^)]+)\)/);
|
||||
if (m) {
|
||||
const [r, g, b] = m[1]
|
||||
.split(',')
|
||||
.slice(0, 3)
|
||||
.map((v) => parseFloat(v.trim()));
|
||||
return { r, g, b };
|
||||
}
|
||||
}
|
||||
|
||||
// hsl/hsla
|
||||
if (color.startsWith('hsl')) {
|
||||
const m = color.match(/hsla?\(([^)]+)\)/);
|
||||
if (m) {
|
||||
const [hStr, sStr, lStr] = m[1].split(',').map((v) => v.trim());
|
||||
const h = parseFloat(hStr);
|
||||
const s = parseFloat(sStr) / 100;
|
||||
const l = parseFloat(lStr) / 100;
|
||||
// convert HSL to RGB
|
||||
const c = (1 - Math.abs(2 * l - 1)) * s;
|
||||
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||
const m2 = l - c / 2;
|
||||
let r1 = 0, g1 = 0, b1 = 0;
|
||||
if (0 <= h && h < 60) [r1, g1, b1] = [c, x, 0];
|
||||
else if (60 <= h && h < 120) [r1, g1, b1] = [x, c, 0];
|
||||
else if (120 <= h && h < 180) [r1, g1, b1] = [0, c, x];
|
||||
else if (180 <= h && h < 240) [r1, g1, b1] = [0, x, c];
|
||||
else if (240 <= h && h < 300) [r1, g1, b1] = [x, 0, c];
|
||||
else [r1, g1, b1] = [c, 0, x];
|
||||
return {
|
||||
r: Math.round((r1 + m2) * 255),
|
||||
g: Math.round((g1 + m2) * 255),
|
||||
b: Math.round((b1 + m2) * 255),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getContrastColor(bg: string): '#000000' | '#FFFFFF' {
|
||||
const rgb = parseColorToRGB(bg) || { r: 0, g: 0, b: 0 };
|
||||
// Relative luminance
|
||||
const srgb = [rgb.r, rgb.g, rgb.b].map((v) => {
|
||||
const c = v / 255;
|
||||
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||||
});
|
||||
const L = 0.2126 * srgb[0] + 0.7152 * srgb[1] + 0.0722 * srgb[2];
|
||||
return L > 0.179 ? '#000000' : '#FFFFFF';
|
||||
}
|
||||
|
||||
export function applyPrimaryColor(value: string): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
const root = document.documentElement;
|
||||
|
||||
// Apply to global primary variables used by Tailwind theme tokens
|
||||
root.style.setProperty('--primary', value);
|
||||
root.style.setProperty('--primary-foreground', getContrastColor(value));
|
||||
|
||||
// Align commonly used accent variables in user layout to the same main color
|
||||
root.style.setProperty('--magenta', value);
|
||||
root.style.setProperty('--sidebar-primary', value);
|
||||
root.style.setProperty('--sidebar-primary-foreground', getContrastColor(value));
|
||||
}
|
||||
|
||||
export function initializePrimaryColor(): void {
|
||||
const saved = getStoredPrimaryColor();
|
||||
if (saved) applyPrimaryColor(saved);
|
||||
}
|
||||
|
||||
const primaryColorRef = ref<string>(getStoredPrimaryColor() || '#ff007a');
|
||||
|
||||
export function usePrimaryColor(): UsePrimaryColorReturn {
|
||||
onMounted(() => {
|
||||
const saved = getStoredPrimaryColor();
|
||||
if (saved) {
|
||||
primaryColorRef.value = saved;
|
||||
}
|
||||
});
|
||||
|
||||
function updatePrimaryColor(value: string) {
|
||||
primaryColorRef.value = value;
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem(STORAGE_KEY, value);
|
||||
}
|
||||
setCookie(STORAGE_KEY, value);
|
||||
applyPrimaryColor(value);
|
||||
}
|
||||
|
||||
return { primaryColor: primaryColorRef, updatePrimaryColor };
|
||||
}
|
||||
120
resources/js/composables/useTwoFactorAuth.ts
Normal file
120
resources/js/composables/useTwoFactorAuth.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import type { ComputedRef, Ref } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { qrCode, recoveryCodes, secretKey } from '@/routes/two-factor';
|
||||
|
||||
export type UseTwoFactorAuthReturn = {
|
||||
qrCodeSvg: Ref<string | null>;
|
||||
manualSetupKey: Ref<string | null>;
|
||||
recoveryCodesList: Ref<string[]>;
|
||||
errors: Ref<string[]>;
|
||||
hasSetupData: ComputedRef<boolean>;
|
||||
clearSetupData: () => void;
|
||||
clearErrors: () => void;
|
||||
clearTwoFactorAuthData: () => void;
|
||||
fetchQrCode: () => Promise<void>;
|
||||
fetchSetupKey: () => Promise<void>;
|
||||
fetchSetupData: () => Promise<void>;
|
||||
fetchRecoveryCodes: () => Promise<void>;
|
||||
};
|
||||
|
||||
const fetchJson = async <T>(url: string): Promise<T> => {
|
||||
const response = await fetch(url, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
const errors = ref<string[]>([]);
|
||||
const manualSetupKey = ref<string | null>(null);
|
||||
const qrCodeSvg = ref<string | null>(null);
|
||||
const recoveryCodesList = ref<string[]>([]);
|
||||
|
||||
const hasSetupData = computed<boolean>(
|
||||
() => qrCodeSvg.value !== null && manualSetupKey.value !== null,
|
||||
);
|
||||
|
||||
export const useTwoFactorAuth = (): UseTwoFactorAuthReturn => {
|
||||
const fetchQrCode = async (): Promise<void> => {
|
||||
try {
|
||||
const { svg } = await fetchJson<{ svg: string; url: string }>(
|
||||
qrCode.url(),
|
||||
);
|
||||
|
||||
qrCodeSvg.value = svg;
|
||||
} catch {
|
||||
errors.value.push('Failed to fetch QR code');
|
||||
qrCodeSvg.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSetupKey = async (): Promise<void> => {
|
||||
try {
|
||||
const { secretKey: key } = await fetchJson<{ secretKey: string }>(
|
||||
secretKey.url(),
|
||||
);
|
||||
|
||||
manualSetupKey.value = key;
|
||||
} catch {
|
||||
errors.value.push('Failed to fetch a setup key');
|
||||
manualSetupKey.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const clearSetupData = (): void => {
|
||||
manualSetupKey.value = null;
|
||||
qrCodeSvg.value = null;
|
||||
clearErrors();
|
||||
};
|
||||
|
||||
const clearErrors = (): void => {
|
||||
errors.value = [];
|
||||
};
|
||||
|
||||
const clearTwoFactorAuthData = (): void => {
|
||||
clearSetupData();
|
||||
clearErrors();
|
||||
recoveryCodesList.value = [];
|
||||
};
|
||||
|
||||
const fetchRecoveryCodes = async (): Promise<void> => {
|
||||
try {
|
||||
clearErrors();
|
||||
recoveryCodesList.value = await fetchJson<string[]>(
|
||||
recoveryCodes.url(),
|
||||
);
|
||||
} catch {
|
||||
errors.value.push('Failed to fetch recovery codes');
|
||||
recoveryCodesList.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSetupData = async (): Promise<void> => {
|
||||
try {
|
||||
clearErrors();
|
||||
await Promise.all([fetchQrCode(), fetchSetupKey()]);
|
||||
} catch {
|
||||
qrCodeSvg.value = null;
|
||||
manualSetupKey.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
qrCodeSvg,
|
||||
manualSetupKey,
|
||||
recoveryCodesList,
|
||||
errors,
|
||||
hasSetupData,
|
||||
clearSetupData,
|
||||
clearErrors,
|
||||
clearTwoFactorAuthData,
|
||||
fetchQrCode,
|
||||
fetchSetupKey,
|
||||
fetchSetupData,
|
||||
fetchRecoveryCodes,
|
||||
};
|
||||
};
|
||||
180
resources/js/composables/useVault.ts
Normal file
180
resources/js/composables/useVault.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { ref } from 'vue';
|
||||
import { csrfFetch } from '@/utils/csrfFetch';
|
||||
|
||||
export interface VaultBalances {
|
||||
balance: string;
|
||||
vault_balance: string;
|
||||
vault_balances: Record<string, string>;
|
||||
currency: string | null;
|
||||
}
|
||||
|
||||
export function useVault() {
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const balances = ref<VaultBalances | null>(null);
|
||||
const transfers = ref<any>(null);
|
||||
const pinRequired = ref(false);
|
||||
const lockedUntil = ref<string | null>(null);
|
||||
|
||||
const sessionPin = ref<string | null>(null);
|
||||
|
||||
async function load(perPage = 10) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const resp = await fetch(`/api/wallet/vault?per_page=${perPage}`, {
|
||||
headers: { 'Accept': 'application/json' }
|
||||
});
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const json = await resp.json();
|
||||
balances.value = {
|
||||
balance: json.balance,
|
||||
vault_balance: json.vault_balance,
|
||||
vault_balances: json.vault_balances ?? { BTX: json.vault_balance },
|
||||
currency: json.currency,
|
||||
};
|
||||
transfers.value = json.transfers;
|
||||
} catch (e: any) {
|
||||
error.value = e?.message || 'Failed to load vault';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function uuidv4(): string {
|
||||
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
|
||||
return (crypto as any).randomUUID();
|
||||
}
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
async function deposit(amount: string, currency = 'BTX', pin?: string) {
|
||||
return doAction('/api/wallet/vault/deposit', amount, currency, pin);
|
||||
}
|
||||
|
||||
async function withdraw(amount: string, currency = 'BTX', pin?: string) {
|
||||
return doAction('/api/wallet/vault/withdraw', amount, currency, pin);
|
||||
}
|
||||
|
||||
async function doAction(url: string, amount: string, currency: string, pin?: string) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
pinRequired.value = false;
|
||||
lockedUntil.value = null;
|
||||
|
||||
try {
|
||||
const usePin = pin ?? sessionPin.value;
|
||||
if (!usePin) {
|
||||
pinRequired.value = true;
|
||||
throw new Error('PIN required');
|
||||
}
|
||||
|
||||
const body = { amount, currency, pin: usePin, idempotency_key: uuidv4() };
|
||||
const resp = await csrfFetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (resp.status === 423) {
|
||||
const j = await resp.json().catch(() => ({}));
|
||||
pinRequired.value = true;
|
||||
lockedUntil.value = j?.locked_until || null;
|
||||
throw new Error(j?.message || 'PIN required');
|
||||
}
|
||||
|
||||
if (!resp.ok) {
|
||||
const j = await resp.json().catch(() => ({}));
|
||||
throw new Error(j?.message || `HTTP ${resp.status}`);
|
||||
}
|
||||
|
||||
const json = await resp.json();
|
||||
|
||||
if (json?.balances) {
|
||||
balances.value = {
|
||||
balance: json.balances.balance,
|
||||
vault_balance: json.balances.vault_balance,
|
||||
vault_balances: json.balances.vault_balances ?? { BTX: json.balances.vault_balance },
|
||||
currency: balances.value?.currency || 'BTX',
|
||||
};
|
||||
} else {
|
||||
await load();
|
||||
}
|
||||
return json;
|
||||
} catch (e: any) {
|
||||
error.value = e?.message || 'Vault action failed';
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyPin(pin: string) {
|
||||
error.value = null;
|
||||
try {
|
||||
const resp = await csrfFetch('/api/wallet/vault/pin/verify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||
body: JSON.stringify({ pin }),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const j = await resp.json().catch(() => ({}));
|
||||
if (resp.status === 423) lockedUntil.value = j?.locked_until || null;
|
||||
throw new Error(j?.message || `HTTP ${resp.status}`);
|
||||
}
|
||||
|
||||
sessionPin.value = pin;
|
||||
pinRequired.value = false;
|
||||
lockedUntil.value = null;
|
||||
|
||||
return await resp.json();
|
||||
} catch (e: any) {
|
||||
error.value = e?.message || 'PIN verification failed';
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
function clearSessionPin() {
|
||||
sessionPin.value = null;
|
||||
}
|
||||
|
||||
async function setPin(pin: string, current_pin?: string) {
|
||||
error.value = null;
|
||||
const payload: any = { pin };
|
||||
if (current_pin) payload.current_pin = current_pin;
|
||||
|
||||
const resp = await csrfFetch('/api/wallet/vault/pin/set', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const j = await resp.json().catch(() => ({}));
|
||||
throw new Error(j?.message || `HTTP ${resp.status}`);
|
||||
}
|
||||
|
||||
sessionPin.value = pin;
|
||||
return await resp.json();
|
||||
}
|
||||
|
||||
return {
|
||||
loading,
|
||||
error,
|
||||
balances,
|
||||
transfers,
|
||||
pinRequired,
|
||||
lockedUntil,
|
||||
load,
|
||||
deposit,
|
||||
withdraw,
|
||||
verifyPin,
|
||||
setPin,
|
||||
clearSessionPin,
|
||||
};
|
||||
}
|
||||
162
resources/js/i18n/de.json
Normal file
162
resources/js/i18n/de.json
Normal file
@@ -0,0 +1,162 @@
|
||||
{
|
||||
"nav": {
|
||||
"lobby": "Lobby",
|
||||
"liveCasino": "Live Casino",
|
||||
"slots": "Slots",
|
||||
"wallet": "Wallet",
|
||||
"bonuses": "Boni",
|
||||
"vip": "VIP Club",
|
||||
"guilds": "Gilden",
|
||||
"responsible": "Verantwortung",
|
||||
"faq": "FAQ",
|
||||
"settings": "Einstellungen",
|
||||
"public_profile": "Öffentliches Profil",
|
||||
"account_settings": "Kontoeinstellungen"
|
||||
},
|
||||
"topbar": {
|
||||
"welcome_guest": "Willkommen bei BetiX",
|
||||
"welcome_user": "Willkommen zurück, {name}",
|
||||
"vault": "Vault",
|
||||
"search": "Suche",
|
||||
"deposit": "Einzahlen"
|
||||
},
|
||||
"footer": {
|
||||
"legal": {
|
||||
"terms": "Allgemeine Geschäftsbedingungen",
|
||||
"cookies": "Cookie-Richtlinie",
|
||||
"privacy": "Datenschutz",
|
||||
"bonusPolicy": "Bonus-Richtlinie",
|
||||
"disputes": "Streitbeilegung",
|
||||
"responsible": "Verantwortungsvolles Spielen",
|
||||
"aml": "AML-Richtlinie",
|
||||
"risks": "Risikohinweise"
|
||||
}
|
||||
},
|
||||
"wallet": { "title": "Wallet", "subtitle": "Verwalte dein Guthaben, Ein- und Auszahlungen." },
|
||||
"vault": {
|
||||
"title": "Vault",
|
||||
"toVault": "In Vault",
|
||||
"fromVault": "Aus Vault",
|
||||
"amount": { "placeholder": "z. B. 6200 oder 6200.0000" }
|
||||
},
|
||||
"bonuses": {
|
||||
"title": "Aktionen",
|
||||
"tabs": { "available": "Verfügbar", "active": "Aktiv", "history": "Historie" },
|
||||
"featured": "FEATURED",
|
||||
"minDeposit": "Min. Einzahlung",
|
||||
"claim": "Jetzt sichern",
|
||||
"activate": "Aktivieren",
|
||||
"errorLoad": "Boni konnten nicht geladen werden",
|
||||
"noActive": "Keine aktiven Boni. Prüfe die verfügbaren Angebote!",
|
||||
"noHistory": "Noch keine Historie.",
|
||||
"ended": "Beendet",
|
||||
"wagerProgress": "Umsatzfortschritt",
|
||||
"wageredOf": "{wagered} / {total} umgesetzt",
|
||||
"bonus": "Bonus"
|
||||
},
|
||||
"notif": { "success": "Erfolg", "error": "Fehler" },
|
||||
"sections": {
|
||||
"gaming": "Gaming",
|
||||
"user": "Benutzer",
|
||||
"supportInfo": "Support & Info"
|
||||
},
|
||||
"policy": {
|
||||
"toc": "Inhalt",
|
||||
"lastUpdated": "Zuletzt aktualisiert"
|
||||
},
|
||||
"common": {
|
||||
"guest": "Gast",
|
||||
"loginToPlay": "Bitte einloggen zum Spielen"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Einloggen",
|
||||
"register": "Registrieren",
|
||||
"logout": "Abmelden"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Spiele, @Nutzer, Anbieter suchen...",
|
||||
"slots": "Spiele",
|
||||
"users": "Spieler",
|
||||
"providers": "Anbieter",
|
||||
"noResults": "Keine Ergebnisse gefunden",
|
||||
"hint": "@ für Nutzer, Anbietername für Provider"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Benachrichtigungen",
|
||||
"clearAll": "Alle löschen",
|
||||
"empty": "Noch keine Benachrichtigungen",
|
||||
"markRead": "Alle als gelesen markieren"
|
||||
},
|
||||
"dashboard": {
|
||||
"welcome_offer": "Willkommensangebot",
|
||||
"claim_now": "Jetzt sichern",
|
||||
"read_terms": "AGB lesen",
|
||||
"search_placeholder": "Spiele suchen...",
|
||||
"originals": "Originals",
|
||||
"loading": "Lädt...",
|
||||
"reload": "Erneut laden",
|
||||
"no_games": "Keine Spiele gefunden.",
|
||||
"no_slots": "Keine Slots gefunden.",
|
||||
"no_live": "Keine Live-Spiele gefunden.",
|
||||
"popular_slots": "Beliebte Slots",
|
||||
"show_all": "Alle anzeigen",
|
||||
"live_wins": "Live-Gewinne",
|
||||
"live_feed": "Live-Feed",
|
||||
"won_in": "gewonnen in",
|
||||
"new_releases": "Neuerscheinungen",
|
||||
"live_casino": "Live Casino",
|
||||
"hall_of_fame": "Ruhmeshalle",
|
||||
"recently_played": "Zuletzt gespielt",
|
||||
"error_loading": "Fehler beim Laden der Spiele."
|
||||
},
|
||||
"vip": {
|
||||
"your_rank": "DEIN RANG",
|
||||
"max": "MAX",
|
||||
"unlocked": "Freigeschaltet",
|
||||
"current": "Aktuell",
|
||||
"locked": "Gesperrt",
|
||||
"benefits": "Vorteile",
|
||||
"requires": "Benötigt",
|
||||
"reward_claimed": "Belohnung erhalten"
|
||||
},
|
||||
"bonus": {
|
||||
"promo_title": "Promo Code einlösen",
|
||||
"promo_placeholder": "Code eingeben (z. B. WELCOME10)",
|
||||
"promo_redeem": "Einlösen",
|
||||
"promo_redeeming": "Wird eingelöst…",
|
||||
"unlock_at": "Freischaltung bei"
|
||||
},
|
||||
"guilds": {
|
||||
"title": "Gilden",
|
||||
"subtitle_new": "Erstelle eine Gilde oder tritt einer mit einem Einladungscode bei.",
|
||||
"subtitle_member": "Verwalte deine Gilde, lade Mitglieder ein und sieh deine Mitgliederliste.",
|
||||
"tab_active": "Aktiv",
|
||||
"create": "Gilde erstellen",
|
||||
"join": "Gilde beitreten",
|
||||
"name": "Name",
|
||||
"tag": "Kürzel",
|
||||
"logo_url": "Logo-URL",
|
||||
"description": "Beschreibung",
|
||||
"invite_code": "Einladungscode",
|
||||
"appearance": "Erscheinungsbild",
|
||||
"members": "Mitglieder",
|
||||
"sorted_by_wager": "Sortiert nach Einsatz",
|
||||
"wagered": "Eingesetzt",
|
||||
"kick": "Mitglied entfernen",
|
||||
"leave": "Gilde verlassen",
|
||||
"top_title": "Bestenliste",
|
||||
"top_desc": "Die am höchsten bewerteten Gilden nach Punkten. Top-Gilden erhalten Boni.",
|
||||
"rank": "Rang",
|
||||
"guild": "Gilde",
|
||||
"points": "Punkte",
|
||||
"no_guilds": "Noch keine Gilden gefunden.",
|
||||
"members_col": "Mitglieder"
|
||||
},
|
||||
"trophy": {
|
||||
"title": "Trophäenraum",
|
||||
"title_user": "Trophäenraum von {name}",
|
||||
"achievements": "Errungenschaften",
|
||||
"unlocked_label": "Freigeschaltet",
|
||||
"locked_label": "Gesperrt"
|
||||
}
|
||||
}
|
||||
162
resources/js/i18n/en.json
Normal file
162
resources/js/i18n/en.json
Normal file
@@ -0,0 +1,162 @@
|
||||
{
|
||||
"nav": {
|
||||
"lobby": "Lobby",
|
||||
"liveCasino": "Live Casino",
|
||||
"slots": "Slots",
|
||||
"wallet": "Wallet",
|
||||
"bonuses": "Bonuses",
|
||||
"vip": "VIP Club",
|
||||
"guilds": "Guilds",
|
||||
"responsible": "Responsible",
|
||||
"faq": "FAQ",
|
||||
"settings": "Settings",
|
||||
"public_profile": "Public Profile",
|
||||
"account_settings": "Account Settings"
|
||||
},
|
||||
"topbar": {
|
||||
"welcome_guest": "Welcome to BetiX",
|
||||
"welcome_user": "Welcome back, {name}",
|
||||
"vault": "Vault",
|
||||
"search": "Search",
|
||||
"deposit": "Deposit"
|
||||
},
|
||||
"footer": {
|
||||
"legal": {
|
||||
"terms": "Terms and Conditions",
|
||||
"cookies": "Cookie Policy",
|
||||
"privacy": "Privacy Policy",
|
||||
"bonusPolicy": "Bonus Policy",
|
||||
"disputes": "Dispute Resolution Policy",
|
||||
"responsible": "Responsible Gaming",
|
||||
"aml": "AML Policy",
|
||||
"risks": "Risk Warnings"
|
||||
}
|
||||
},
|
||||
"wallet": { "title": "Wallet", "subtitle": "Manage your funds, deposit, and withdraw." },
|
||||
"vault": {
|
||||
"title": "Vault",
|
||||
"toVault": "To Vault",
|
||||
"fromVault": "From Vault",
|
||||
"amount": { "placeholder": "e.g. 6200 or 6200.0000" }
|
||||
},
|
||||
"bonuses": {
|
||||
"title": "Promotions",
|
||||
"tabs": { "available": "Available", "active": "Active", "history": "History" },
|
||||
"featured": "FEATURED",
|
||||
"minDeposit": "Min. Deposit",
|
||||
"claim": "Claim Now",
|
||||
"activate": "Activate",
|
||||
"errorLoad": "Failed to load bonuses",
|
||||
"noActive": "No active bonuses. Check available offers!",
|
||||
"noHistory": "No history yet.",
|
||||
"ended": "Ended",
|
||||
"wagerProgress": "Wager Progress",
|
||||
"wageredOf": "{wagered} / {total} wagered",
|
||||
"bonus": "Bonus"
|
||||
},
|
||||
"notif": { "success": "Success", "error": "Error", "dropdown": { "title": "Notifications", "clearAll": "Clear All", "empty": "No notifications yet" } },
|
||||
"sections": {
|
||||
"gaming": "Gaming",
|
||||
"user": "User",
|
||||
"supportInfo": "Support & Info"
|
||||
},
|
||||
"policy": {
|
||||
"toc": "Contents",
|
||||
"lastUpdated": "Last updated"
|
||||
},
|
||||
"common": {
|
||||
"guest": "Guest",
|
||||
"loginToPlay": "Please login to play"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Log In",
|
||||
"register": "Sign Up",
|
||||
"logout": "Logout"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Search games, @users, providers...",
|
||||
"slots": "Games",
|
||||
"users": "Players",
|
||||
"providers": "Providers",
|
||||
"noResults": "No results found",
|
||||
"hint": "Type @ to search users, type provider name to filter"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"clearAll": "Clear All",
|
||||
"empty": "No notifications yet",
|
||||
"markRead": "Mark all read"
|
||||
},
|
||||
"dashboard": {
|
||||
"welcome_offer": "Welcome Offer",
|
||||
"claim_now": "Claim Now",
|
||||
"read_terms": "Read Terms",
|
||||
"search_placeholder": "Search games...",
|
||||
"originals": "Originals",
|
||||
"loading": "Loading...",
|
||||
"reload": "Retry",
|
||||
"no_games": "No games found.",
|
||||
"no_slots": "No slots found.",
|
||||
"no_live": "No live games found.",
|
||||
"popular_slots": "Popular Slots",
|
||||
"show_all": "Show All",
|
||||
"live_wins": "Live Wins",
|
||||
"live_feed": "Live Feed",
|
||||
"won_in": "won in",
|
||||
"new_releases": "New Releases",
|
||||
"live_casino": "Live Casino",
|
||||
"hall_of_fame": "Hall of Fame",
|
||||
"recently_played": "Recently Played",
|
||||
"error_loading": "Error loading games."
|
||||
},
|
||||
"vip": {
|
||||
"your_rank": "YOUR RANK",
|
||||
"max": "MAX",
|
||||
"unlocked": "Unlocked",
|
||||
"current": "Current",
|
||||
"locked": "Locked",
|
||||
"benefits": "Benefits",
|
||||
"requires": "Requires",
|
||||
"reward_claimed": "Reward Claimed"
|
||||
},
|
||||
"bonus": {
|
||||
"promo_title": "Redeem Promo Code",
|
||||
"promo_placeholder": "Enter your code (e.g. WELCOME10)",
|
||||
"promo_redeem": "Redeem",
|
||||
"promo_redeeming": "Redeeming...",
|
||||
"unlock_at": "Unlock at"
|
||||
},
|
||||
"guilds": {
|
||||
"title": "Guilds",
|
||||
"subtitle_new": "Create a guild or join one with an invite code.",
|
||||
"subtitle_member": "Manage your guild, invite members, and see your roster.",
|
||||
"tab_active": "Active",
|
||||
"create": "Create a Guild",
|
||||
"join": "Join a Guild",
|
||||
"name": "Name",
|
||||
"tag": "Tag",
|
||||
"logo_url": "Logo URL",
|
||||
"description": "Description",
|
||||
"invite_code": "Invite code",
|
||||
"appearance": "Appearance",
|
||||
"members": "Members",
|
||||
"sorted_by_wager": "Sorted by Wager",
|
||||
"wagered": "Wagered",
|
||||
"kick": "Kick Member",
|
||||
"leave": "Leave Guild",
|
||||
"top_title": "Leaderboard",
|
||||
"top_desc": "Highest ranked guilds by points. Top guilds receive bonuses.",
|
||||
"rank": "Rank",
|
||||
"guild": "Guild",
|
||||
"points": "Points",
|
||||
"no_guilds": "No guilds found yet.",
|
||||
"members_col": "Members"
|
||||
},
|
||||
"trophy": {
|
||||
"title": "Trophy Room",
|
||||
"title_user": "{name}'s Trophy Room",
|
||||
"achievements": "Achievements",
|
||||
"unlocked_label": "Unlocked",
|
||||
"locked_label": "Locked"
|
||||
}
|
||||
}
|
||||
68
resources/js/i18n/es.json
Normal file
68
resources/js/i18n/es.json
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"nav": {
|
||||
"lobby": "Lobby",
|
||||
"liveCasino": "Casino en Vivo",
|
||||
"slots": "Tragamonedas",
|
||||
"wallet": "Billetera",
|
||||
"bonuses": "Bonos",
|
||||
"vip": "Club VIP",
|
||||
"guilds": "Gremios",
|
||||
"responsible": "Juego responsable",
|
||||
"faq": "FAQ"
|
||||
},
|
||||
"topbar": {
|
||||
"welcome_guest": "Bienvenido a BetiX",
|
||||
"welcome_user": "Bienvenido de nuevo, {name}",
|
||||
"vault": "Vault",
|
||||
"search": "Buscar",
|
||||
"deposit": "Depositar"
|
||||
},
|
||||
"footer": {
|
||||
"legal": {
|
||||
"terms": "Términos y Condiciones",
|
||||
"cookies": "Política de Cookies",
|
||||
"privacy": "Política de Privacidad",
|
||||
"bonusPolicy": "Política de Bonos",
|
||||
"disputes": "Resolución de Disputas",
|
||||
"responsible": "Juego Responsable",
|
||||
"aml": "Política AML",
|
||||
"risks": "Advertencias de Riesgo"
|
||||
}
|
||||
},
|
||||
"wallet": { "title": "Billetera", "subtitle": "Administra tus fondos, depósitos y retiros." },
|
||||
"vault": {
|
||||
"title": "Vault",
|
||||
"toVault": "A Vault",
|
||||
"fromVault": "Desde Vault",
|
||||
"amount": { "placeholder": "p. ej., 6200 o 6200.0000" }
|
||||
},
|
||||
"bonuses": {
|
||||
"title": "Promociones",
|
||||
"tabs": { "available": "Disponibles", "active": "Activos", "history": "Historial" },
|
||||
"featured": "DESTACADO",
|
||||
"minDeposit": "Depósito mín.",
|
||||
"claim": "Reclamar",
|
||||
"activate": "Activar",
|
||||
"errorLoad": "No se pudieron cargar los bonos",
|
||||
"noActive": "No hay bonos activos. ¡Revisa las ofertas disponibles!",
|
||||
"noHistory": "Aún no hay historial.",
|
||||
"ended": "Finalizado",
|
||||
"wagerProgress": "Progreso de apuesta",
|
||||
"wageredOf": "{wagered} / {total} apostado",
|
||||
"bonus": "Bono"
|
||||
},
|
||||
"notif": { "success": "Éxito", "error": "Error", "dropdown": { "title": "Notificaciones", "clearAll": "Borrar todo", "empty": "Aún no hay notificaciones" } },
|
||||
"sections": {
|
||||
"gaming": "Juegos",
|
||||
"user": "Usuario",
|
||||
"supportInfo": "Soporte e Información"
|
||||
},
|
||||
"policy": {
|
||||
"toc": "Contenido",
|
||||
"lastUpdated": "Última actualización"
|
||||
},
|
||||
"common": {
|
||||
"guest": "Invitado",
|
||||
"loginToPlay": "Inicia sesión para jugar"
|
||||
}
|
||||
}
|
||||
43
resources/js/i18n/index.ts
Normal file
43
resources/js/i18n/index.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { createI18n } from 'vue-i18n';
|
||||
|
||||
// Start with English synchronously to avoid FOUC; other locales lazy-loaded
|
||||
async function loadLocaleMessages(locale: string) {
|
||||
const norm = locale.replace('-', '_');
|
||||
switch (norm) {
|
||||
case 'de':
|
||||
return (await import('./de.json')).default;
|
||||
case 'es':
|
||||
return (await import('./es.json')).default;
|
||||
case 'pt_BR':
|
||||
case 'pt-br':
|
||||
return (await import('./pt_BR.json')).default;
|
||||
case 'tr':
|
||||
return (await import('./tr.json')).default;
|
||||
case 'pl':
|
||||
return (await import('./pl.json')).default;
|
||||
default:
|
||||
return (await import('./en.json')).default;
|
||||
}
|
||||
}
|
||||
|
||||
export const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
fallbackLocale: 'en',
|
||||
messages: {},
|
||||
});
|
||||
|
||||
export async function initI18n(startLocale: string) {
|
||||
const msgs = await loadLocaleMessages(startLocale || 'en');
|
||||
i18n.global.setLocaleMessage(startLocale || 'en', msgs);
|
||||
i18n.global.locale.value = startLocale || 'en';
|
||||
}
|
||||
|
||||
export async function setLocale(locale: string) {
|
||||
const norm = locale.replace('-', '_');
|
||||
if (!i18n.global.getLocaleMessage(norm) || Object.keys(i18n.global.getLocaleMessage(norm)).length === 0) {
|
||||
const msgs = await loadLocaleMessages(norm);
|
||||
i18n.global.setLocaleMessage(norm, msgs);
|
||||
}
|
||||
i18n.global.locale.value = norm;
|
||||
}
|
||||
40
resources/js/i18n/pl.json
Normal file
40
resources/js/i18n/pl.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"nav": {
|
||||
"lobby": "Lobby",
|
||||
"liveCasino": "Kasyno na żywo",
|
||||
"slots": "Sloty",
|
||||
"wallet": "Portfel",
|
||||
"bonuses": "Bonusy",
|
||||
"vip": "Klub VIP",
|
||||
"guilds": "Gildie",
|
||||
"responsible": "Odpowiedzialna gra",
|
||||
"faq": "FAQ"
|
||||
},
|
||||
"topbar": {
|
||||
"welcome_guest": "Witamy w BetiX",
|
||||
"welcome_user": "Witamy ponownie, {name}",
|
||||
"vault": "Sejf",
|
||||
"search": "Szukaj",
|
||||
"deposit": "Wpłata"
|
||||
},
|
||||
"footer": {
|
||||
"legal": {
|
||||
"terms": "Regulamin",
|
||||
"cookies": "Polityka Cookies",
|
||||
"privacy": "Polityka Prywatności",
|
||||
"bonusPolicy": "Polityka Bonusów",
|
||||
"disputes": "Polityka Rozwiązywania Sporów",
|
||||
"responsible": "Odpowiedzialna Gra",
|
||||
"aml": "Polityka AML",
|
||||
"risks": "Ostrzeżenia o Ryzyku"
|
||||
}
|
||||
},
|
||||
"wallet": { "title": "Portfel", "subtitle": "Zarządzaj środkami, wpłatami i wypłatami." },
|
||||
"vault": {
|
||||
"title": "Sejf",
|
||||
"toVault": "Do sejfu",
|
||||
"fromVault": "Z sejfu",
|
||||
"amount": { "placeholder": "np. 6200 lub 6200.0000" }
|
||||
},
|
||||
"notif": { "success": "Sukces", "error": "Błąd" }
|
||||
}
|
||||
55
resources/js/i18n/pt_BR.json
Normal file
55
resources/js/i18n/pt_BR.json
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"nav": {
|
||||
"lobby": "Lobby",
|
||||
"liveCasino": "Cassino ao Vivo",
|
||||
"slots": "Caça‑níqueis",
|
||||
"wallet": "Carteira",
|
||||
"bonuses": "Bônus",
|
||||
"vip": "Clube VIP",
|
||||
"guilds": "Guildas",
|
||||
"responsible": "Jogo responsável",
|
||||
"faq": "FAQ"
|
||||
},
|
||||
"topbar": {
|
||||
"welcome_guest": "Bem-vindo à BetiX",
|
||||
"welcome_user": "Bem-vindo de volta, {name}",
|
||||
"vault": "Cofre",
|
||||
"search": "Buscar",
|
||||
"deposit": "Depositar"
|
||||
},
|
||||
"footer": {
|
||||
"legal": {
|
||||
"terms": "Termos e Condições",
|
||||
"cookies": "Política de Cookies",
|
||||
"privacy": "Política de Privacidade",
|
||||
"bonusPolicy": "Política de Bônus",
|
||||
"disputes": "Política de Resolução de Disputas",
|
||||
"responsible": "Jogo Responsável",
|
||||
"aml": "Política AML",
|
||||
"risks": "Avisos de Risco"
|
||||
}
|
||||
},
|
||||
"wallet": { "title": "Carteira", "subtitle": "Gerencie seus fundos, depósitos e saques." },
|
||||
"vault": {
|
||||
"title": "Cofre",
|
||||
"toVault": "Para o Cofre",
|
||||
"fromVault": "Do Cofre",
|
||||
"amount": { "placeholder": "ex.: 6200 ou 6200.0000" }
|
||||
},
|
||||
"bonuses": {
|
||||
"title": "Promoções",
|
||||
"tabs": { "available": "Disponíveis", "active": "Ativos", "history": "Histórico" },
|
||||
"featured": "DESTAQUE",
|
||||
"minDeposit": "Depósito mín.",
|
||||
"claim": "Resgatar",
|
||||
"activate": "Ativar",
|
||||
"errorLoad": "Falha ao carregar os bônus",
|
||||
"noActive": "Não há bônus ativos. Veja as ofertas disponíveis!",
|
||||
"noHistory": "Ainda sem histórico.",
|
||||
"ended": "Encerrado",
|
||||
"wagerProgress": "Progresso de aposta",
|
||||
"wageredOf": "{wagered} / {total} apostado",
|
||||
"bonus": "Bônus"
|
||||
},
|
||||
"notif": { "success": "Sucesso", "error": "Erro" }
|
||||
}
|
||||
55
resources/js/i18n/tr.json
Normal file
55
resources/js/i18n/tr.json
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"nav": {
|
||||
"lobby": "Lobi",
|
||||
"liveCasino": "Canlı Casino",
|
||||
"slots": "Slotlar",
|
||||
"wallet": "Cüzdan",
|
||||
"bonuses": "Bonuslar",
|
||||
"vip": "VIP Kulübü",
|
||||
"guilds": "Loncalar",
|
||||
"responsible": "Sorumlu Oyun",
|
||||
"faq": "SSS"
|
||||
},
|
||||
"topbar": {
|
||||
"welcome_guest": "BetiX'e Hoş Geldiniz",
|
||||
"welcome_user": "Tekrar hoş geldin, {name}",
|
||||
"vault": "Kasa",
|
||||
"search": "Ara",
|
||||
"deposit": "Yatır"
|
||||
},
|
||||
"footer": {
|
||||
"legal": {
|
||||
"terms": "Şartlar ve Koşullar",
|
||||
"cookies": "Çerez Politikası",
|
||||
"privacy": "Gizlilik Politikası",
|
||||
"bonusPolicy": "Bonus Politikası",
|
||||
"disputes": "Uyuşmazlık Çözümü Politikası",
|
||||
"responsible": "Sorumlu Oyun",
|
||||
"aml": "AML Politikası",
|
||||
"risks": "Risk Uyarıları"
|
||||
}
|
||||
},
|
||||
"wallet": { "title": "Cüzdan", "subtitle": "Bakiyeni yönet, para yatır ve çek." },
|
||||
"vault": {
|
||||
"title": "Kasa",
|
||||
"toVault": "Kasaya",
|
||||
"fromVault": "Kasadan",
|
||||
"amount": { "placeholder": "örn. 6200 veya 6200.0000" }
|
||||
},
|
||||
"bonuses": {
|
||||
"title": "Promosyonlar",
|
||||
"tabs": { "available": "Mevcut", "active": "Aktif", "history": "Geçmiş" },
|
||||
"featured": "ÖNE ÇIKAN",
|
||||
"minDeposit": "Min. Yatırım",
|
||||
"claim": "Hemen Al",
|
||||
"activate": "Aktifleştir",
|
||||
"errorLoad": "Bonuslar yüklenemedi",
|
||||
"noActive": "Aktif bonus yok. Mevcut tekliflere göz at!",
|
||||
"noHistory": "Henüz geçmiş yok.",
|
||||
"ended": "Bitti",
|
||||
"wagerProgress": "Çevrim İlerleme",
|
||||
"wageredOf": "{wagered} / {total} çevrildi",
|
||||
"bonus": "Bonus"
|
||||
},
|
||||
"notif": { "success": "Başarılı", "error": "Hata" }
|
||||
}
|
||||
18
resources/js/layouts/AppLayout.vue
Normal file
18
resources/js/layouts/AppLayout.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import AppLayout from '@/layouts/app/AppSidebarLayout.vue';
|
||||
import type { BreadcrumbItem } from '@/types';
|
||||
|
||||
type Props = {
|
||||
breadcrumbs?: BreadcrumbItem[];
|
||||
};
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
breadcrumbs: () => [],
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<slot />
|
||||
</AppLayout>
|
||||
</template>
|
||||
14
resources/js/layouts/AuthLayout.vue
Normal file
14
resources/js/layouts/AuthLayout.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import AuthLayout from '@/layouts/auth/AuthSimpleLayout.vue';
|
||||
|
||||
defineProps<{
|
||||
title?: string;
|
||||
description?: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthLayout :title="title" :description="description">
|
||||
<slot />
|
||||
</AuthLayout>
|
||||
</template>
|
||||
88
resources/js/layouts/admin/AdminLayout.vue
Normal file
88
resources/js/layouts/admin/AdminLayout.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
import { Link, usePage } from '@inertiajs/vue3';
|
||||
import { computed, onMounted, nextTick } from 'vue';
|
||||
|
||||
const page = usePage();
|
||||
const user = computed(() => (page.props as any).auth?.user || null);
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => { if ((window as any).lucide) (window as any).lucide.createIcons(); });
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-layout">
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<i data-lucide="shield"></i>
|
||||
<span>Admin</span>
|
||||
</div>
|
||||
<nav class="nav">
|
||||
<Link href="/admin" class="nav-item" :class="{ active: $page.url === '/admin' }">
|
||||
<i data-lucide="layout-dashboard"></i>
|
||||
<span>Dashboard</span>
|
||||
</Link>
|
||||
<div class="nav-label">Wallets</div>
|
||||
<Link href="/admin/wallets/settings" class="nav-item" :class="{ active: $page.url.startsWith('/admin/wallets/settings') }">
|
||||
<i data-lucide="settings"></i>
|
||||
<span>Einstellungen</span>
|
||||
</Link>
|
||||
<Link href="/admin/payments/settings" class="nav-item" :class="{ active: $page.url.startsWith('/admin/payments/settings') }">
|
||||
<i data-lucide="credit-card"></i>
|
||||
<span>Zahlungen</span>
|
||||
</Link>
|
||||
<div class="nav-label">Weitere</div>
|
||||
<Link href="/admin/promos" class="nav-item" :class="{ active: $page.url.startsWith('/admin/promos') }">
|
||||
<i data-lucide="gift"></i>
|
||||
<span>Promos</span>
|
||||
</Link>
|
||||
<Link href="/admin/support" class="nav-item" :class="{ active: $page.url.startsWith('/admin/support') }">
|
||||
<i data-lucide="message-square"></i>
|
||||
<span>Support</span>
|
||||
</Link>
|
||||
</nav>
|
||||
<div class="sidebar-foot">
|
||||
<div class="user">
|
||||
<i data-lucide="user"></i>
|
||||
<span>{{ user?.username || user?.name || 'Admin' }}</span>
|
||||
</div>
|
||||
<Link href="/" class="back-link"><i data-lucide="arrow-left"></i> Zurück</Link>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main">
|
||||
<header class="topbar">
|
||||
<div class="title"><slot name="title">Admin</slot></div>
|
||||
<div class="actions"><slot name="actions" /></div>
|
||||
</header>
|
||||
<div class="container">
|
||||
<slot />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-layout { display: grid; grid-template-columns: 260px 1fr; min-height: 100vh; background: #0b0b0c; color: #e5e7eb; }
|
||||
.sidebar { background: #0f0f10; border-right: 1px solid #1f1f22; display: flex; flex-direction: column; }
|
||||
.brand { height: 56px; display: flex; align-items: center; gap: 10px; padding: 0 16px; font-weight: 800; letter-spacing: .5px; border-bottom: 1px solid #1f1f22; }
|
||||
.brand i { width: 18px; height: 18px; }
|
||||
.nav { padding: 12px; display: flex; flex-direction: column; gap: 6px; }
|
||||
.nav-item { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 10px; color: #cbd5e1; text-decoration: none; }
|
||||
.nav-item i { width: 18px; height: 18px; }
|
||||
.nav-item:hover { background: #151518; color: #fff; }
|
||||
.nav-item.active { background: #1b1b20; color: #fff; border: 1px solid #23232a; }
|
||||
.nav-label { font-size: 11px; color: #9ca3af; font-weight: 700; padding: 8px 12px 0; text-transform: uppercase; letter-spacing: 1px; }
|
||||
.sidebar-foot { margin-top: auto; padding: 12px; border-top: 1px solid #1f1f22; display: grid; gap: 8px; }
|
||||
.user { display: flex; align-items: center; gap: 8px; color: #9ca3af; }
|
||||
.back-link { color: #cbd5e1; text-decoration: none; display: inline-flex; align-items: center; gap: 8px; }
|
||||
.back-link:hover { color: #fff; }
|
||||
.main { display: flex; flex-direction: column; }
|
||||
.topbar { height: 56px; display: flex; align-items: center; justify-content: space-between; padding: 0 16px; border-bottom: 1px solid #1f1f22; background: #0f0f10; }
|
||||
.title { font-weight: 800; }
|
||||
.container { padding: 16px; }
|
||||
@media (max-width: 960px) {
|
||||
.admin-layout { grid-template-columns: 1fr; }
|
||||
.sidebar { display: none; }
|
||||
}
|
||||
</style>
|
||||
729
resources/js/layouts/admin/CasinoAdminLayout.vue
Normal file
729
resources/js/layouts/admin/CasinoAdminLayout.vue
Normal file
@@ -0,0 +1,729 @@
|
||||
<script setup lang="ts">
|
||||
import { Link, router, usePage } from '@inertiajs/vue3';
|
||||
import { computed, onMounted, nextTick, ref, watch } from 'vue';
|
||||
|
||||
const page = usePage();
|
||||
const user = computed(() => (page.props as any).auth?.user || null);
|
||||
const mobileOpen = ref(false);
|
||||
const isLoading = ref(false);
|
||||
const loadingProgress = ref(0);
|
||||
const pageVisible = ref(false);
|
||||
let loadTimer: any = null;
|
||||
|
||||
const navSections = [
|
||||
{
|
||||
label: 'Overview',
|
||||
items: [
|
||||
{ label: 'Dashboard', icon: 'layout-dashboard', href: '/admin/casino', segment: 'casino' },
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Users',
|
||||
items: [
|
||||
{ label: 'Nutzer', icon: 'users', href: '/admin/users', segment: 'users' },
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Content',
|
||||
items: [
|
||||
{ label: 'Live Chat', icon: 'message-square', href: '/admin/chat', segment: 'chat' },
|
||||
{ label: 'Promos', icon: 'ticket', href: '/admin/promos', segment: 'promos' },
|
||||
{ label: 'Support', icon: 'life-buoy', href: '/admin/support', segment: 'support' },
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Moderation',
|
||||
items: [
|
||||
{ label: 'Chat Reports', icon: 'flag', href: '/admin/reports/chat', segment: 'reports/chat' },
|
||||
{ label: 'Profil Reports', icon: 'shield-alert', href: '/admin/reports/profiles', segment: 'reports/profiles' },
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Payments',
|
||||
items: [
|
||||
{ label: 'Payments', icon: 'credit-card', href: '/admin/payments/settings', segment: 'payments' },
|
||||
{ label: 'Wallets', icon: 'wallet', href: '/admin/wallets/settings', segment: 'wallets' },
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Settings',
|
||||
items: [
|
||||
{ label: 'Site Settings', icon: 'settings', href: '/admin/settings/site', segment: 'settings/site' },
|
||||
{ label: 'VPN & GeoBlock', icon: 'shield-ban', href: '/admin/settings/geo', segment: 'settings/geo' },
|
||||
]
|
||||
},
|
||||
];
|
||||
|
||||
const isActive = (segment: string) => page.url.startsWith(`/admin/${segment}`);
|
||||
const isAnyActive = (items: any[]) => items.some(i => isActive(i.segment));
|
||||
|
||||
function startLoading() {
|
||||
isLoading.value = true;
|
||||
loadingProgress.value = 0;
|
||||
pageVisible.value = false;
|
||||
loadTimer = setInterval(() => {
|
||||
if (loadingProgress.value < 85) loadingProgress.value += Math.random() * 18;
|
||||
}, 120);
|
||||
}
|
||||
|
||||
function finishLoading() {
|
||||
clearInterval(loadTimer);
|
||||
loadingProgress.value = 100;
|
||||
setTimeout(() => {
|
||||
isLoading.value = false;
|
||||
loadingProgress.value = 0;
|
||||
pageVisible.value = true;
|
||||
nextTick(() => { if ((window as any).lucide) (window as any).lucide.createIcons(); });
|
||||
}, 300);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
pageVisible.value = true;
|
||||
router.on('start', startLoading);
|
||||
router.on('finish', finishLoading);
|
||||
nextTick(() => { if ((window as any).lucide) (window as any).lucide.createIcons(); });
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="al-root" :class="{ 'mobile-open': mobileOpen }">
|
||||
|
||||
<!-- Animated background -->
|
||||
<div class="al-bg" aria-hidden="true">
|
||||
<div class="al-bg-blob al-bg-blob-1"></div>
|
||||
<div class="al-bg-blob al-bg-blob-2"></div>
|
||||
<div class="al-bg-blob al-bg-blob-3"></div>
|
||||
<div class="al-bg-grid"></div>
|
||||
</div>
|
||||
|
||||
<!-- Loading bar -->
|
||||
<div class="al-progress-bar" :class="{ visible: isLoading }">
|
||||
<div class="al-progress-fill" :style="{ width: loadingProgress + '%' }"></div>
|
||||
<div class="al-progress-glow"></div>
|
||||
</div>
|
||||
|
||||
<!-- Loading overlay -->
|
||||
<Transition name="fade">
|
||||
<div v-if="isLoading" class="al-loading-overlay">
|
||||
<div class="al-spinner">
|
||||
<div class="al-spinner-ring"></div>
|
||||
<div class="al-spinner-ring al-spinner-ring-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Mobile overlay -->
|
||||
<Transition name="fade">
|
||||
<div v-if="mobileOpen" class="al-overlay" @click="mobileOpen = false"></div>
|
||||
</Transition>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<aside class="al-sidebar">
|
||||
<div class="al-sidebar-inner">
|
||||
<!-- Brand -->
|
||||
<div class="al-brand">
|
||||
<div class="al-brand-icon">
|
||||
<i data-lucide="swords"></i>
|
||||
<div class="al-brand-icon-glow"></div>
|
||||
</div>
|
||||
<div class="al-brand-text">
|
||||
<span class="al-brand-name">Casino Admin</span>
|
||||
<span class="al-brand-sub">Control Panel</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nav -->
|
||||
<nav class="al-nav">
|
||||
<template v-for="section in navSections" :key="section.label">
|
||||
<div class="al-nav-section">
|
||||
<div class="al-nav-label">{{ section.label }}</div>
|
||||
<Link
|
||||
v-for="item in section.items"
|
||||
:key="item.href"
|
||||
:href="item.href"
|
||||
class="al-nav-item"
|
||||
:class="{ active: isActive(item.segment) }"
|
||||
@click="mobileOpen = false"
|
||||
>
|
||||
<span class="al-nav-item-bg"></span>
|
||||
<i :data-lucide="item.icon" class="al-nav-icon"></i>
|
||||
<span class="al-nav-text">{{ item.label }}</span>
|
||||
<span v-if="isActive(item.segment)" class="al-active-pip"></span>
|
||||
</Link>
|
||||
</div>
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="al-foot">
|
||||
<div class="al-user">
|
||||
<div class="al-user-avatar">
|
||||
<img v-if="user?.avatar_url" :src="user.avatar_url" alt="">
|
||||
<span v-else>{{ (user?.username || 'A')[0].toUpperCase() }}</span>
|
||||
</div>
|
||||
<div class="al-user-info">
|
||||
<span class="al-user-name">{{ user?.username || 'Admin' }}</span>
|
||||
<span class="al-user-badge">Administrator</span>
|
||||
</div>
|
||||
<div class="al-user-status"></div>
|
||||
</div>
|
||||
<Link href="/" class="al-back-btn">
|
||||
<i data-lucide="arrow-left"></i>
|
||||
<span>Zurück zur Site</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main -->
|
||||
<div class="al-main">
|
||||
<!-- Topbar -->
|
||||
<header class="al-topbar">
|
||||
<div class="al-topbar-left">
|
||||
<button class="al-menu-btn" @click="mobileOpen = !mobileOpen">
|
||||
<i data-lucide="menu"></i>
|
||||
</button>
|
||||
<div class="al-breadcrumb">
|
||||
<span class="al-breadcrumb-icon"><i data-lucide="layout-dashboard"></i></span>
|
||||
<span class="al-breadcrumb-sep">/</span>
|
||||
<h1 class="al-page-title">
|
||||
<slot name="title">Dashboard</slot>
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="al-topbar-right">
|
||||
<slot name="actions" />
|
||||
<Link href="/" class="al-site-btn">
|
||||
<i data-lucide="external-link"></i>
|
||||
<span>Live Site</span>
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Page content -->
|
||||
<Transition name="page">
|
||||
<div v-if="pageVisible" class="al-content">
|
||||
<slot />
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ─── Root ─── */
|
||||
.al-root {
|
||||
display: grid;
|
||||
grid-template-columns: 260px 1fr;
|
||||
min-height: 100vh;
|
||||
background: #06060a;
|
||||
color: #e4e4e7;
|
||||
font-family: 'Inter', sans-serif;
|
||||
position: relative;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* ─── Animated Background ─── */
|
||||
.al-bg {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.al-bg-blob {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(80px);
|
||||
opacity: 0.06;
|
||||
}
|
||||
.al-bg-blob-1 {
|
||||
width: 600px; height: 600px;
|
||||
background: radial-gradient(circle, #df006a, transparent);
|
||||
top: -200px; left: -100px;
|
||||
animation: blob-drift 18s ease-in-out infinite;
|
||||
}
|
||||
.al-bg-blob-2 {
|
||||
width: 500px; height: 500px;
|
||||
background: radial-gradient(circle, #7c3aed, transparent);
|
||||
bottom: -100px; right: -100px;
|
||||
animation: blob-drift 24s ease-in-out infinite reverse;
|
||||
}
|
||||
.al-bg-blob-3 {
|
||||
width: 400px; height: 400px;
|
||||
background: radial-gradient(circle, #0ea5e9, transparent);
|
||||
top: 50%; left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
animation: blob-drift 30s ease-in-out infinite 6s;
|
||||
}
|
||||
@keyframes blob-drift {
|
||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||
33% { transform: translate(60px, -40px) scale(1.1); }
|
||||
66% { transform: translate(-40px, 60px) scale(0.95); }
|
||||
}
|
||||
|
||||
.al-bg-grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(255,255,255,0.015) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,0.015) 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
}
|
||||
|
||||
/* ─── Progress Bar ─── */
|
||||
.al-progress-bar {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0;
|
||||
height: 2px;
|
||||
z-index: 9999;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
background: transparent;
|
||||
}
|
||||
.al-progress-bar.visible { opacity: 1; }
|
||||
.al-progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #df006a, #f472b6, #df006a);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer-progress 1.2s linear infinite;
|
||||
transition: width 0.15s ease;
|
||||
border-radius: 0 2px 2px 0;
|
||||
}
|
||||
@keyframes shimmer-progress {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
.al-progress-glow {
|
||||
position: absolute;
|
||||
right: 0; top: -2px;
|
||||
width: 80px; height: 6px;
|
||||
background: rgba(223,0,106,0.6);
|
||||
filter: blur(6px);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* ─── Loading Overlay ─── */
|
||||
.al-loading-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
.al-spinner {
|
||||
position: relative;
|
||||
width: 44px; height: 44px;
|
||||
}
|
||||
.al-spinner-ring {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
border: 2px solid transparent;
|
||||
border-top-color: #df006a;
|
||||
animation: spin 0.9s linear infinite;
|
||||
}
|
||||
.al-spinner-ring-2 {
|
||||
inset: 6px;
|
||||
border-top-color: #f472b6;
|
||||
animation-duration: 0.6s;
|
||||
animation-direction: reverse;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ─── Sidebar ─── */
|
||||
.al-sidebar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
z-index: 200;
|
||||
background: rgba(12, 12, 18, 0.95);
|
||||
border-right: 1px solid rgba(255,255,255,0.06);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.al-sidebar::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; right: 0;
|
||||
width: 1px; height: 100%;
|
||||
background: linear-gradient(180deg, transparent, rgba(223,0,106,0.3) 50%, transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.al-sidebar-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.al-sidebar-inner::-webkit-scrollbar { display: none; }
|
||||
|
||||
/* Brand */
|
||||
.al-brand {
|
||||
height: 68px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 20px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.al-brand-icon {
|
||||
position: relative;
|
||||
width: 38px; height: 38px;
|
||||
background: linear-gradient(135deg, #df006a 0%, #7c0036 100%);
|
||||
border-radius: 12px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 4px 16px rgba(223,0,106,0.5);
|
||||
animation: icon-pulse 3s ease-in-out infinite;
|
||||
}
|
||||
@keyframes icon-pulse {
|
||||
0%, 100% { box-shadow: 0 4px 16px rgba(223,0,106,0.5); }
|
||||
50% { box-shadow: 0 4px 24px rgba(223,0,106,0.8), 0 0 40px rgba(223,0,106,0.2); }
|
||||
}
|
||||
.al-brand-icon i { width: 18px; height: 18px; position: relative; z-index: 1; }
|
||||
.al-brand-icon-glow {
|
||||
position: absolute;
|
||||
inset: -2px;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(135deg, rgba(223,0,106,0.4), transparent);
|
||||
filter: blur(4px);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
.al-brand:hover .al-brand-icon-glow { opacity: 1; }
|
||||
|
||||
.al-brand-text { display: flex; flex-direction: column; }
|
||||
.al-brand-name { font-weight: 800; font-size: 14px; color: #fff; letter-spacing: 0.3px; }
|
||||
.al-brand-sub {
|
||||
font-size: 9px; color: #df006a; font-weight: 700;
|
||||
text-transform: uppercase; letter-spacing: 1.5px;
|
||||
}
|
||||
|
||||
/* Navigation */
|
||||
.al-nav { padding: 12px; flex: 1; display: flex; flex-direction: column; gap: 2px; }
|
||||
.al-nav-section { margin-bottom: 8px; }
|
||||
.al-nav-label {
|
||||
font-size: 9px;
|
||||
font-weight: 800;
|
||||
color: #3f3f46;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.5px;
|
||||
padding: 8px 10px 4px;
|
||||
}
|
||||
|
||||
.al-nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
color: #52525b;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
position: relative;
|
||||
margin-bottom: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.al-nav-item-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 10px;
|
||||
opacity: 0;
|
||||
background: rgba(255,255,255,0.04);
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.al-nav-item:hover { color: #d4d4d8; transform: translateX(2px); }
|
||||
.al-nav-item:hover .al-nav-item-bg { opacity: 1; }
|
||||
.al-nav-item.active {
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, rgba(223,0,106,0.18) 0%, rgba(223,0,106,0.08) 100%);
|
||||
border: 1px solid rgba(223,0,106,0.25);
|
||||
box-shadow: 0 2px 12px rgba(223,0,106,0.15), inset 0 1px 0 rgba(255,255,255,0.05);
|
||||
}
|
||||
.al-nav-icon { width: 16px; height: 16px; flex-shrink: 0; transition: transform 0.2s; }
|
||||
.al-nav-item:hover .al-nav-icon { transform: scale(1.1); }
|
||||
.al-nav-item.active .al-nav-icon { color: #f472b6; filter: drop-shadow(0 0 4px rgba(244,114,182,0.6)); }
|
||||
.al-nav-text { flex: 1; }
|
||||
.al-active-pip {
|
||||
width: 6px; height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #df006a;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 0 8px rgba(223,0,106,0.9);
|
||||
animation: pip-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pip-pulse {
|
||||
0%, 100% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.4); opacity: 0.7; }
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.al-foot {
|
||||
padding: 12px;
|
||||
border-top: 1px solid rgba(255,255,255,0.05);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.al-user {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 10px 12px;
|
||||
background: rgba(255,255,255,0.03);
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
border-radius: 12px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.al-user:hover { background: rgba(255,255,255,0.05); border-color: rgba(255,255,255,0.1); }
|
||||
.al-user-avatar {
|
||||
width: 34px; height: 34px; border-radius: 10px;
|
||||
background: linear-gradient(135deg, #df006a, #7c0036);
|
||||
overflow: hidden; display: flex; align-items: center; justify-content: center;
|
||||
font-weight: 800; font-size: 13px; color: #fff; flex-shrink: 0;
|
||||
box-shadow: 0 2px 8px rgba(223,0,106,0.4);
|
||||
}
|
||||
.al-user-avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.al-user-info { display: flex; flex-direction: column; flex: 1; min-width: 0; }
|
||||
.al-user-name { font-weight: 700; font-size: 12px; color: #e4e4e7; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.al-user-badge {
|
||||
display: inline-block; font-size: 9px; font-weight: 800;
|
||||
color: #df006a; letter-spacing: 0.5px; text-transform: uppercase;
|
||||
}
|
||||
.al-user-status {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
background: #00c864; flex-shrink: 0;
|
||||
box-shadow: 0 0 6px rgba(0,200,100,0.7);
|
||||
animation: pip-pulse 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.al-back-btn {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
color: #3f3f46; font-size: 12px; font-weight: 600; text-decoration: none;
|
||||
padding: 8px 12px; border-radius: 10px;
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.al-back-btn:hover { color: #a1a1aa; background: rgba(255,255,255,0.04); border-color: rgba(255,255,255,0.06); }
|
||||
.al-back-btn i { width: 14px; height: 14px; transition: transform 0.2s; }
|
||||
.al-back-btn:hover i { transform: translateX(-2px); }
|
||||
|
||||
/* ─── Main ─── */
|
||||
.al-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Topbar */
|
||||
.al-topbar {
|
||||
height: 64px;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0 28px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||
background: rgba(10, 10, 16, 0.8);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
position: sticky; top: 0; z-index: 100;
|
||||
gap: 16px;
|
||||
}
|
||||
.al-topbar-left { display: flex; align-items: center; gap: 14px; min-width: 0; }
|
||||
.al-topbar-right { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
|
||||
|
||||
.al-menu-btn {
|
||||
display: none;
|
||||
width: 36px; height: 36px; border-radius: 10px;
|
||||
background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.08);
|
||||
cursor: pointer; color: #a1a1aa;
|
||||
align-items: center; justify-content: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.al-menu-btn:hover { background: rgba(255,255,255,0.1); color: #fff; transform: scale(1.05); }
|
||||
.al-menu-btn i { width: 18px; height: 18px; }
|
||||
|
||||
.al-breadcrumb {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.al-breadcrumb-icon { display: flex; color: #3f3f46; }
|
||||
.al-breadcrumb-icon i { width: 14px; height: 14px; }
|
||||
.al-breadcrumb-sep { color: #27272a; font-size: 14px; }
|
||||
.al-page-title {
|
||||
font-weight: 700; font-size: 16px; color: #e4e4e7;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.al-site-btn {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
background: rgba(255,255,255,0.04);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
color: #71717a; padding: 7px 14px;
|
||||
border-radius: 10px; font-size: 12px; font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.al-site-btn:hover {
|
||||
background: rgba(255,255,255,0.08);
|
||||
border-color: rgba(255,255,255,0.15);
|
||||
color: #fff;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
}
|
||||
.al-site-btn i { width: 13px; height: 13px; }
|
||||
|
||||
/* ─── Content ─── */
|
||||
.al-content { padding: 28px; flex: 1; }
|
||||
|
||||
/* ─── Overlay ─── */
|
||||
.al-overlay {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.8);
|
||||
backdrop-filter: blur(6px);
|
||||
z-index: 150;
|
||||
}
|
||||
|
||||
/* ─── Transitions ─── */
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s ease; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
|
||||
.page-enter-active {
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.page-leave-active {
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.page-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
.page-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
|
||||
/* ─── Global Admin Styles (passed via :deep) ─── */
|
||||
:deep(.admin-card) {
|
||||
background: rgba(15, 15, 20, 0.8);
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
backdrop-filter: blur(10px);
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
:deep(.admin-card:hover) {
|
||||
border-color: rgba(255,255,255,0.1);
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
:deep(.btn-primary) {
|
||||
position: relative;
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
background: linear-gradient(135deg, #df006a 0%, #b8005a 100%);
|
||||
border: none; color: #fff;
|
||||
padding: 10px 20px; border-radius: 10px;
|
||||
font-weight: 700; font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
box-shadow: 0 4px 14px rgba(223,0,106,0.35);
|
||||
overflow: hidden;
|
||||
}
|
||||
:deep(.btn-primary::before) {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: -100%;
|
||||
width: 100%; height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.1), transparent);
|
||||
transition: left 0.4s ease;
|
||||
}
|
||||
:deep(.btn-primary:hover) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(223,0,106,0.5);
|
||||
}
|
||||
:deep(.btn-primary:hover::before) { left: 100%; }
|
||||
:deep(.btn-primary:active) { transform: translateY(0); }
|
||||
:deep(.btn-primary:disabled) { opacity: 0.5; cursor: not-allowed; transform: none; box-shadow: none; }
|
||||
:deep(.btn-primary i) { width: 15px; height: 15px; }
|
||||
|
||||
:deep(.card) {
|
||||
background: rgba(12, 12, 18, 0.9);
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
:deep(.card-head) {
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.02) 0%, transparent 100%);
|
||||
}
|
||||
:deep(.card-head h2) { font-size: 16px; font-weight: 700; color: #fff; margin: 0 0 2px; }
|
||||
:deep(.card-subtitle) { color: #52525b; font-size: 12px; margin: 0; }
|
||||
:deep(.card-body) { padding: 20px 24px; }
|
||||
|
||||
:deep(.field-input) {
|
||||
background: rgba(255,255,255,0.04) !important;
|
||||
border: 1px solid rgba(255,255,255,0.08) !important;
|
||||
border-radius: 10px !important;
|
||||
color: #e4e4e7 !important;
|
||||
transition: border-color 0.2s, box-shadow 0.2s !important;
|
||||
}
|
||||
:deep(.field-input:focus) {
|
||||
outline: none !important;
|
||||
border-color: rgba(223,0,106,0.5) !important;
|
||||
box-shadow: 0 0 0 3px rgba(223,0,106,0.1) !important;
|
||||
}
|
||||
|
||||
:deep(.toggle-btn) {
|
||||
transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1) !important;
|
||||
}
|
||||
:deep(.toggle-btn.active) {
|
||||
box-shadow: 0 0 12px rgba(223,0,106,0.4) !important;
|
||||
}
|
||||
:deep(.toggle-knob) {
|
||||
transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1) !important;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.4) !important;
|
||||
}
|
||||
|
||||
:deep(.alert-success) {
|
||||
background: rgba(0, 200, 100, 0.08) !important;
|
||||
border: 1px solid rgba(0, 200, 100, 0.2) !important;
|
||||
border-radius: 12px !important;
|
||||
animation: slide-in 0.3s cubic-bezier(0.16, 1, 0.3, 1) !important;
|
||||
}
|
||||
@keyframes slide-in {
|
||||
from { opacity: 0; transform: translateY(-8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* ─── Mobile ─── */
|
||||
@media (max-width: 1024px) {
|
||||
.al-root { grid-template-columns: 1fr; }
|
||||
.al-sidebar {
|
||||
position: fixed; left: -280px; top: 0; width: 260px; height: 100vh;
|
||||
z-index: 300; transition: left 0.35s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.al-root.mobile-open .al-sidebar { left: 0; }
|
||||
.al-menu-btn { display: flex !important; }
|
||||
.al-content { padding: 16px; }
|
||||
.al-topbar { padding: 0 16px; }
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.al-site-btn span { display: none; }
|
||||
.al-breadcrumb-icon { display: none; }
|
||||
.al-breadcrumb-sep { display: none; }
|
||||
}
|
||||
</style>
|
||||
23
resources/js/layouts/app/AppHeaderLayout.vue
Normal file
23
resources/js/layouts/app/AppHeaderLayout.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import AppContent from '@/components/AppContent.vue';
|
||||
import AppHeader from '@/components/AppHeader.vue';
|
||||
import AppShell from '@/components/AppShell.vue';
|
||||
import type { BreadcrumbItem } from '@/types';
|
||||
|
||||
type Props = {
|
||||
breadcrumbs?: BreadcrumbItem[];
|
||||
};
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
breadcrumbs: () => [],
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell class="flex-col">
|
||||
<AppHeader :breadcrumbs="breadcrumbs" />
|
||||
<AppContent>
|
||||
<slot />
|
||||
</AppContent>
|
||||
</AppShell>
|
||||
</template>
|
||||
25
resources/js/layouts/app/AppSidebarLayout.vue
Normal file
25
resources/js/layouts/app/AppSidebarLayout.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import AppContent from '@/components/AppContent.vue';
|
||||
import AppShell from '@/components/AppShell.vue';
|
||||
import AppSidebar from '@/components/AppSidebar.vue';
|
||||
import AppSidebarHeader from '@/components/AppSidebarHeader.vue';
|
||||
import type { BreadcrumbItem } from '@/types';
|
||||
|
||||
type Props = {
|
||||
breadcrumbs?: BreadcrumbItem[];
|
||||
};
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
breadcrumbs: () => [],
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell variant="sidebar">
|
||||
<AppSidebar />
|
||||
<AppContent variant="sidebar" class="overflow-x-hidden">
|
||||
<AppSidebarHeader :breadcrumbs="breadcrumbs" />
|
||||
<slot />
|
||||
</AppContent>
|
||||
</AppShell>
|
||||
</template>
|
||||
50
resources/js/layouts/auth/AuthCardLayout.vue
Normal file
50
resources/js/layouts/auth/AuthCardLayout.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import AppLogoIcon from '@/components/AppLogoIcon.vue';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { home } from '@/routes';
|
||||
|
||||
defineProps<{
|
||||
title?: string;
|
||||
description?: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex min-h-svh flex-col items-center justify-center gap-6 bg-muted p-6 md:p-10"
|
||||
>
|
||||
<div class="flex w-full max-w-md flex-col gap-6">
|
||||
<Link
|
||||
:href="home()"
|
||||
class="flex items-center gap-2 self-center font-medium"
|
||||
>
|
||||
<div class="flex h-9 w-9 items-center justify-center">
|
||||
<AppLogoIcon
|
||||
class="size-9 fill-current text-black dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<Card class="rounded-xl">
|
||||
<CardHeader class="px-10 pt-8 pb-0 text-center">
|
||||
<CardTitle class="text-xl">{{ title }}</CardTitle>
|
||||
<CardDescription>
|
||||
{{ description }}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="px-10 py-8">
|
||||
<slot />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
40
resources/js/layouts/auth/AuthSimpleLayout.vue
Normal file
40
resources/js/layouts/auth/AuthSimpleLayout.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import AppLogoIcon from '@/components/AppLogoIcon.vue';
|
||||
|
||||
defineProps<{
|
||||
title?: string;
|
||||
description?: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex min-h-svh flex-col items-center justify-center gap-6 bg-[#020202] p-6 md:p-10 text-white"
|
||||
>
|
||||
<div class="w-full max-w-sm">
|
||||
<div class="flex flex-col gap-8">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<Link
|
||||
href="/"
|
||||
class="flex flex-col items-center gap-2 font-medium"
|
||||
>
|
||||
<div
|
||||
class="mb-1 flex h-9 w-9 items-center justify-center rounded-md"
|
||||
>
|
||||
<AppLogoIcon class="size-9" />
|
||||
</div>
|
||||
<span class="sr-only">BetiX</span>
|
||||
</Link>
|
||||
<div class="space-y-2 text-center">
|
||||
<h1 class="text-xl font-medium">{{ title }}</h1>
|
||||
<p class="text-center text-sm text-[#888888]">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
47
resources/js/layouts/auth/AuthSplitLayout.vue
Normal file
47
resources/js/layouts/auth/AuthSplitLayout.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import { Link, usePage } from '@inertiajs/vue3';
|
||||
import AppLogoIcon from '@/components/AppLogoIcon.vue';
|
||||
import { home } from '@/routes';
|
||||
|
||||
const page = usePage();
|
||||
const name = page.props.name;
|
||||
|
||||
defineProps<{
|
||||
title?: string;
|
||||
description?: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative grid h-dvh flex-col items-center justify-center px-8 sm:px-0 lg:max-w-none lg:grid-cols-2 lg:px-0"
|
||||
>
|
||||
<div
|
||||
class="relative hidden h-full flex-col bg-muted p-10 text-white lg:flex dark:border-r"
|
||||
>
|
||||
<div class="absolute inset-0 bg-zinc-900" />
|
||||
<Link
|
||||
:href="home()"
|
||||
class="relative z-20 flex items-center text-lg font-medium"
|
||||
>
|
||||
<AppLogoIcon class="mr-2 size-8 fill-current text-white" />
|
||||
{{ name }}
|
||||
</Link>
|
||||
</div>
|
||||
<div class="lg:p-8">
|
||||
<div
|
||||
class="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]"
|
||||
>
|
||||
<div class="flex flex-col space-y-2 text-center">
|
||||
<h1 class="text-xl font-medium tracking-tight" v-if="title">
|
||||
{{ title }}
|
||||
</h1>
|
||||
<p class="text-sm text-muted-foreground" v-if="description">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
76
resources/js/layouts/settings/Layout.vue
Normal file
76
resources/js/layouts/settings/Layout.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { useCurrentUrl } from '@/composables/useCurrentUrl';
|
||||
import { toUrl } from '@/lib/utils';
|
||||
import { type NavItem } from '@/types';
|
||||
import { edit as editAppearance } from '@/routes/appearance';
|
||||
import { edit as editProfile } from '@/routes/profile';
|
||||
import { show } from '@/routes/two-factor';
|
||||
import { edit as editPassword } from '@/routes/user-password';
|
||||
|
||||
const sidebarNavItems: NavItem[] = [
|
||||
{
|
||||
title: 'Profile',
|
||||
href: editProfile(),
|
||||
},
|
||||
{
|
||||
title: 'Password',
|
||||
href: editPassword(),
|
||||
},
|
||||
{
|
||||
title: 'Two-Factor Auth',
|
||||
href: show(),
|
||||
},
|
||||
{
|
||||
title: 'Appearance',
|
||||
href: editAppearance(),
|
||||
},
|
||||
];
|
||||
|
||||
const { isCurrentUrl } = useCurrentUrl();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="px-4 py-6">
|
||||
<Heading
|
||||
title="Settings"
|
||||
description="Manage your profile and account settings"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col lg:flex-row lg:space-x-12">
|
||||
<aside class="w-full max-w-xl lg:w-48">
|
||||
<nav
|
||||
class="flex flex-col space-y-1 space-x-0"
|
||||
aria-label="Settings"
|
||||
>
|
||||
<Button
|
||||
v-for="item in sidebarNavItems"
|
||||
:key="toUrl(item.href)"
|
||||
variant="ghost"
|
||||
:class="[
|
||||
'w-full justify-start',
|
||||
{ 'bg-muted': isCurrentUrl(item.href) },
|
||||
]"
|
||||
as-child
|
||||
>
|
||||
<Link :href="item.href">
|
||||
<component :is="item.icon" class="h-4 w-4" />
|
||||
{{ item.title }}
|
||||
</Link>
|
||||
</Button>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<Separator class="my-6 lg:hidden" />
|
||||
|
||||
<div class="flex-1 md:max-w-2xl">
|
||||
<section class="max-w-xl space-y-12">
|
||||
<slot />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
1507
resources/js/layouts/user/userlayout.vue
Normal file
1507
resources/js/layouts/user/userlayout.vue
Normal file
File diff suppressed because it is too large
Load Diff
11
resources/js/lib/utils.ts
Normal file
11
resources/js/lib/utils.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { InertiaLinkProps } from '@inertiajs/vue3';
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function toUrl(href: NonNullable<InertiaLinkProps['href']>) {
|
||||
return typeof href === 'string' ? href : href?.url;
|
||||
}
|
||||
502
resources/js/pages/Admin/CasinoDashboard.vue
Normal file
502
resources/js/pages/Admin/CasinoDashboard.vue
Normal file
@@ -0,0 +1,502 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, Link } from '@inertiajs/vue3';
|
||||
import { Chart, registerables } from 'chart.js';
|
||||
import { onMounted, nextTick } from 'vue';
|
||||
import CasinoAdminLayout from '@/layouts/admin/CasinoAdminLayout.vue';
|
||||
|
||||
Chart.register(...registerables);
|
||||
|
||||
const props = defineProps<{
|
||||
stats: {
|
||||
total_users: number;
|
||||
total_wagered: number;
|
||||
total_payout: number;
|
||||
house_edge: number;
|
||||
active_bans: number;
|
||||
new_users_24h: number;
|
||||
};
|
||||
chartData: any[];
|
||||
recentBets: any[];
|
||||
recentUsers: any[];
|
||||
}>();
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
if ((window as any).lucide) (window as any).lucide.createIcons();
|
||||
|
||||
const ctx = document.getElementById('profitChart') as HTMLCanvasElement;
|
||||
if (ctx) {
|
||||
new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: props.chartData.map(d => d.label),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Profit (USD)',
|
||||
data: props.chartData.map(d => d.profit),
|
||||
borderColor: '#ff007a',
|
||||
backgroundColor: 'rgba(255, 0, 122, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
borderWidth: 3,
|
||||
pointRadius: 4,
|
||||
pointBackgroundColor: '#ff007a'
|
||||
},
|
||||
{
|
||||
label: 'Wagered',
|
||||
data: props.chartData.map(d => d.wagered),
|
||||
borderColor: '#3b82f6',
|
||||
borderDash: [5, 5],
|
||||
fill: false,
|
||||
tension: 0.4,
|
||||
borderWidth: 2,
|
||||
pointRadius: 0
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
labels: { color: '#a1a1aa', font: { size: 12, weight: 'bold' } }
|
||||
},
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
backgroundColor: '#111113',
|
||||
titleColor: '#fff',
|
||||
bodyColor: '#a1a1aa',
|
||||
borderColor: '#1f1f22',
|
||||
borderWidth: 1
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
grid: { color: '#1f1f22' },
|
||||
ticks: { color: '#a1a1aa' }
|
||||
},
|
||||
x: {
|
||||
grid: { display: false },
|
||||
ticks: { color: '#a1a1aa' }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Helper to format large numbers
|
||||
const formatNumber = (num: number) => {
|
||||
return new Intl.NumberFormat('en-US').format(num);
|
||||
};
|
||||
|
||||
// Helper to format currency
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount || 0);
|
||||
};
|
||||
|
||||
// Calculate trend direction (Mock logic - replace with actual DB calculation if needed)
|
||||
const trends = {
|
||||
users: { value: '+5.2%', isPositive: true },
|
||||
wagered: { value: '+12.4%', isPositive: true },
|
||||
bans: { value: '-2', isPositive: true }, // Less bans is positive
|
||||
newUsers: { value: '+15', isPositive: true },
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CasinoAdminLayout>
|
||||
<Head title="Casino Dashboard" />
|
||||
<template #title>
|
||||
Overview
|
||||
</template>
|
||||
|
||||
<!-- Stats Grid -->
|
||||
<div class="stats-grid">
|
||||
<!-- Total Users -->
|
||||
<div class="stat-card" style="animation: slideUp 0.3s ease-out;">
|
||||
<div class="stat-header">
|
||||
<div class="stat-label">Total Users</div>
|
||||
<div class="stat-icon bg-blue-500/10 text-blue-400">
|
||||
<i data-lucide="users"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-body">
|
||||
<div class="stat-value">{{ formatNumber(stats.total_users) }}</div>
|
||||
<div class="stat-trend" :class="trends.users.isPositive ? 'text-green-400' : 'text-red-400'">
|
||||
<i :data-lucide="trends.users.isPositive ? 'trending-up' : 'trending-down'" class="w-4 h-4 inline mr-1"></i>
|
||||
{{ trends.users.value }} from last month
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Total Wagered -->
|
||||
<div class="stat-card" style="animation: slideUp 0.4s ease-out;">
|
||||
<div class="stat-header">
|
||||
<div class="stat-label">Total Wagered</div>
|
||||
<div class="stat-icon bg-green-500/10 text-green-400">
|
||||
<i data-lucide="dollar-sign"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-body">
|
||||
<div class="stat-value text-green-400">{{ formatCurrency(stats.total_wagered) }}</div>
|
||||
<div class="stat-trend text-green-400">
|
||||
<i data-lucide="trending-up" class="w-4 h-4 inline mr-1"></i>
|
||||
{{ trends.wagered.value }} from last month
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- House Edge / GGR -->
|
||||
<div class="stat-card" style="animation: slideUp 0.5s ease-out;">
|
||||
<div class="stat-header">
|
||||
<div class="stat-label">House GGR</div>
|
||||
<div class="stat-icon bg-purple-500/10 text-purple-400">
|
||||
<i data-lucide="trending-up"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-body">
|
||||
<div class="stat-value text-purple-400">{{ formatCurrency(stats.house_edge) }}</div>
|
||||
<div class="stat-trend text-gray-400">
|
||||
Total profit (Wager - Payout)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New Users 24h -->
|
||||
<div class="stat-card" style="animation: slideUp 0.6s ease-out;">
|
||||
<div class="stat-header">
|
||||
<div class="stat-label">New Users (24h)</div>
|
||||
<div class="stat-icon bg-blue-500/10 text-blue-400">
|
||||
<i data-lucide="user-plus"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-body">
|
||||
<div class="stat-value">{{ formatNumber(stats.new_users_24h) }}</div>
|
||||
<div class="stat-trend text-green-400">
|
||||
<i data-lucide="arrow-up-right" class="w-4 h-4 inline mr-1"></i>
|
||||
{{ trends.newUsers.value }} vs yesterday
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Profit Chart Panel -->
|
||||
<div class="panel chart-panel mb-6" style="animation: fadeIn 0.8s ease-out;">
|
||||
<div class="panel-header">
|
||||
<h3>Performance Overview (7 Days)</h3>
|
||||
<div class="flex gap-4 items-center text-xs text-gray-400">
|
||||
<div class="flex items-center gap-1"><span class="w-3 h-3 rounded-full bg-[#ff007a]"></span> Profit</div>
|
||||
<div class="flex items-center gap-1"><span class="w-3 h-3 rounded-full bg-blue-500"></span> Wagered</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-container" style="height: 300px; padding: 20px;">
|
||||
<canvas id="profitChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Activity Sections -->
|
||||
<div class="activity-grid">
|
||||
<!-- Recent Bets Table -->
|
||||
<div class="panel list-panel">
|
||||
<div class="panel-header">
|
||||
<h3>Recent High-Rollers</h3>
|
||||
<button class="btn-ghost">View All</button>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="activity-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Player</th>
|
||||
<th>Game</th>
|
||||
<th class="text-right">Wager</th>
|
||||
<th class="text-right">Payout</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="bet in recentBets" :key="bet.id" class="hover-row">
|
||||
<td class="font-bold flex items-center gap-2">
|
||||
<div class="avatar-small">{{ bet.user?.username?.charAt(0) || '?' }}</div>
|
||||
{{ bet.user?.username || 'Unknown' }}
|
||||
</td>
|
||||
<td class="text-gray-400">{{ bet.game_name }}</td>
|
||||
<td class="text-right font-mono">{{ formatCurrency(bet.wager_amount) }}</td>
|
||||
<td class="text-right font-mono font-bold" :class="bet.payout_amount > bet.wager_amount ? 'text-green-400' : 'text-gray-500'">
|
||||
{{ bet.payout_amount > 0 ? '+' : '' }}{{ formatCurrency(bet.payout_amount) }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!recentBets || recentBets.length === 0">
|
||||
<td colspan="4" class="text-center py-8 text-gray-500">No bets recorded yet.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Registrations -->
|
||||
<div class="panel list-panel">
|
||||
<div class="panel-header">
|
||||
<h3>Newest Members</h3>
|
||||
<Link href="/admin/users" class="btn-ghost">Manage Users</Link>
|
||||
</div>
|
||||
<div class="user-list">
|
||||
<div v-for="u in recentUsers" :key="u.id" class="user-item">
|
||||
<div class="avatar">{{ u.username.charAt(0).toUpperCase() }}</div>
|
||||
<div class="u-details">
|
||||
<div class="u-name">{{ u.username }}</div>
|
||||
<div class="u-email">{{ u.email }}</div>
|
||||
</div>
|
||||
<div class="u-time text-xs text-gray-500">
|
||||
{{ new Date(u.created_at).toLocaleDateString() }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!recentUsers || recentUsers.length === 0" class="text-center py-8 text-gray-500">
|
||||
No recent users.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CasinoAdminLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@keyframes slideUp {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
/* Stats Grid */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: #111113;
|
||||
border: 1px solid #1f1f22;
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 30px -10px rgba(0,0,0,0.3);
|
||||
border-color: #27272a;
|
||||
}
|
||||
|
||||
.stat-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #a1a1aa;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.stat-icon i {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
margin-bottom: 4px;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.stat-trend {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Activity Grid */
|
||||
.activity-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.activity-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: #111113;
|
||||
border: 1px solid #1f1f22;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid #1f1f22;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.panel-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #3b82f6;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
.btn-ghost:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Table */
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.activity-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.activity-table th {
|
||||
padding: 12px 24px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #a1a1aa;
|
||||
text-transform: uppercase;
|
||||
background: #0c0c0e;
|
||||
}
|
||||
|
||||
.activity-table td {
|
||||
padding: 16px 24px;
|
||||
font-size: 14px;
|
||||
border-bottom: 1px solid #1f1f22;
|
||||
color: #e4e4e7;
|
||||
}
|
||||
|
||||
.hover-row:hover td {
|
||||
background: #161618;
|
||||
}
|
||||
|
||||
.activity-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.text-right { text-align: right; }
|
||||
.text-center { text-align: center; }
|
||||
|
||||
/* User List */
|
||||
.user-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.user-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid #1f1f22;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.user-item:hover {
|
||||
background: #161618;
|
||||
}
|
||||
|
||||
.user-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: #27272a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.avatar-small {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: #27272a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.u-details {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.u-name {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.u-email {
|
||||
font-size: 12px;
|
||||
color: #a1a1aa;
|
||||
}
|
||||
|
||||
/* Utilities */
|
||||
.text-green-400 { color: #4ade80; }
|
||||
.text-red-400 { color: #f87171; }
|
||||
.text-blue-400 { color: #60a5fa; }
|
||||
.text-purple-400 { color: #c084fc; }
|
||||
.text-gray-400 { color: #9ca3af; }
|
||||
.text-gray-500 { color: #6b7280; }
|
||||
|
||||
.bg-blue-500\/10 { background-color: rgba(59, 130, 246, 0.1); }
|
||||
.bg-green-500\/10 { background-color: rgba(34, 197, 94, 0.1); }
|
||||
.bg-red-500\/10 { background-color: rgba(239, 68, 68, 0.1); }
|
||||
.bg-purple-500\/10 { background-color: rgba(168, 85, 247, 0.1); }
|
||||
|
||||
.font-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
|
||||
</style>
|
||||
110
resources/js/pages/Admin/Chat.vue
Normal file
110
resources/js/pages/Admin/Chat.vue
Normal file
@@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, router } from '@inertiajs/vue3';
|
||||
import { onMounted, ref, nextTick } from 'vue';
|
||||
import CasinoAdminLayout from '@/layouts/admin/CasinoAdminLayout.vue';
|
||||
|
||||
const props = defineProps<{ aiEnabled?: boolean }>();
|
||||
const ai = ref(!!props.aiEnabled);
|
||||
|
||||
const toggling = ref(false);
|
||||
async function toggleAi() {
|
||||
toggling.value = true;
|
||||
try {
|
||||
await router.post('/admin/chat/toggle-ai', { enabled: ai.value }, { preserveScroll: true });
|
||||
} finally {
|
||||
toggling.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const messages = ref<any[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
async function loadMessages() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await fetch('/api/chat?limit=50');
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
messages.value = json?.data || [];
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
nextTick(() => { if ((window as any).lucide) (window as any).lucide.createIcons(); });
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadMessages();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CasinoAdminLayout>
|
||||
<Head title="Admin – Live Chat" />
|
||||
<template #title>
|
||||
Live Chat Management
|
||||
</template>
|
||||
<template #actions>
|
||||
<label class="switch">
|
||||
<input type="checkbox" v-model="ai" @change="toggleAi" :disabled="toggling" />
|
||||
<span class="slider"></span>
|
||||
<span class="ml-2 text-sm" :class="ai ? 'text-green' : 'text-muted'">AI {{ ai ? 'Enabled' : 'Disabled' }}</span>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<h3>Recent Messages</h3>
|
||||
<button class="btn-ghost" @click="loadMessages" :disabled="loading">
|
||||
<i data-lucide="refresh-ccw"></i> Refresh
|
||||
</button>
|
||||
</div>
|
||||
<div class="chat-list">
|
||||
<div v-for="m in messages" :key="m.id" class="chat-item">
|
||||
<div class="avatar">{{ m.user?.username?.charAt(0)?.toUpperCase() || '?' }}</div>
|
||||
<div class="content">
|
||||
<div class="meta">
|
||||
<span class="name">{{ m.user?.username || 'Unknown' }}</span>
|
||||
<span class="time">{{ new Date(m.created_at).toLocaleString() }}</span>
|
||||
</div>
|
||||
<div class="text">{{ m.message }}</div>
|
||||
</div>
|
||||
<form :action="`/admin/chat/${m.id}`" method="post" class="actions" @submit.prevent="$el.submit()">
|
||||
<input type="hidden" name="_method" value="DELETE" />
|
||||
<button class="btn-danger" title="Delete message"><i data-lucide="trash-2"></i></button>
|
||||
</form>
|
||||
</div>
|
||||
<div v-if="!messages.length && !loading" class="text-center py-8 text-muted">No messages yet.</div>
|
||||
</div>
|
||||
</div>
|
||||
</CasinoAdminLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.panel { background: #111113; border: 1px solid #1f1f22; border-radius: 16px; }
|
||||
.panel-header { display: flex; justify-content: space-between; align-items: center; padding: 16px 20px; border-bottom: 1px solid #1f1f22; }
|
||||
.chat-list { display: flex; flex-direction: column; }
|
||||
.chat-item { display: grid; grid-template-columns: 40px 1fr auto; gap: 12px; padding: 14px 20px; border-bottom: 1px solid #1f1f22; }
|
||||
.avatar { width: 40px; height: 40px; border-radius: 50%; background: #27272a; color: #fff; display: flex; align-items: center; justify-content: center; font-weight: 800; }
|
||||
.meta { display: flex; gap: 8px; align-items: baseline; }
|
||||
.meta .name { font-weight: 700; color: #fff; }
|
||||
.meta .time { color: #a1a1aa; font-size: 12px; }
|
||||
.text { color: #e4e4e7; }
|
||||
.actions { display: flex; align-items: center; gap: 8px; }
|
||||
.btn-ghost { background: transparent; border: none; color: #a1a1aa; display: inline-flex; align-items: center; gap: 6px; cursor: pointer; }
|
||||
.btn-ghost:hover { color: #fff; }
|
||||
.btn-danger { background: transparent; border: 1px solid #7f1d1d; color: #ef4444; padding: 6px 10px; border-radius: 8px; cursor: pointer; }
|
||||
.btn-danger:hover { background: rgba(239, 68, 68, .1); }
|
||||
|
||||
/* Switch */
|
||||
.switch { display: inline-flex; align-items: center; }
|
||||
.switch input { display: none; }
|
||||
.switch .slider { width: 40px; height: 22px; background: #27272a; border-radius: 9999px; position: relative; transition: .2s; display: inline-block; }
|
||||
.switch .slider:after { content: ''; width: 18px; height: 18px; background: #fff; border-radius: 50%; position: absolute; top: 2px; left: 2px; transition: .2s; }
|
||||
.switch input:checked + .slider { background: #16a34a; }
|
||||
.switch input:checked + .slider:after { transform: translateX(18px); }
|
||||
|
||||
.text-muted { color: #a1a1aa; }
|
||||
.text-green { color: #16a34a; }
|
||||
.text-white { color: #fff; }
|
||||
</style>
|
||||
761
resources/js/pages/Admin/ChatReportShow.vue
Normal file
761
resources/js/pages/Admin/ChatReportShow.vue
Normal file
@@ -0,0 +1,761 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, router } from '@inertiajs/vue3';
|
||||
import { ref } from 'vue';
|
||||
import CasinoAdminLayout from '@/layouts/admin/CasinoAdminLayout.vue';
|
||||
import {
|
||||
ArrowLeft, Flag, Hash, Clock, CheckCircle2, XCircle,
|
||||
Ban, MessageSquareOff, AlertTriangle, History,
|
||||
Calendar, Mail, Shield, Gavel, Loader2, ChevronRight,
|
||||
UserRound, ShieldAlert, Star, Crown, ArrowRight
|
||||
} from 'lucide-vue-next';
|
||||
|
||||
interface Restriction {
|
||||
id: number;
|
||||
type: 'chat_ban' | 'account_ban';
|
||||
reason: string | null;
|
||||
active: boolean;
|
||||
starts_at: string | null;
|
||||
ends_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface UserProfile {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
avatar: string | null;
|
||||
avatar_url: string | null;
|
||||
role: string;
|
||||
vip_level: number;
|
||||
is_banned: boolean;
|
||||
created_at: string;
|
||||
restrictions?: Restriction[];
|
||||
}
|
||||
|
||||
interface ContextMsg {
|
||||
id: string | number;
|
||||
message: string;
|
||||
user: { id: number; username: string };
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface Report {
|
||||
id: number;
|
||||
reporter_id: number;
|
||||
message_id: string;
|
||||
message_text: string;
|
||||
sender_id: number | null;
|
||||
sender_username: string | null;
|
||||
reason: string | null;
|
||||
context_messages: ContextMsg[] | null;
|
||||
status: 'pending' | 'reviewed' | 'dismissed';
|
||||
admin_note: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
report: Report;
|
||||
senderUser: UserProfile | null;
|
||||
reporterUser: UserProfile | null;
|
||||
flash?: string | null;
|
||||
}>();
|
||||
|
||||
// ── Restriction management ────────────────────────────────────
|
||||
const extendHours = ref<Record<number, number>>({});
|
||||
const flashMsg = ref(props.flash || '');
|
||||
|
||||
function liftRestriction(id: number) {
|
||||
router.post(`/admin/restrictions/${id}/lift`, {}, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => { flashMsg.value = 'Sperre wurde aufgehoben.'; },
|
||||
});
|
||||
}
|
||||
function extendRestriction(id: number) {
|
||||
const h = extendHours.value[id];
|
||||
if (!h || h < 1) return;
|
||||
router.post(`/admin/restrictions/${id}/extend`, { hours: h }, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => { flashMsg.value = `Sperre um ${h}h verlängert.`; extendHours.value[id] = 0; },
|
||||
});
|
||||
}
|
||||
|
||||
// ── Punishment ────────────────────────────────────────────────
|
||||
const punishType = ref<'chat_ban' | 'account_ban'>('chat_ban');
|
||||
const punishReason = ref('');
|
||||
const punishHours = ref<number | null>(null);
|
||||
const submitting = ref(false);
|
||||
|
||||
const chatTemplates = [
|
||||
{ label: '1 Tag', hours: 24, reason: 'Spam' },
|
||||
{ label: '3 Tage', hours: 72, reason: 'Beleidigung' },
|
||||
{ label: '7 Tage', hours: 168, reason: 'Belästigung' },
|
||||
{ label: '30 Tage', hours: 720, reason: 'Schwerer Verstoß' },
|
||||
{ label: 'Permanent',hours: null, reason: 'Dauerhafter Ausschluss' },
|
||||
];
|
||||
const banTemplates = [
|
||||
{ label: '1 Tag', hours: 24, reason: 'Betrug / Scam' },
|
||||
{ label: '7 Tage', hours: 168, reason: 'Schwerer Verstoß' },
|
||||
{ label: '30 Tage', hours: 720, reason: 'Wiederholte Verstöße' },
|
||||
{ label: 'Permanent',hours: null, reason: 'Dauerhafter Ausschluss' },
|
||||
];
|
||||
|
||||
function applyTemplate(reason: string, hours: number | null) {
|
||||
punishReason.value = reason;
|
||||
punishHours.value = hours;
|
||||
}
|
||||
|
||||
function submitPunish() {
|
||||
if (!punishReason.value || submitting.value) return;
|
||||
submitting.value = true;
|
||||
router.post(`/admin/reports/chat/${props.report.id}/punish`, {
|
||||
type: punishType.value, reason: punishReason.value, hours: punishHours.value,
|
||||
}, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => { flashMsg.value = 'Strafe erfolgreich verhängt!'; },
|
||||
onFinish: () => { submitting.value = false; },
|
||||
});
|
||||
}
|
||||
|
||||
function updateStatus(status: string) {
|
||||
router.post(`/admin/reports/chat/${props.report.id}`, { status }, { preserveScroll: true });
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────
|
||||
const statusMeta: Record<string, { color: string; bg: string; border: string; label: string }> = {
|
||||
pending: { color: '#f59e0b', bg: 'rgba(245,158,11,0.12)', border: 'rgba(245,158,11,0.35)', label: 'Ausstehend' },
|
||||
reviewed: { color: '#22c55e', bg: 'rgba(34,197,94,0.12)', border: 'rgba(34,197,94,0.35)', label: 'Bearbeitet' },
|
||||
dismissed: { color: '#6b7280', bg: 'rgba(107,114,128,0.1)', border: 'rgba(107,114,128,0.3)', label: 'Abgelehnt' },
|
||||
};
|
||||
const reasonLabels: Record<string, string> = {
|
||||
spam:'Spam', beleidigung:'Beleidigung', belaestigung:'Belästigung', betrug:'Betrug', sonstiges:'Sonstiges',
|
||||
};
|
||||
function rl(r: string | null) { return r ? (reasonLabels[r] ?? r) : null; }
|
||||
|
||||
function avUrl(u: UserProfile | null) { return u?.avatar_url || u?.avatar || null; }
|
||||
function ini(name?: string | null) { return (name || '?')[0].toUpperCase(); }
|
||||
|
||||
function fmt(d: string | null) {
|
||||
if (!d) return '–';
|
||||
return new Date(d).toLocaleString('de-DE', { day:'2-digit', month:'2-digit', year:'2-digit', hour:'2-digit', minute:'2-digit' });
|
||||
}
|
||||
function fmtDur(h: number | null) {
|
||||
if (h === null) return 'Permanent';
|
||||
if (h < 24) return `${h}h`;
|
||||
return `${h/24}d`;
|
||||
}
|
||||
function timeLeft(d: string | null) {
|
||||
if (!d) return 'Permanent';
|
||||
const ms = new Date(d).getTime() - Date.now();
|
||||
if (ms <= 0) return 'Abgelaufen';
|
||||
const days = Math.floor(ms / 86400000);
|
||||
const hrs = Math.floor((ms % 86400000) / 3600000);
|
||||
return days > 0 ? `${days}T ${hrs}h` : `${hrs}h`;
|
||||
}
|
||||
|
||||
function activeR(u: UserProfile | null) {
|
||||
return (u?.restrictions ?? []).filter(r => r.active && (!r.ends_at || new Date(r.ends_at) > new Date()));
|
||||
}
|
||||
function allR(u: UserProfile | null) { return u?.restrictions ?? []; }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CasinoAdminLayout>
|
||||
<Head :title="`Case #${report.id}`" />
|
||||
<template #title>
|
||||
<div class="pt">
|
||||
<a href="/admin/reports/chat" class="back"><ArrowLeft :size="14" /> Chat Reports</a>
|
||||
<span class="ptdiv">/</span>
|
||||
<span class="pt-case"><Hash :size="12" />{{ report.id }}</span>
|
||||
<span class="status-chip" :style="{ color: statusMeta[report.status].color, background: statusMeta[report.status].bg, borderColor: statusMeta[report.status].border }">
|
||||
{{ statusMeta[report.status].label }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Flash banner -->
|
||||
<transition name="fade">
|
||||
<div v-if="flashMsg" class="flash">
|
||||
<CheckCircle2 :size="15" />
|
||||
<span>{{ flashMsg }}</span>
|
||||
<button @click="flashMsg = ''"><XCircle :size="14" /></button>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- Case summary bar -->
|
||||
<div class="summary-bar">
|
||||
<div class="sb-item">
|
||||
<span class="sb-label">Melder</span>
|
||||
<span class="sb-val blue">@{{ reporterUser?.username || '–' }}</span>
|
||||
</div>
|
||||
<ArrowRight :size="14" class="sb-arrow" />
|
||||
<div class="sb-item">
|
||||
<span class="sb-label">Gemeldet</span>
|
||||
<span class="sb-val pink">@{{ senderUser?.username || report.sender_username || '–' }}</span>
|
||||
</div>
|
||||
<div class="sb-sep" />
|
||||
<div class="sb-item" v-if="rl(report.reason)">
|
||||
<span class="sb-label">Grund</span>
|
||||
<span class="sb-val"><Flag :size="11" /> {{ rl(report.reason) }}</span>
|
||||
</div>
|
||||
<div class="sb-item">
|
||||
<span class="sb-label">Datum</span>
|
||||
<span class="sb-val">{{ fmt(report.created_at) }}</span>
|
||||
</div>
|
||||
<div class="sb-item">
|
||||
<span class="sb-label">Msg-ID</span>
|
||||
<span class="sb-val mono">#{{ report.message_id }}</span>
|
||||
</div>
|
||||
<!-- Quick status -->
|
||||
<div class="sb-status-btns">
|
||||
<button :class="['ssb', { active: report.status === 'reviewed' }]" @click="updateStatus('reviewed')">
|
||||
<CheckCircle2 :size="12" /> Erledigt
|
||||
</button>
|
||||
<button :class="['ssb dismiss', { active: report.status === 'dismissed' }]" @click="updateStatus('dismissed')">
|
||||
<XCircle :size="12" /> Ablehnen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main grid -->
|
||||
<div class="main-grid">
|
||||
|
||||
<!-- ══ LEFT col ══════════════════════════════════════ -->
|
||||
<div class="col-left">
|
||||
|
||||
<!-- Reporter -->
|
||||
<div class="user-card blue-top">
|
||||
<div class="uc-label blue"><UserRound :size="10" /> Melder</div>
|
||||
<div class="uc-row">
|
||||
<div class="uc-av blue">
|
||||
<img v-if="avUrl(reporterUser)" :src="avUrl(reporterUser)!" />
|
||||
<span v-else>{{ ini(reporterUser?.username) }}</span>
|
||||
</div>
|
||||
<div class="uc-info">
|
||||
<div class="uc-name">@{{ reporterUser?.username || '–' }}</div>
|
||||
<div class="uc-email"><Mail :size="9" /> {{ reporterUser?.email || '–' }}</div>
|
||||
<div class="uc-since"><Calendar :size="9" /> seit {{ reporterUser?.created_at ? new Date(reporterUser.created_at).toLocaleDateString('de-DE') : '–' }}</div>
|
||||
</div>
|
||||
<a v-if="reporterUser" :href="`/admin/users/${reporterUser.id}`" class="uc-open" title="Profil öffnen"><ChevronRight :size="14" /></a>
|
||||
</div>
|
||||
<div class="uc-badges">
|
||||
<span class="badge role"><Crown :size="8" /> {{ reporterUser?.role || 'user' }}</span>
|
||||
<span v-if="reporterUser?.vip_level && reporterUser.vip_level > 0" class="badge vip"><Star :size="8" /> VIP {{ reporterUser.vip_level }}</span>
|
||||
<span v-if="reporterUser?.is_banned" class="badge banned"><Ban :size="8" /> Gebannt</span>
|
||||
<span v-if="activeR(reporterUser).some(r => r.type==='chat_ban')" class="badge cbanned"><MessageSquareOff :size="8" /> Chat-Bann</span>
|
||||
</div>
|
||||
<!-- Mini restriction history -->
|
||||
<div v-if="allR(reporterUser).length" class="mini-hist">
|
||||
<div class="mh-title"><History :size="9" /> Historie <span class="mh-count">{{ allR(reporterUser).length }}</span></div>
|
||||
<div v-for="r in allR(reporterUser).slice(0,3)" :key="r.id" class="mh-row" :class="{ active: r.active }">
|
||||
<span class="mh-type" :class="r.type==='chat_ban'?'chat':'ban'">{{ r.type==='chat_ban'?'Chat':'Acc' }}</span>
|
||||
<span class="mh-reason">{{ r.reason || '–' }}</span>
|
||||
<span v-if="r.active" class="mh-live">●</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="reported-divider"><Flag :size="11" class="df" /> hat gemeldet</div>
|
||||
|
||||
<!-- Reported / Sender -->
|
||||
<div class="user-card pink-top">
|
||||
<div class="uc-label pink"><ShieldAlert :size="10" /> Gemeldet</div>
|
||||
<div class="uc-row">
|
||||
<div class="uc-av pink">
|
||||
<img v-if="avUrl(senderUser)" :src="avUrl(senderUser)!" />
|
||||
<span v-else>{{ ini(senderUser?.username ?? report.sender_username) }}</span>
|
||||
</div>
|
||||
<div class="uc-info">
|
||||
<div class="uc-name">@{{ senderUser?.username || report.sender_username || '–' }}</div>
|
||||
<div class="uc-email"><Mail :size="9" /> {{ senderUser?.email || `ID: ${report.sender_id || '–'}` }}</div>
|
||||
<div v-if="senderUser" class="uc-since"><Calendar :size="9" /> seit {{ new Date(senderUser.created_at).toLocaleDateString('de-DE') }}</div>
|
||||
</div>
|
||||
<a v-if="senderUser" :href="`/admin/users/${senderUser.id}`" class="uc-open" title="Profil öffnen"><ChevronRight :size="14" /></a>
|
||||
</div>
|
||||
<div class="uc-badges">
|
||||
<span v-if="senderUser" class="badge role"><Crown :size="8" /> {{ senderUser.role }}</span>
|
||||
<span v-if="senderUser?.vip_level && senderUser.vip_level > 0" class="badge vip"><Star :size="8" /> VIP {{ senderUser.vip_level }}</span>
|
||||
<span v-if="senderUser?.is_banned" class="badge banned"><Ban :size="8" /> Gebannt</span>
|
||||
<span v-if="activeR(senderUser).some(r => r.type==='chat_ban')" class="badge cbanned"><MessageSquareOff :size="8" /> Chat-Bann</span>
|
||||
</div>
|
||||
|
||||
<!-- Active restrictions alert -->
|
||||
<div v-if="activeR(senderUser).length" class="active-alert">
|
||||
<AlertTriangle :size="13" />
|
||||
<div>
|
||||
<div class="aa-title">{{ activeR(senderUser).length }} aktive Sperre(n)</div>
|
||||
<div class="aa-list">
|
||||
<div v-for="r in activeR(senderUser)" :key="r.id" class="aa-row">
|
||||
<span class="mh-type" :class="r.type==='chat_ban'?'chat':'ban'">{{ r.type==='chat_ban'?'Chat-Bann':'Acc-Bann' }}</span>
|
||||
<span class="aa-until">{{ r.ends_at ? timeLeft(r.ends_at) : 'Permanent' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Full restriction history with lift/extend -->
|
||||
<div v-if="allR(senderUser).length" class="restrict-hist">
|
||||
<div class="mh-title"><History :size="9" /> Sperr-Historie <span class="mh-count">{{ allR(senderUser).length }}</span></div>
|
||||
<div v-for="r in allR(senderUser)" :key="r.id" class="rh-item" :class="{ active: r.active }">
|
||||
<div class="rh-row">
|
||||
<span class="mh-type" :class="r.type==='chat_ban'?'chat':'ban'">{{ r.type==='chat_ban'?'Chat':'Acc' }}</span>
|
||||
<span class="rh-reason">{{ r.reason || '–' }}</span>
|
||||
<span v-if="r.active" class="mh-live">AKTIV</span>
|
||||
<span class="rh-date">{{ fmt(r.created_at) }}</span>
|
||||
</div>
|
||||
<div v-if="r.ends_at || r.active" class="rh-until">
|
||||
<Clock :size="9" />
|
||||
{{ r.ends_at ? fmt(r.ends_at) : 'Permanent' }}
|
||||
<span v-if="r.active && r.ends_at" class="rh-left">{{ timeLeft(r.ends_at) }}</span>
|
||||
</div>
|
||||
<div v-if="r.active" class="rh-actions">
|
||||
<button class="rha lift" @click="liftRestriction(r.id)">
|
||||
<CheckCircle2 :size="10" /> Aufheben
|
||||
</button>
|
||||
<div class="rha-extend">
|
||||
<input v-model.number="extendHours[r.id]" type="number" min="1" placeholder="Std." class="rha-input" />
|
||||
<button class="rha extend" @click="extendRestriction(r.id)">
|
||||
<Clock :size="10" /> Verlängern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="no-hist">Keine Sperr-Historie</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══ CENTER: Chat timeline ══════════════════════════ -->
|
||||
<div class="col-center">
|
||||
<div class="ct-head">
|
||||
<MessageSquareOff :size="15" class="ct-icon" />
|
||||
<span>Chat-Kontext</span>
|
||||
<span class="ct-sub">{{ (report.context_messages?.length ?? 0) + 1 }} Nachrichten</span>
|
||||
</div>
|
||||
|
||||
<div class="timeline">
|
||||
<div v-if="!report.context_messages?.length && !report.message_text" class="tl-empty">
|
||||
Kein Kontext gespeichert.
|
||||
</div>
|
||||
|
||||
<!-- Context messages -->
|
||||
<div
|
||||
v-for="cm in report.context_messages"
|
||||
:key="cm.id"
|
||||
class="tl-msg"
|
||||
:class="{
|
||||
'by-sender': cm.user?.id === report.sender_id,
|
||||
'by-reporter': cm.user?.id === report.reporter_id,
|
||||
}"
|
||||
>
|
||||
<div class="tl-av" :class="{ 'av-s': cm.user?.id === report.sender_id, 'av-r': cm.user?.id === report.reporter_id }">
|
||||
{{ ini(cm.user?.username) }}
|
||||
</div>
|
||||
<div class="tl-body">
|
||||
<div class="tl-meta">
|
||||
<span class="tl-user">@{{ cm.user?.username || '?' }}</span>
|
||||
<span class="tl-ts">{{ fmt(cm.created_at) }}</span>
|
||||
</div>
|
||||
<div class="tl-text">{{ cm.message }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ★ Reported message ★ -->
|
||||
<div class="tl-msg reported">
|
||||
<div class="rep-flag"><Flag :size="10" /></div>
|
||||
<div class="tl-av av-s av-rep">{{ ini(report.sender_username) }}</div>
|
||||
<div class="tl-body">
|
||||
<div class="tl-meta">
|
||||
<span class="tl-user rep-user">@{{ report.sender_username || '?' }}</span>
|
||||
<span class="rep-badge"><Flag :size="9" /> Gemeldet</span>
|
||||
<span v-if="rl(report.reason)" class="reason-badge">{{ rl(report.reason) }}</span>
|
||||
<span class="tl-ts">{{ fmt(report.created_at) }}</span>
|
||||
</div>
|
||||
<div class="tl-text rep-text">{{ report.message_text }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══ RIGHT: Punishment ══════════════════════════════ -->
|
||||
<div class="col-right">
|
||||
<div class="pun-head">
|
||||
<Gavel :size="15" class="pun-icon" />
|
||||
<span>Strafe verhängen</span>
|
||||
<span class="ct-sub" v-if="senderUser">@{{ senderUser.username }}</span>
|
||||
<span class="ct-sub warn" v-else>Kein Nutzer verknüpft</span>
|
||||
</div>
|
||||
|
||||
<div class="pun-form" :class="{ locked: !senderUser }">
|
||||
<!-- Type toggle -->
|
||||
<div class="type-row">
|
||||
<button :class="['type-btn', { on: punishType==='chat_ban' }]" @click="punishType='chat_ban'">
|
||||
<MessageSquareOff :size="12" /> Chat-Bann
|
||||
</button>
|
||||
<button :class="['type-btn ban', { on: punishType==='account_ban' }]" @click="punishType='account_ban'">
|
||||
<Ban :size="12" /> Account-Bann
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Templates -->
|
||||
<div class="tpl-block">
|
||||
<div class="tpl-label">Schnell-Vorlagen</div>
|
||||
<div class="tpl-list">
|
||||
<button
|
||||
v-for="t in (punishType==='chat_ban' ? chatTemplates : banTemplates)"
|
||||
:key="t.label"
|
||||
class="tpl-btn"
|
||||
:class="{ on: punishReason===t.reason && punishHours===t.hours, ban: punishType==='account_ban' }"
|
||||
@click="applyTemplate(t.reason, t.hours)"
|
||||
>
|
||||
<span class="tpl-name">{{ t.label }}</span>
|
||||
<span class="tpl-dur">{{ fmtDur(t.hours) }}</span>
|
||||
<span class="tpl-reason">{{ t.reason }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="or-line"><span>oder anpassen</span></div>
|
||||
|
||||
<!-- Custom -->
|
||||
<div class="custom-block">
|
||||
<label class="fl">
|
||||
<span><Clock :size="10" /> Dauer (Stunden)</span>
|
||||
<input v-model.number="punishHours" type="number" min="1" placeholder="leer = permanent" class="fi" />
|
||||
</label>
|
||||
<label class="fl">
|
||||
<span><Shield :size="10" /> Grund</span>
|
||||
<textarea v-model="punishReason" rows="2" placeholder="Begründung..." class="fi ta"></textarea>
|
||||
</label>
|
||||
|
||||
<!-- Preview -->
|
||||
<div v-if="punishReason" class="pun-preview">
|
||||
<span class="pp-type" :class="punishType==='account_ban'?'ban':'chat'">
|
||||
{{ punishType==='chat_ban' ? 'Chat-Bann' : 'Account-Bann' }}
|
||||
</span>
|
||||
<span class="pp-dur">{{ fmtDur(punishHours) }}</span>
|
||||
<span v-if="punishHours" class="pp-until">
|
||||
bis {{ new Date(Date.now() + (punishHours??0)*3600000).toLocaleDateString('de-DE') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="pun-btn"
|
||||
:class="{ ban: punishType==='account_ban' }"
|
||||
:disabled="!punishReason || !senderUser || submitting"
|
||||
@click="submitPunish"
|
||||
>
|
||||
<Loader2 v-if="submitting" :size="13" class="spin" />
|
||||
<Ban v-else-if="punishType==='account_ban'" :size="13" />
|
||||
<MessageSquareOff v-else :size="13" />
|
||||
{{ punishType==='chat_ban' ? 'Chat-Bann verhängen' : 'Account sperren' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /main-grid -->
|
||||
</CasinoAdminLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ─ Page title ─ */
|
||||
.pt { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
|
||||
.back { display:flex; align-items:center; gap:4px; color:#555; font-size:12px; text-decoration:none; }
|
||||
.back:hover { color:#ccc; }
|
||||
.ptdiv { color:#2a2a2a; }
|
||||
.pt-case { display:flex; align-items:center; gap:3px; font-weight:800; color:#fff; font-size:14px; }
|
||||
.status-chip {
|
||||
font-size:10px; font-weight:800; padding:3px 10px; border-radius:20px;
|
||||
border:1px solid; text-transform:uppercase; letter-spacing:.5px;
|
||||
}
|
||||
|
||||
/* ─ Flash ─ */
|
||||
.flash {
|
||||
display:flex; align-items:center; gap:8px; margin-bottom:16px;
|
||||
background:rgba(34,197,94,0.1); border:1px solid rgba(34,197,94,0.3);
|
||||
color:#22c55e; padding:10px 14px; border-radius:10px; font-size:13px; font-weight:600;
|
||||
}
|
||||
.flash button { margin-left:auto; background:none; border:none; color:inherit; cursor:pointer; display:flex; }
|
||||
.fade-enter-active,.fade-leave-active { transition:opacity .3s; }
|
||||
.fade-enter-from,.fade-leave-to { opacity:0; }
|
||||
|
||||
/* ─ Summary bar ─ */
|
||||
.summary-bar {
|
||||
display:flex; align-items:center; gap:0; flex-wrap:wrap;
|
||||
background:#111113; border:1px solid #1e1e21; border-radius:12px;
|
||||
padding:12px 18px; margin-bottom:18px; gap:16px;
|
||||
}
|
||||
.sb-item { display:flex; flex-direction:column; gap:2px; }
|
||||
.sb-label { font-size:9px; font-weight:700; text-transform:uppercase; letter-spacing:.6px; color:#444; }
|
||||
.sb-val { font-size:12px; font-weight:700; color:#ccc; display:flex; align-items:center; gap:4px; }
|
||||
.sb-val.blue { color:#60a5fa; }
|
||||
.sb-val.pink { color:#f472b6; }
|
||||
.sb-val.mono { font-family:monospace; color:#666; font-size:11px; }
|
||||
.sb-arrow { color:#333; flex-shrink:0; }
|
||||
.sb-sep { width:1px; height:28px; background:#1e1e21; }
|
||||
.sb-status-btns { margin-left:auto; display:flex; gap:6px; }
|
||||
.ssb {
|
||||
display:flex; align-items:center; gap:5px; padding:6px 12px;
|
||||
border-radius:7px; border:1px solid #252528; background:#161618;
|
||||
color:#555; font-size:11px; font-weight:700; cursor:pointer; transition:.15s;
|
||||
}
|
||||
.ssb:hover { border-color:#3a3a3f; color:#aaa; }
|
||||
.ssb.active,.ssb:hover.active { border-color:rgba(34,197,94,0.4); background:rgba(34,197,94,0.08); color:#22c55e; }
|
||||
.ssb.dismiss.active { border-color:rgba(107,114,128,0.4); background:rgba(107,114,128,0.08); color:#6b7280; }
|
||||
|
||||
/* ─ Main grid ─ */
|
||||
.main-grid {
|
||||
display:grid;
|
||||
grid-template-columns: 270px 1fr 290px;
|
||||
gap:16px;
|
||||
align-items:start;
|
||||
}
|
||||
@media(max-width:1300px){ .main-grid { grid-template-columns: 250px 1fr 270px; } }
|
||||
@media(max-width:1000px){ .main-grid { grid-template-columns:1fr; } }
|
||||
|
||||
/* ─ User cards ─ */
|
||||
.col-left { display:flex; flex-direction:column; gap:8px; }
|
||||
.user-card {
|
||||
background:#111113; border:1px solid #1e1e21; border-radius:12px; padding:14px;
|
||||
display:flex; flex-direction:column; gap:9px; position:relative;
|
||||
}
|
||||
.blue-top { border-top:2px solid #3b82f6; }
|
||||
.pink-top { border-top:2px solid #df006a; }
|
||||
|
||||
.uc-label {
|
||||
font-size:9px; font-weight:800; text-transform:uppercase; letter-spacing:.6px;
|
||||
display:flex; align-items:center; gap:3px; padding:2px 8px; border-radius:20px;
|
||||
width:fit-content; border:1px solid;
|
||||
}
|
||||
.uc-label.blue { color:#3b82f6; background:rgba(59,130,246,.08); border-color:rgba(59,130,246,.2); }
|
||||
.uc-label.pink { color:#df006a; background:rgba(223,0,106,.08); border-color:rgba(223,0,106,.2); }
|
||||
|
||||
.uc-row { display:flex; align-items:center; gap:10px; }
|
||||
.uc-av {
|
||||
width:40px; height:40px; border-radius:10px; flex-shrink:0;
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
font-size:16px; font-weight:900; overflow:hidden;
|
||||
}
|
||||
.uc-av img { width:100%; height:100%; object-fit:cover; }
|
||||
.uc-av.blue { background:rgba(59,130,246,.15); color:#3b82f6; }
|
||||
.uc-av.pink { background:rgba(223,0,106,.15); color:#df006a; }
|
||||
.uc-info { flex:1; min-width:0; }
|
||||
.uc-name { font-size:13px; font-weight:800; color:#e0e0e0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.uc-email, .uc-since { display:flex; align-items:center; gap:4px; font-size:10px; color:#555; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.uc-open {
|
||||
color:#666; background:#161618; border:1px solid #252528; border-radius:7px;
|
||||
padding:5px; display:flex; cursor:pointer; text-decoration:none; transition:.15s; flex-shrink:0;
|
||||
}
|
||||
.uc-open:hover { color:#ccc; border-color:#3a3a3f; }
|
||||
|
||||
.uc-badges { display:flex; gap:5px; flex-wrap:wrap; }
|
||||
.badge {
|
||||
display:flex; align-items:center; gap:3px;
|
||||
font-size:9px; font-weight:800; padding:2px 6px; border-radius:5px; border:1px solid;
|
||||
text-transform:uppercase;
|
||||
}
|
||||
.badge.role { color:#555; border-color:#252528; background:#161618; }
|
||||
.badge.vip { color:#fcd34d; border-color:rgba(252,211,77,.25); background:rgba(252,211,77,.06); }
|
||||
.badge.banned { color:#ef4444; border-color:rgba(239,68,68,.3); background:rgba(239,68,68,.08); }
|
||||
.badge.cbanned { color:#f97316; border-color:rgba(249,115,22,.3); background:rgba(249,115,22,.08); }
|
||||
|
||||
/* Mini history (reporter) */
|
||||
.mini-hist { border-top:1px solid #1a1a1c; padding-top:8px; }
|
||||
.mh-title { display:flex; align-items:center; gap:4px; font-size:9px; font-weight:700; text-transform:uppercase; color:#444; letter-spacing:.5px; margin-bottom:5px; }
|
||||
.mh-count { margin-left:auto; background:#1a1a1c; border:1px solid #252528; border-radius:8px; padding:0 5px; color:#666; font-size:9px; }
|
||||
.mh-row { display:flex; align-items:center; gap:6px; padding:4px 7px; border-radius:5px; background:#0e0e10; border:1px solid #1a1a1c; margin-bottom:3px; font-size:10px; }
|
||||
.mh-row.active { border-color:rgba(239,68,68,.2); background:rgba(239,68,68,.04); }
|
||||
.mh-type { font-size:9px; font-weight:800; padding:1px 5px; border-radius:3px; }
|
||||
.mh-type.chat { color:#f97316; background:rgba(249,115,22,.1); }
|
||||
.mh-type.ban { color:#ef4444; background:rgba(239,68,68,.1); }
|
||||
.mh-reason { flex:1; color:#666; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.mh-live { color:#ef4444; font-size:12px; }
|
||||
|
||||
/* Active alert */
|
||||
.active-alert {
|
||||
display:flex; align-items:flex-start; gap:8px;
|
||||
background:rgba(239,68,68,.06); border:1px solid rgba(239,68,68,.2);
|
||||
border-radius:8px; padding:8px 10px; color:#ef4444; font-size:12px; font-weight:600;
|
||||
}
|
||||
.aa-title { font-size:11px; font-weight:700; color:#ef4444; margin-bottom:4px; }
|
||||
.aa-list { display:flex; flex-direction:column; gap:3px; }
|
||||
.aa-row { display:flex; align-items:center; justify-content:space-between; gap:8px; }
|
||||
.aa-until { font-size:10px; color:#888; font-weight:500; }
|
||||
|
||||
/* Full restriction history (sender) */
|
||||
.restrict-hist { border-top:1px solid #1a1a1c; padding-top:8px; }
|
||||
.rh-item {
|
||||
border:1px solid #1a1a1c; border-radius:8px; padding:7px 9px;
|
||||
margin-bottom:5px; display:flex; flex-direction:column; gap:4px;
|
||||
background:#0e0e10;
|
||||
}
|
||||
.rh-item.active { border-color:rgba(239,68,68,.25); background:rgba(239,68,68,.03); }
|
||||
.rh-row { display:flex; align-items:center; gap:6px; font-size:11px; }
|
||||
.rh-reason { flex:1; color:#777; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.rh-date { color:#444; font-size:10px; white-space:nowrap; }
|
||||
.rh-until { display:flex; align-items:center; gap:4px; font-size:10px; color:#555; }
|
||||
.rh-left { color:#f97316; font-weight:700; margin-left:4px; }
|
||||
.rh-actions { display:flex; align-items:center; gap:6px; flex-wrap:wrap; }
|
||||
.rha {
|
||||
display:flex; align-items:center; gap:3px; font-size:10px; font-weight:700;
|
||||
padding:3px 9px; border-radius:5px; border:1px solid; cursor:pointer; transition:.15s;
|
||||
}
|
||||
.rha.lift { color:#22c55e; border-color:rgba(34,197,94,.3); background:rgba(34,197,94,.07); }
|
||||
.rha.lift:hover { background:rgba(34,197,94,.14); }
|
||||
.rha.extend { color:#f59e0b; border-color:rgba(245,158,11,.3); background:rgba(245,158,11,.07); }
|
||||
.rha.extend:hover { background:rgba(245,158,11,.14); }
|
||||
.rha-extend { display:flex; align-items:center; gap:4px; }
|
||||
.rha-input {
|
||||
width:56px; padding:3px 7px; background:#0d0d0f;
|
||||
border:1px solid #252528; color:#ccc; border-radius:5px; font-size:10px;
|
||||
}
|
||||
.no-hist { font-size:11px; color:#333; font-style:italic; border-top:1px solid #1a1a1c; padding-top:8px; }
|
||||
|
||||
/* Divider */
|
||||
.reported-divider {
|
||||
display:flex; align-items:center; justify-content:center; gap:6px;
|
||||
color:#3a3a3f; font-size:11px; font-weight:700;
|
||||
}
|
||||
.df { color:#df006a; }
|
||||
|
||||
/* ─ Chat timeline ─ */
|
||||
.col-center { min-width:0; }
|
||||
.ct-head {
|
||||
display:flex; align-items:center; gap:7px; margin-bottom:12px;
|
||||
font-size:13px; font-weight:800; color:#e0e0e0;
|
||||
}
|
||||
.ct-icon { color:#df006a; }
|
||||
.ct-sub { font-size:11px; font-weight:400; color:#555; margin-left:2px; }
|
||||
.ct-sub.warn { color:#ef4444; }
|
||||
|
||||
.timeline {
|
||||
background:#0c0c0e; border:1px solid #1a1a1c; border-radius:12px; overflow:hidden;
|
||||
}
|
||||
.tl-empty { padding:40px; text-align:center; color:#3a3a3f; font-size:13px; }
|
||||
|
||||
.tl-msg {
|
||||
display:flex; align-items:flex-start; gap:10px;
|
||||
padding:10px 14px; border-bottom:1px solid #111113; transition:background .1s;
|
||||
}
|
||||
.tl-msg:last-child { border-bottom:none; }
|
||||
.tl-msg:hover { background:rgba(255,255,255,.015); }
|
||||
.tl-msg.by-sender { background:rgba(223,0,106,.03); }
|
||||
.tl-msg.by-reporter { background:rgba(59,130,246,.03); }
|
||||
|
||||
.tl-av {
|
||||
width:28px; height:28px; border-radius:7px; flex-shrink:0;
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
font-size:11px; font-weight:800; background:#1a1a1c; color:#555;
|
||||
}
|
||||
.tl-av.av-s { background:rgba(223,0,106,.15); color:#df006a; }
|
||||
.tl-av.av-r { background:rgba(59,130,246,.15); color:#3b82f6; }
|
||||
.tl-av.av-rep { box-shadow:0 0 0 2px rgba(223,0,106,.45); }
|
||||
|
||||
.tl-body { flex:1; min-width:0; }
|
||||
.tl-meta { display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin-bottom:2px; }
|
||||
.tl-user { font-size:11px; font-weight:700; color:#888; }
|
||||
.tl-ts { font-size:10px; color:#3a3a3f; margin-left:auto; }
|
||||
.tl-text { font-size:13px; color:#bbb; line-height:1.45; word-break:break-word; }
|
||||
|
||||
/* Reported msg */
|
||||
.tl-msg.reported {
|
||||
background:rgba(223,0,106,.06) !important;
|
||||
border-left:3px solid #df006a;
|
||||
padding-left:11px;
|
||||
position:relative;
|
||||
}
|
||||
.rep-flag {
|
||||
position:absolute; left:-1px; top:8px;
|
||||
width:18px; height:18px; background:#df006a; color:#fff;
|
||||
border-radius:50%; display:flex; align-items:center; justify-content:center;
|
||||
}
|
||||
.rep-user { color:#f472b6 !important; }
|
||||
.rep-badge {
|
||||
display:flex; align-items:center; gap:3px;
|
||||
font-size:9px; font-weight:800; color:#df006a;
|
||||
background:rgba(223,0,106,.1); border:1px solid rgba(223,0,106,.25);
|
||||
padding:1px 6px; border-radius:4px; text-transform:uppercase;
|
||||
}
|
||||
.reason-badge {
|
||||
font-size:9px; font-weight:700; color:#f59e0b;
|
||||
background:rgba(245,158,11,.1); border:1px solid rgba(245,158,11,.25);
|
||||
padding:1px 6px; border-radius:4px;
|
||||
}
|
||||
.rep-text { color:#fff !important; font-weight:500; }
|
||||
|
||||
/* ─ Punishment panel ─ */
|
||||
.col-right { min-width:0; }
|
||||
.pun-head {
|
||||
display:flex; align-items:center; gap:7px; margin-bottom:14px;
|
||||
font-size:13px; font-weight:800; color:#e0e0e0;
|
||||
}
|
||||
.pun-icon { color:#f59e0b; }
|
||||
|
||||
.pun-form { display:flex; flex-direction:column; gap:14px; }
|
||||
.pun-form.locked { opacity:.45; pointer-events:none; }
|
||||
|
||||
.type-row { display:flex; gap:8px; }
|
||||
.type-btn {
|
||||
flex:1; display:flex; align-items:center; justify-content:center; gap:5px;
|
||||
font-size:12px; font-weight:700; padding:9px; border-radius:9px;
|
||||
border:1px solid #252528; background:#111113; color:#555; cursor:pointer; transition:.15s;
|
||||
}
|
||||
.type-btn.on { border-color:rgba(249,115,22,.4); background:rgba(249,115,22,.1); color:#f97316; }
|
||||
.type-btn.ban.on { border-color:rgba(239,68,68,.4); background:rgba(239,68,68,.1); color:#ef4444; }
|
||||
|
||||
.tpl-block { display:flex; flex-direction:column; gap:6px; }
|
||||
.tpl-label { font-size:10px; font-weight:700; text-transform:uppercase; color:#444; letter-spacing:.5px; }
|
||||
.tpl-list { display:flex; flex-direction:column; gap:4px; }
|
||||
.tpl-btn {
|
||||
display:grid; grid-template-columns:auto 1fr auto;
|
||||
align-items:center; gap:6px; padding:8px 11px;
|
||||
border-radius:8px; border:1px solid #1a1a1c; background:#0e0e10;
|
||||
cursor:pointer; transition:.15s; text-align:left;
|
||||
}
|
||||
.tpl-btn:hover { border-color:#252528; background:#111113; }
|
||||
.tpl-btn.on { border-color:rgba(249,115,22,.35); background:rgba(249,115,22,.07); }
|
||||
.tpl-btn.ban.on { border-color:rgba(239,68,68,.35); background:rgba(239,68,68,.07); }
|
||||
.tpl-name { font-size:12px; font-weight:700; color:#ccc; }
|
||||
.tpl-dur { font-size:10px; font-weight:700; color:#666; background:#161618; border:1px solid #252528; padding:1px 7px; border-radius:5px; justify-self:end; }
|
||||
.tpl-reason { grid-column:1/-1; font-size:10px; color:#555; }
|
||||
|
||||
.or-line {
|
||||
text-align:center; color:#2a2a2a; font-size:10px; font-weight:600;
|
||||
position:relative;
|
||||
}
|
||||
.or-line span { position:relative; z-index:1; background:#0f0f11; padding:0 10px; }
|
||||
.or-line::before { content:''; position:absolute; top:50%; left:0; right:0; height:1px; background:#1a1a1c; }
|
||||
|
||||
.custom-block { display:flex; flex-direction:column; gap:10px; }
|
||||
.fl { display:flex; flex-direction:column; gap:5px; font-size:11px; font-weight:600; color:#555; }
|
||||
.fl span { display:flex; align-items:center; gap:4px; }
|
||||
.fi {
|
||||
background:#0c0c0e; border:1px solid #1e1e21; color:#ccc;
|
||||
padding:8px 11px; border-radius:8px; font-size:13px; width:100%; box-sizing:border-box;
|
||||
}
|
||||
.fi:focus { outline:none; border-color:rgba(223,0,106,.3); }
|
||||
.ta { resize:none; }
|
||||
|
||||
.pun-preview {
|
||||
display:flex; align-items:center; gap:8px; flex-wrap:wrap;
|
||||
background:#0c0c0e; border:1px solid #1e1e21; border-radius:8px; padding:8px 11px; font-size:11px;
|
||||
}
|
||||
.pp-type { font-weight:800; padding:2px 8px; border-radius:5px; }
|
||||
.pp-type.chat { color:#f97316; background:rgba(249,115,22,.1); }
|
||||
.pp-type.ban { color:#ef4444; background:rgba(239,68,68,.1); }
|
||||
.pp-dur { font-weight:700; color:#888; }
|
||||
.pp-until { color:#555; font-size:10px; margin-left:auto; }
|
||||
|
||||
.pun-btn {
|
||||
display:flex; align-items:center; justify-content:center; gap:7px;
|
||||
width:100%; padding:11px; border-radius:10px; border:none; cursor:pointer;
|
||||
font-size:13px; font-weight:800; color:#fff; transition:.2s;
|
||||
background:linear-gradient(135deg,#f97316,#ea580c);
|
||||
}
|
||||
.pun-btn.ban { background:linear-gradient(135deg,#ef4444,#dc2626); }
|
||||
.pun-btn:hover:not(:disabled) { filter:brightness(1.1); }
|
||||
.pun-btn:disabled { opacity:.4; cursor:not-allowed; }
|
||||
.spin { animation:spin .8s linear infinite; }
|
||||
@keyframes spin { to { transform:rotate(360deg); } }
|
||||
</style>
|
||||
354
resources/js/pages/Admin/ChatReports.vue
Normal file
354
resources/js/pages/Admin/ChatReports.vue
Normal file
@@ -0,0 +1,354 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, router } from '@inertiajs/vue3';
|
||||
import { ref, computed } from 'vue';
|
||||
import CasinoAdminLayout from '@/layouts/admin/CasinoAdminLayout.vue';
|
||||
import {
|
||||
Search, Flag, Clock, CheckCircle2, XCircle, ChevronRight,
|
||||
MessageSquareText, Eye, EyeOff
|
||||
} from 'lucide-vue-next';
|
||||
|
||||
interface Reporter {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
avatar: string | null;
|
||||
avatar_url: string | null;
|
||||
}
|
||||
|
||||
interface Report {
|
||||
id: number;
|
||||
reporter: Reporter;
|
||||
message_id: string;
|
||||
message_text: string;
|
||||
sender_id: number | null;
|
||||
sender_username: string | null;
|
||||
reason: string | null;
|
||||
status: 'pending' | 'reviewed' | 'dismissed';
|
||||
admin_note: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
reports: { data: Report[]; links: any[]; meta: any };
|
||||
filters: { status?: string; search?: string };
|
||||
stats: { total: number; pending: number; reviewed: number; dismissed: number };
|
||||
}>();
|
||||
|
||||
const filterStatus = ref(props.filters.status ?? 'pending');
|
||||
const searchInput = ref(props.filters.search ?? '');
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function applyFilter() {
|
||||
router.get('/admin/reports/chat', {
|
||||
status: filterStatus.value || undefined,
|
||||
search: searchInput.value || undefined,
|
||||
}, { preserveScroll: true });
|
||||
}
|
||||
|
||||
function onSearchInput() {
|
||||
if (searchTimer) clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(applyFilter, 400);
|
||||
}
|
||||
|
||||
function setStatus(s: string) {
|
||||
filterStatus.value = s;
|
||||
applyFilter();
|
||||
}
|
||||
|
||||
function openCase(id: number) {
|
||||
router.visit(`/admin/reports/chat/${id}`);
|
||||
}
|
||||
|
||||
function fmt(d: string) {
|
||||
return new Date(d).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
const reasonLabels: Record<string, string> = {
|
||||
spam: 'Spam', beleidigung: 'Beleidigung', belaestigung: 'Belästigung',
|
||||
betrug: 'Betrug', sonstiges: 'Sonstiges', harassment: 'Belästigung',
|
||||
offensive: 'Beleidigung', scam: 'Betrug', other: 'Sonstiges',
|
||||
};
|
||||
function rl(r: string | null) { return r ? (reasonLabels[r] ?? r) : '–'; }
|
||||
|
||||
const statusMeta: Record<string, { color: string; bg: string; border: string; label: string }> = {
|
||||
pending: { color: '#f59e0b', bg: 'rgba(245,158,11,0.12)', border: 'rgba(245,158,11,0.3)', label: 'Pending' },
|
||||
reviewed: { color: '#22c55e', bg: 'rgba(34,197,94,0.12)', border: 'rgba(34,197,94,0.3)', label: 'Reviewed' },
|
||||
dismissed: { color: '#6b7280', bg: 'rgba(107,114,128,0.1)', border: 'rgba(107,114,128,0.25)', label: 'Dismissed' },
|
||||
};
|
||||
|
||||
const showingLabel = computed(() => {
|
||||
if (!filterStatus.value || filterStatus.value === 'all') return 'Alle';
|
||||
return statusMeta[filterStatus.value]?.label ?? filterStatus.value;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CasinoAdminLayout>
|
||||
<Head title="Admin – Chat Reports" />
|
||||
<template #title>Chat Reports</template>
|
||||
|
||||
<!-- ── Stats row ── -->
|
||||
<div class="stats-row">
|
||||
<button class="stat-card" :class="{ active: !filterStatus || filterStatus === 'all' }" @click="setStatus('all')">
|
||||
<MessageSquareText :size="16" class="sc-icon all" />
|
||||
<div class="sc-body">
|
||||
<span class="sc-val">{{ stats.total }}</span>
|
||||
<span class="sc-label">Gesamt</span>
|
||||
</div>
|
||||
</button>
|
||||
<button class="stat-card" :class="{ active: filterStatus === 'pending' }" @click="setStatus('pending')">
|
||||
<Clock :size="16" class="sc-icon pending" />
|
||||
<div class="sc-body">
|
||||
<span class="sc-val pending">{{ stats.pending }}</span>
|
||||
<span class="sc-label">Offen</span>
|
||||
</div>
|
||||
<span v-if="stats.pending > 0" class="sc-dot"></span>
|
||||
</button>
|
||||
<button class="stat-card" :class="{ active: filterStatus === 'reviewed' }" @click="setStatus('reviewed')">
|
||||
<CheckCircle2 :size="16" class="sc-icon reviewed" />
|
||||
<div class="sc-body">
|
||||
<span class="sc-val reviewed">{{ stats.reviewed }}</span>
|
||||
<span class="sc-label">Erledigt</span>
|
||||
</div>
|
||||
</button>
|
||||
<button class="stat-card" :class="{ active: filterStatus === 'dismissed' }" @click="setStatus('dismissed')">
|
||||
<XCircle :size="16" class="sc-icon dismissed" />
|
||||
<div class="sc-body">
|
||||
<span class="sc-val dismissed">{{ stats.dismissed }}</span>
|
||||
<span class="sc-label">Abgelehnt</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ── Toolbar ── -->
|
||||
<div class="toolbar">
|
||||
<div class="search-wrap">
|
||||
<Search :size="14" class="search-icon" />
|
||||
<input
|
||||
v-model="searchInput"
|
||||
@input="onSearchInput"
|
||||
type="text"
|
||||
placeholder="Case-Nr. oder Username suchen…"
|
||||
class="search-input"
|
||||
/>
|
||||
</div>
|
||||
<div class="showing-pill">
|
||||
<Eye :size="12" />
|
||||
{{ showingLabel }}
|
||||
<span class="showing-count">{{ reports.meta?.total ?? reports.data.length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Table ── -->
|
||||
<div class="table-wrap">
|
||||
<div v-if="!reports.data.length" class="empty-state">
|
||||
<Flag :size="28" class="empty-icon" />
|
||||
<div class="empty-text">Keine Reports gefunden</div>
|
||||
<div class="empty-sub" v-if="searchInput">Für „{{ searchInput }}" wurden keine Ergebnisse gefunden.</div>
|
||||
</div>
|
||||
|
||||
<table v-else class="reports-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:70px">Case</th>
|
||||
<th style="width:150px">Von</th>
|
||||
<th style="width:150px">Gemeldet</th>
|
||||
<th style="width:110px">Grund</th>
|
||||
<th>Nachricht</th>
|
||||
<th style="width:105px">Status</th>
|
||||
<th style="width:120px">Datum</th>
|
||||
<th style="width:28px"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="r in reports.data"
|
||||
:key="r.id"
|
||||
class="report-row"
|
||||
:class="`status-${r.status}`"
|
||||
@click="openCase(r.id)"
|
||||
>
|
||||
<td class="td-case">
|
||||
<span class="case-badge">#{{ r.id }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="user-cell">
|
||||
<div class="dot reporter"></div>
|
||||
<span class="uname">@{{ r.reporter?.username || '–' }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="user-cell">
|
||||
<div class="dot sender"></div>
|
||||
<span class="uname">@{{ r.sender_username || '–' }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="reason-chip">{{ rl(r.reason) }}</span>
|
||||
</td>
|
||||
<td class="td-msg">
|
||||
<span class="msg-snip">{{ r.message_text }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="status-chip"
|
||||
:style="{ color: statusMeta[r.status].color, background: statusMeta[r.status].bg, borderColor: statusMeta[r.status].border }"
|
||||
>{{ statusMeta[r.status].label }}</span>
|
||||
</td>
|
||||
<td class="td-date">{{ fmt(r.created_at) }}</td>
|
||||
<td class="td-arrow"><ChevronRight :size="14" /></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- ── Pagination ── -->
|
||||
<div class="pagination" v-if="reports.links && reports.links.length > 3">
|
||||
<template v-for="link in reports.links" :key="link.label">
|
||||
<button
|
||||
class="page-btn"
|
||||
:class="{ active: link.active, disabled: !link.url }"
|
||||
:disabled="!link.url"
|
||||
@click="link.url && router.visit(link.url)"
|
||||
v-html="link.label"
|
||||
></button>
|
||||
</template>
|
||||
</div>
|
||||
</CasinoAdminLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ── Stats ── */
|
||||
.stats-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 10px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.stat-card {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
background: #111113; border: 1px solid #1e1e21; border-radius: 12px;
|
||||
padding: 14px 16px; cursor: pointer; transition: .15s; position: relative;
|
||||
text-align: left;
|
||||
}
|
||||
.stat-card:hover { border-color: #2a2a2f; background: #141416; }
|
||||
.stat-card.active { border-color: #333; background: #161618; }
|
||||
.sc-icon { flex-shrink: 0; }
|
||||
.sc-icon.all { color: #888; }
|
||||
.sc-icon.pending { color: #f59e0b; }
|
||||
.sc-icon.reviewed { color: #22c55e; }
|
||||
.sc-icon.dismissed { color: #6b7280; }
|
||||
.sc-body { display: flex; flex-direction: column; gap: 1px; }
|
||||
.sc-val { font-size: 22px; font-weight: 900; color: #fff; line-height: 1; }
|
||||
.sc-val.pending { color: #f59e0b; }
|
||||
.sc-val.reviewed { color: #22c55e; }
|
||||
.sc-val.dismissed { color: #6b7280; }
|
||||
.sc-label { font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: .5px; font-weight: 700; }
|
||||
.sc-dot {
|
||||
position: absolute; top: 10px; right: 10px;
|
||||
width: 7px; height: 7px; border-radius: 50%; background: #f59e0b;
|
||||
box-shadow: 0 0 6px rgba(245,158,11,.6);
|
||||
animation: pulse-dot 1.5s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse-dot {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: .5; transform: scale(.7); }
|
||||
}
|
||||
|
||||
/* ── Toolbar ── */
|
||||
.toolbar {
|
||||
display: flex; align-items: center; gap: 12px; margin-bottom: 16px;
|
||||
}
|
||||
.search-wrap {
|
||||
flex: 1; position: relative; max-width: 380px;
|
||||
}
|
||||
.search-icon {
|
||||
position: absolute; left: 11px; top: 50%; transform: translateY(-50%); color: #444; pointer-events: none;
|
||||
}
|
||||
.search-input {
|
||||
width: 100%; background: #111113; border: 1px solid #1e1e21; border-radius: 9px;
|
||||
color: #ccc; font-size: 13px; padding: 9px 12px 9px 34px; outline: none; transition: .15s;
|
||||
}
|
||||
.search-input::placeholder { color: #3a3a3f; }
|
||||
.search-input:focus { border-color: #333; }
|
||||
|
||||
.showing-pill {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
background: #111113; border: 1px solid #1e1e21; border-radius: 20px;
|
||||
padding: 6px 12px; font-size: 12px; color: #555; margin-left: auto;
|
||||
}
|
||||
.showing-count {
|
||||
background: #1a1a1c; border: 1px solid #252528; border-radius: 10px;
|
||||
padding: 1px 7px; color: #888; font-size: 11px; font-weight: 700;
|
||||
}
|
||||
|
||||
/* ── Table ── */
|
||||
.table-wrap {
|
||||
background: #111113; border: 1px solid #1e1e21; border-radius: 14px; overflow: hidden;
|
||||
}
|
||||
.empty-state { padding: 60px 20px; text-align: center; }
|
||||
.empty-icon { color: #2a2a2f; margin: 0 auto 12px; }
|
||||
.empty-text { color: #555; font-size: 14px; font-weight: 700; }
|
||||
.empty-sub { color: #3a3a3f; font-size: 12px; margin-top: 4px; }
|
||||
|
||||
.reports-table { width: 100%; border-collapse: collapse; table-layout: fixed; }
|
||||
.reports-table thead tr { border-bottom: 1px solid #1e1e21; }
|
||||
.reports-table th {
|
||||
padding: 10px 14px; font-size: 10px; font-weight: 700;
|
||||
text-transform: uppercase; letter-spacing: .6px; color: #3a3a3f; text-align: left;
|
||||
}
|
||||
.reports-table td { padding: 11px 14px; font-size: 13px; vertical-align: middle; }
|
||||
|
||||
.report-row { border-bottom: 1px solid #161618; cursor: pointer; transition: background .12s; }
|
||||
.report-row:last-child { border-bottom: none; }
|
||||
.report-row:hover { background: rgba(255,255,255,.025); }
|
||||
.report-row.status-pending { border-left: 2px solid #f59e0b; }
|
||||
.report-row.status-reviewed { border-left: 2px solid #22c55e; opacity: .65; }
|
||||
.report-row.status-dismissed { border-left: 2px solid #252528; opacity: .45; }
|
||||
|
||||
.td-case { width: 70px; }
|
||||
.case-badge {
|
||||
font-size: 11px; font-weight: 800; color: #888;
|
||||
background: #161618; border: 1px solid #252528;
|
||||
padding: 3px 9px; border-radius: 6px;
|
||||
}
|
||||
|
||||
.user-cell { display: flex; align-items: center; gap: 7px; white-space: nowrap; overflow: hidden; }
|
||||
.dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
|
||||
.dot.reporter { background: #3b82f6; }
|
||||
.dot.sender { background: #df006a; }
|
||||
.uname { font-size: 13px; color: #ddd; font-weight: 600; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.reason-chip {
|
||||
font-size: 10px; font-weight: 700; color: #aaa;
|
||||
background: #161618; border: 1px solid #252528;
|
||||
padding: 3px 8px; border-radius: 6px; white-space: nowrap;
|
||||
}
|
||||
|
||||
.td-msg { max-width: 0; }
|
||||
.msg-snip {
|
||||
color: #555; font-size: 12px; display: block;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-chip {
|
||||
font-size: 10px; font-weight: 800; padding: 3px 9px;
|
||||
border-radius: 20px; border: 1px solid;
|
||||
text-transform: uppercase; letter-spacing: .4px; white-space: nowrap;
|
||||
}
|
||||
|
||||
.td-date { color: #444; font-size: 11px; white-space: nowrap; }
|
||||
.td-arrow { color: #333; text-align: center; }
|
||||
|
||||
/* ── Pagination ── */
|
||||
.pagination { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 18px; justify-content: center; }
|
||||
.page-btn {
|
||||
background: #111; border: 1px solid #222; color: #ccc;
|
||||
padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 12px; transition: .2s;
|
||||
}
|
||||
.page-btn.active { background: #df006a; border-color: #df006a; color: #fff; }
|
||||
.page-btn.disabled { opacity: .4; cursor: default; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.stats-row { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
</style>
|
||||
155
resources/js/pages/Admin/Dashboard.vue
Normal file
155
resources/js/pages/Admin/Dashboard.vue
Normal file
@@ -0,0 +1,155 @@
|
||||
<script setup lang="ts">
|
||||
import { useForm, Head } from '@inertiajs/vue3';
|
||||
import { ref } from 'vue';
|
||||
import UserLayout from '@/layouts/admin/CasinoAdminLayout.vue';
|
||||
|
||||
defineProps<{
|
||||
users: any;
|
||||
}>();
|
||||
|
||||
const editingUser = ref<any>(null);
|
||||
const form = useForm({
|
||||
vip_level: 0,
|
||||
balance: '0',
|
||||
is_banned: false,
|
||||
is_chat_banned: false,
|
||||
ban_reason: '',
|
||||
ban_ends_at: '', // optional end time for account ban
|
||||
chat_ban_ends_at: '', // optional end time for chat ban
|
||||
role: 'User',
|
||||
});
|
||||
|
||||
const openEditModal = (user: any) => {
|
||||
editingUser.value = user;
|
||||
form.vip_level = user.vip_level;
|
||||
form.balance = user.balance;
|
||||
form.is_banned = user.is_banned || false;
|
||||
form.is_chat_banned = user.is_chat_banned || false;
|
||||
form.ban_reason = user.ban_reason || '';
|
||||
form.ban_ends_at = '';
|
||||
form.chat_ban_ends_at = '';
|
||||
form.role = user.role || 'User';
|
||||
};
|
||||
|
||||
const saveUser = () => {
|
||||
if (!editingUser.value) return;
|
||||
form.post(`/admin/users/${editingUser.value.id}`, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
editingUser.value = null;
|
||||
}
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UserLayout>
|
||||
<Head title="Admin Dashboard" />
|
||||
<div class="p-4 sm:p-6 lg:p-8">
|
||||
<h1 class="text-2xl font-bold text-white mb-6">Admin Dashboard</h1>
|
||||
|
||||
<div class="bg-[#0f0f0f] border border-[#1f1f1f] rounded-xl shadow-lg">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm text-left text-gray-400">
|
||||
<thead class="text-xs text-gray-400 uppercase bg-[#141414]">
|
||||
<tr>
|
||||
<th scope="col" class="px-6 py-3">ID</th>
|
||||
<th scope="col" class="px-6 py-3">User</th>
|
||||
<th scope="col" class="px-6 py-3">Role</th>
|
||||
<th scope="col" class="px-6 py-3">Balance</th>
|
||||
<th scope="col" class="px-6 py-3">Status</th>
|
||||
<th scope="col" class="px-6 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="user in users.data" :key="user.id" class="border-b border-[#1f1f1f] hover:bg-[#141414]">
|
||||
<td class="px-6 py-4 font-bold text-white">{{ user.id }}</td>
|
||||
<td class="px-6 py-4 font-bold text-white">{{ user.username }}</td>
|
||||
<td class="px-6 py-4">
|
||||
<span :class="{'text-red-500': user.role === 'Admin', 'text-blue-400': user.role === 'Staff'}">{{ user.role }}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">${{ user.balance }}</td>
|
||||
<td class="px-6 py-4 flex gap-2">
|
||||
<span v-if="user.is_banned" class="px-2 py-1 text-xs font-bold text-red-400 bg-red-900/50 rounded-full">BANNED</span>
|
||||
<span v-if="user.is_chat_banned" class="px-2 py-1 text-xs font-bold text-orange-400 bg-orange-900/50 rounded-full">MUTED</span>
|
||||
<span v-if="!user.is_banned && !user.is_chat_banned" class="px-2 py-1 text-xs font-bold text-green-400 bg-green-900/50 rounded-full">Active</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right">
|
||||
<button @click="openEditModal(user)" class="font-medium text-blue-500 hover:underline">Edit</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Modal -->
|
||||
<div v-if="editingUser" class="fixed inset-0 bg-black/80 z-50 flex items-center justify-center backdrop-blur-sm" @click.self="editingUser = null">
|
||||
<div class="bg-[#111] border border-[#333] rounded-xl p-6 w-full max-w-md shadow-2xl">
|
||||
<h2 class="text-lg font-bold mb-4 text-white">Edit {{ editingUser.username }}</h2>
|
||||
<form @submit.prevent="saveUser" class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-400 uppercase mb-1">VIP Level</label>
|
||||
<input type="number" v-model="form.vip_level" class="w-full bg-[#0a0a0a] border border-[#333] rounded-lg p-3 text-white text-sm focus:border-blue-500 outline-none">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-400 uppercase mb-1">Role</label>
|
||||
<select v-model="form.role" class="w-full bg-[#0a0a0a] border border-[#333] rounded-lg p-3 text-white text-sm focus:border-blue-500 outline-none">
|
||||
<option>User</option>
|
||||
<option>Staff</option>
|
||||
<option>Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-400 uppercase mb-1">Balance</label>
|
||||
<input type="text" v-model="form.balance" class="w-full bg-[#0a0a0a] border border-[#333] rounded-lg p-3 text-white text-sm focus:border-blue-500 outline-none">
|
||||
</div>
|
||||
|
||||
<div class="border-t border-[#222] pt-4 mt-4">
|
||||
<h3 class="text-sm font-bold text-red-500 uppercase mb-3">Restrictions</h3>
|
||||
|
||||
<div class="space-y-3">
|
||||
<label class="flex items-center p-3 bg-[#1a0505] border border-red-900/30 rounded-lg cursor-pointer hover:bg-[#2a0a0a] transition">
|
||||
<input type="checkbox" v-model="form.is_banned" class="w-4 h-4 text-red-600 bg-gray-700 border-gray-600 rounded focus:ring-red-500">
|
||||
<span class="ml-3 text-sm font-bold text-red-400">Account Ban (Full Lock)</span>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center p-3 bg-[#1a1005] border border-orange-900/30 rounded-lg cursor-pointer hover:bg-[#2a1a0a] transition">
|
||||
<input type="checkbox" v-model="form.is_chat_banned" class="w-4 h-4 text-orange-600 bg-gray-700 border-gray-600 rounded focus:ring-orange-500">
|
||||
<span class="ml-3 text-sm font-bold text-orange-400">Chat Ban (Mute)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="form.is_banned || form.is_chat_banned" class="mt-4 space-y-4">
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-400 uppercase mb-1">Reason</label>
|
||||
<input type="text" v-model="form.ban_reason" placeholder="Why?" class="w-full bg-[#0a0a0a] border border-[#333] rounded-lg p-3 text-white text-sm focus:border-red-500 outline-none">
|
||||
</div>
|
||||
|
||||
<div v-if="form.is_banned">
|
||||
<label class="block text-xs font-bold text-gray-400 uppercase mb-1">Account Ban ends at (optional)</label>
|
||||
<input type="datetime-local" v-model="form.ban_ends_at" class="w-full bg-[#0a0a0a] border border-[#333] rounded-lg p-3 text-white text-sm focus:border-red-500 outline-none">
|
||||
<p class="mt-1 text-[11px] text-gray-500">Leave empty for permanent ban. Uses server timezone.</p>
|
||||
</div>
|
||||
|
||||
<div v-if="form.is_chat_banned">
|
||||
<label class="block text-xs font-bold text-gray-400 uppercase mb-1">Chat Ban ends at (optional)</label>
|
||||
<input type="datetime-local" v-model="form.chat_ban_ends_at" class="w-full bg-[#0a0a0a] border border-[#333] rounded-lg p-3 text-white text-sm focus:border-orange-500 outline-none">
|
||||
<p class="mt-1 text-[11px] text-gray-500">Leave empty for permanent mute. Uses server timezone.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<button type="button" @click="editingUser = null" class="px-4 py-2 text-sm font-bold text-gray-400 hover:text-white transition">Cancel</button>
|
||||
<button type="submit" :disabled="form.processing" class="px-6 py-2 text-sm font-bold text-white bg-blue-600 rounded-lg hover:bg-blue-500 transition shadow-lg shadow-blue-900/20">Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UserLayout>
|
||||
</template>
|
||||
440
resources/js/pages/Admin/GeoBlock.vue
Normal file
440
resources/js/pages/Admin/GeoBlock.vue
Normal file
@@ -0,0 +1,440 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, useForm } from '@inertiajs/vue3';
|
||||
import { onMounted, nextTick, ref, computed } from 'vue';
|
||||
import CasinoAdminLayout from '@/layouts/admin/CasinoAdminLayout.vue';
|
||||
|
||||
// Full country list with ISO codes and German names
|
||||
const COUNTRIES: { code: string; name: string }[] = [
|
||||
{ code: 'AF', name: 'Afghanistan' }, { code: 'AL', name: 'Albanien' }, { code: 'DZ', name: 'Algerien' },
|
||||
{ code: 'AD', name: 'Andorra' }, { code: 'AO', name: 'Angola' }, { code: 'AR', name: 'Argentinien' },
|
||||
{ code: 'AM', name: 'Armenien' }, { code: 'AU', name: 'Australien' }, { code: 'AT', name: 'Österreich' },
|
||||
{ code: 'AZ', name: 'Aserbaidschan' }, { code: 'BS', name: 'Bahamas' }, { code: 'BH', name: 'Bahrain' },
|
||||
{ code: 'BD', name: 'Bangladesch' }, { code: 'BY', name: 'Belarus' }, { code: 'BE', name: 'Belgien' },
|
||||
{ code: 'BZ', name: 'Belize' }, { code: 'BO', name: 'Bolivien' }, { code: 'BA', name: 'Bosnien' },
|
||||
{ code: 'BR', name: 'Brasilien' }, { code: 'BN', name: 'Brunei' }, { code: 'BG', name: 'Bulgarien' },
|
||||
{ code: 'KH', name: 'Kambodscha' }, { code: 'CA', name: 'Kanada' }, { code: 'CL', name: 'Chile' },
|
||||
{ code: 'CN', name: 'China' }, { code: 'CO', name: 'Kolumbien' }, { code: 'HR', name: 'Kroatien' },
|
||||
{ code: 'CU', name: 'Kuba' }, { code: 'CY', name: 'Zypern' }, { code: 'CZ', name: 'Tschechien' },
|
||||
{ code: 'DK', name: 'Dänemark' }, { code: 'EG', name: 'Ägypten' }, { code: 'EE', name: 'Estland' },
|
||||
{ code: 'ET', name: 'Äthiopien' }, { code: 'FI', name: 'Finnland' }, { code: 'FR', name: 'Frankreich' },
|
||||
{ code: 'GE', name: 'Georgien' }, { code: 'DE', name: 'Deutschland' }, { code: 'GH', name: 'Ghana' },
|
||||
{ code: 'GR', name: 'Griechenland' }, { code: 'GT', name: 'Guatemala' }, { code: 'HU', name: 'Ungarn' },
|
||||
{ code: 'IN', name: 'Indien' }, { code: 'ID', name: 'Indonesien' }, { code: 'IR', name: 'Iran' },
|
||||
{ code: 'IQ', name: 'Irak' }, { code: 'IE', name: 'Irland' }, { code: 'IL', name: 'Israel' },
|
||||
{ code: 'IT', name: 'Italien' }, { code: 'JP', name: 'Japan' }, { code: 'JO', name: 'Jordanien' },
|
||||
{ code: 'KZ', name: 'Kasachstan' }, { code: 'KE', name: 'Kenia' }, { code: 'KW', name: 'Kuwait' },
|
||||
{ code: 'LV', name: 'Lettland' }, { code: 'LB', name: 'Libanon' }, { code: 'LY', name: 'Libyen' },
|
||||
{ code: 'LT', name: 'Litauen' }, { code: 'LU', name: 'Luxemburg' }, { code: 'MY', name: 'Malaysia' },
|
||||
{ code: 'MT', name: 'Malta' }, { code: 'MX', name: 'Mexiko' }, { code: 'MD', name: 'Moldawien' },
|
||||
{ code: 'MC', name: 'Monaco' }, { code: 'ME', name: 'Montenegro' }, { code: 'MA', name: 'Marokko' },
|
||||
{ code: 'NL', name: 'Niederlande' }, { code: 'NZ', name: 'Neuseeland' }, { code: 'NG', name: 'Nigeria' },
|
||||
{ code: 'NO', name: 'Norwegen' }, { code: 'OM', name: 'Oman' }, { code: 'PK', name: 'Pakistan' },
|
||||
{ code: 'PE', name: 'Peru' }, { code: 'PH', name: 'Philippinen' }, { code: 'PL', name: 'Polen' },
|
||||
{ code: 'PT', name: 'Portugal' }, { code: 'QA', name: 'Katar' }, { code: 'RO', name: 'Rumänien' },
|
||||
{ code: 'RU', name: 'Russland' }, { code: 'SA', name: 'Saudi-Arabien' }, { code: 'RS', name: 'Serbien' },
|
||||
{ code: 'SG', name: 'Singapur' }, { code: 'SK', name: 'Slowakei' }, { code: 'SI', name: 'Slowenien' },
|
||||
{ code: 'ZA', name: 'Südafrika' }, { code: 'KR', name: 'Südkorea' }, { code: 'ES', name: 'Spanien' },
|
||||
{ code: 'LK', name: 'Sri Lanka' }, { code: 'SE', name: 'Schweden' }, { code: 'CH', name: 'Schweiz' },
|
||||
{ code: 'SY', name: 'Syrien' }, { code: 'TW', name: 'Taiwan' }, { code: 'TH', name: 'Thailand' },
|
||||
{ code: 'TN', name: 'Tunesien' }, { code: 'TR', name: 'Türkei' }, { code: 'UA', name: 'Ukraine' },
|
||||
{ code: 'AE', name: 'Vereinigte Arabische Emirate' }, { code: 'GB', name: 'Vereinigtes Königreich' },
|
||||
{ code: 'US', name: 'USA' }, { code: 'UY', name: 'Uruguay' }, { code: 'UZ', name: 'Usbekistan' },
|
||||
{ code: 'VE', name: 'Venezuela' }, { code: 'VN', name: 'Vietnam' }, { code: 'YE', name: 'Jemen' },
|
||||
{ code: 'ZW', name: 'Simbabwe' },
|
||||
];
|
||||
|
||||
const countryMap: Record<string, string> = Object.fromEntries(COUNTRIES.map(c => [c.code, c.name]));
|
||||
function getCountryName(code: string): string { return countryMap[code] ?? code; }
|
||||
|
||||
const props = defineProps<{
|
||||
settings: {
|
||||
enabled: boolean;
|
||||
mode: 'blacklist' | 'whitelist';
|
||||
blocked_countries: string[];
|
||||
allowed_countries: string[];
|
||||
vpn_block: boolean;
|
||||
vpn_provider: 'none' | 'ipqualityscore' | 'proxycheck';
|
||||
vpn_api_key: string;
|
||||
block_message: string;
|
||||
redirect_url: string;
|
||||
};
|
||||
}>();
|
||||
|
||||
const form = useForm({
|
||||
enabled: props.settings.enabled ?? false,
|
||||
mode: props.settings.mode ?? 'blacklist',
|
||||
blocked_countries: [...(props.settings.blocked_countries ?? [])],
|
||||
allowed_countries: [...(props.settings.allowed_countries ?? [])],
|
||||
vpn_block: props.settings.vpn_block ?? false,
|
||||
vpn_provider: props.settings.vpn_provider ?? 'none',
|
||||
vpn_api_key: props.settings.vpn_api_key ?? '',
|
||||
block_message: props.settings.block_message ?? 'This service is not available in your region.',
|
||||
redirect_url: props.settings.redirect_url ?? '',
|
||||
});
|
||||
|
||||
const newBlocked = ref('');
|
||||
const newAllowed = ref('');
|
||||
const blockedSearch = ref('');
|
||||
const allowedSearch = ref('');
|
||||
|
||||
const filteredBlockedCountries = computed(() => {
|
||||
const q = blockedSearch.value.toLowerCase();
|
||||
return COUNTRIES.filter(c =>
|
||||
!form.blocked_countries.includes(c.code) &&
|
||||
(c.name.toLowerCase().includes(q) || c.code.toLowerCase().includes(q))
|
||||
);
|
||||
});
|
||||
|
||||
const filteredAllowedCountries = computed(() => {
|
||||
const q = allowedSearch.value.toLowerCase();
|
||||
return COUNTRIES.filter(c =>
|
||||
!form.allowed_countries.includes(c.code) &&
|
||||
(c.name.toLowerCase().includes(q) || c.code.toLowerCase().includes(q))
|
||||
);
|
||||
});
|
||||
|
||||
function addCountry(list: 'blocked' | 'allowed') {
|
||||
const code = (list === 'blocked' ? newBlocked.value : newAllowed.value).trim().toUpperCase();
|
||||
if (!code || code.length !== 2) return;
|
||||
const arr = list === 'blocked' ? form.blocked_countries : form.allowed_countries;
|
||||
if (!arr.includes(code)) arr.push(code);
|
||||
if (list === 'blocked') { newBlocked.value = ''; blockedSearch.value = ''; }
|
||||
else { newAllowed.value = ''; allowedSearch.value = ''; }
|
||||
}
|
||||
|
||||
function addCountryFromSelect(list: 'blocked' | 'allowed', code: string) {
|
||||
if (!code) return;
|
||||
const arr = list === 'blocked' ? form.blocked_countries : form.allowed_countries;
|
||||
if (!arr.includes(code)) arr.push(code);
|
||||
if (list === 'blocked') { newBlocked.value = ''; blockedSearch.value = ''; }
|
||||
else { newAllowed.value = ''; allowedSearch.value = ''; }
|
||||
}
|
||||
|
||||
function removeCountry(list: 'blocked' | 'allowed', code: string) {
|
||||
if (list === 'blocked') {
|
||||
form.blocked_countries = form.blocked_countries.filter(c => c !== code);
|
||||
} else {
|
||||
form.allowed_countries = form.allowed_countries.filter(c => c !== code);
|
||||
}
|
||||
}
|
||||
|
||||
function submit() {
|
||||
form.post('/admin/settings/geo', { preserveScroll: true });
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => { if ((window as any).lucide) (window as any).lucide.createIcons(); });
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CasinoAdminLayout>
|
||||
<Head title="Admin – VPN & GeoBlock" />
|
||||
|
||||
<template #title>VPN & GeoBlock</template>
|
||||
|
||||
<div class="page-wrap">
|
||||
<!-- Flash -->
|
||||
<div v-if="($page.props as any).flash?.success" class="alert-success">
|
||||
<i data-lucide="check-circle"></i>
|
||||
{{ ($page.props as any).flash.success }}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<div class="card-title-group">
|
||||
<h2>GeoBlock & VPN-Schutz</h2>
|
||||
<p class="card-subtitle">Steuere den Zugang nach Land und blockiere VPN-Nutzer.</p>
|
||||
</div>
|
||||
<button class="btn-primary" @click="submit" :disabled="form.processing">
|
||||
<i data-lucide="save"></i>
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Master Toggle -->
|
||||
<div class="section">
|
||||
<div class="toggle-row">
|
||||
<div>
|
||||
<div class="toggle-label">GeoBlock aktivieren</div>
|
||||
<div class="toggle-desc">Wenn deaktiviert, haben alle Länder Zugang.</div>
|
||||
</div>
|
||||
<button class="toggle-btn" :class="{ active: form.enabled }" @click="form.enabled = !form.enabled">
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="{ 'dimmed': !form.enabled }">
|
||||
<!-- Mode -->
|
||||
<div class="section">
|
||||
<label class="field-label">Modus</label>
|
||||
<div class="mode-grid">
|
||||
<label class="mode-card" :class="{ selected: form.mode === 'blacklist' }">
|
||||
<input type="radio" v-model="form.mode" value="blacklist" class="hidden">
|
||||
<i data-lucide="shield-x"></i>
|
||||
<div>
|
||||
<div class="mode-name">Blacklist</div>
|
||||
<div class="mode-desc">Alle Länder erlaubt, <b>außer</b> den gesperrten.</div>
|
||||
</div>
|
||||
</label>
|
||||
<label class="mode-card" :class="{ selected: form.mode === 'whitelist' }">
|
||||
<input type="radio" v-model="form.mode" value="whitelist" class="hidden">
|
||||
<i data-lucide="shield-check"></i>
|
||||
<div>
|
||||
<div class="mode-name">Whitelist</div>
|
||||
<div class="mode-desc">Nur <b>erlaubte</b> Länder haben Zugang.</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Blocked Countries -->
|
||||
<div class="section" v-if="form.mode === 'blacklist'">
|
||||
<label class="field-label">Gesperrte Länder</label>
|
||||
<div class="country-picker-row">
|
||||
<div class="country-search-wrap">
|
||||
<input type="text" v-model="blockedSearch" placeholder="Land suchen..." class="field-input country-search-input">
|
||||
<div class="country-dropdown" v-if="blockedSearch.length >= 1">
|
||||
<button
|
||||
v-for="c in filteredBlockedCountries.slice(0, 8)"
|
||||
:key="c.code"
|
||||
class="country-option"
|
||||
@click.prevent="addCountryFromSelect('blocked', c.code)"
|
||||
>
|
||||
<img :src="`https://flagcdn.com/20x15/${c.code.toLowerCase()}.png`" :alt="c.code" class="option-flag">
|
||||
<span class="option-name">{{ c.name }}</span>
|
||||
<span class="option-code">{{ c.code }}</span>
|
||||
</button>
|
||||
<p v-if="filteredBlockedCountries.length === 0" class="no-results">Kein Land gefunden.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tags" v-if="form.blocked_countries.length">
|
||||
<span v-for="c in form.blocked_countries" :key="c" class="tag tag-red">
|
||||
<img :src="`https://flagcdn.com/16x12/${c.toLowerCase()}.png`" :alt="c" class="flag-img" @error="(e:any)=>e.target.style.display='none'">
|
||||
<span class="tag-name">{{ getCountryName(c) }}</span>
|
||||
<span class="tag-code">{{ c }}</span>
|
||||
<button @click="removeCountry('blocked', c)"><i data-lucide="x"></i></button>
|
||||
</span>
|
||||
</div>
|
||||
<p class="field-hint" v-else>Noch keine Länder gesperrt.</p>
|
||||
</div>
|
||||
|
||||
<!-- Allowed Countries -->
|
||||
<div class="section" v-if="form.mode === 'whitelist'">
|
||||
<label class="field-label">Erlaubte Länder</label>
|
||||
<div class="country-picker-row">
|
||||
<div class="country-search-wrap">
|
||||
<input type="text" v-model="allowedSearch" placeholder="Land suchen..." class="field-input country-search-input">
|
||||
<div class="country-dropdown" v-if="allowedSearch.length >= 1">
|
||||
<button
|
||||
v-for="c in filteredAllowedCountries.slice(0, 8)"
|
||||
:key="c.code"
|
||||
class="country-option"
|
||||
@click.prevent="addCountryFromSelect('allowed', c.code)"
|
||||
>
|
||||
<img :src="`https://flagcdn.com/20x15/${c.code.toLowerCase()}.png`" :alt="c.code" class="option-flag">
|
||||
<span class="option-name">{{ c.name }}</span>
|
||||
<span class="option-code">{{ c.code }}</span>
|
||||
</button>
|
||||
<p v-if="filteredAllowedCountries.length === 0" class="no-results">Kein Land gefunden.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tags" v-if="form.allowed_countries.length">
|
||||
<span v-for="c in form.allowed_countries" :key="c" class="tag tag-green">
|
||||
<img :src="`https://flagcdn.com/16x12/${c.toLowerCase()}.png`" :alt="c" class="flag-img" @error="(e:any)=>e.target.style.display='none'">
|
||||
<span class="tag-name">{{ getCountryName(c) }}</span>
|
||||
<span class="tag-code">{{ c }}</span>
|
||||
<button @click="removeCountry('allowed', c)"><i data-lucide="x"></i></button>
|
||||
</span>
|
||||
</div>
|
||||
<p class="field-hint" v-else>Noch keine Länder erlaubt.</p>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- VPN Block -->
|
||||
<div class="section">
|
||||
<div class="toggle-row">
|
||||
<div>
|
||||
<div class="toggle-label">VPN/Proxy blockieren</div>
|
||||
<div class="toggle-desc">Nutzer mit VPN oder Proxy werden gesperrt.</div>
|
||||
</div>
|
||||
<button class="toggle-btn" :class="{ active: form.vpn_block }" @click="form.vpn_block = !form.vpn_block">
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="form.vpn_block" class="vpn-settings">
|
||||
<div class="form-row">
|
||||
<div class="form-col">
|
||||
<label class="field-label">VPN-Anbieter</label>
|
||||
<select v-model="form.vpn_provider" class="field-input">
|
||||
<option value="none">Kein (deaktiviert)</option>
|
||||
<option value="ipqualityscore">IPQualityScore</option>
|
||||
<option value="proxycheck">ProxyCheck.io</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-col" v-if="form.vpn_provider !== 'none'">
|
||||
<label class="field-label">API-Schlüssel</label>
|
||||
<input type="text" v-model="form.vpn_api_key" placeholder="API Key..." class="field-input">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- Block Message & Redirect -->
|
||||
<div class="section">
|
||||
<div class="form-row">
|
||||
<div class="form-col full">
|
||||
<label class="field-label">Sperr-Nachricht</label>
|
||||
<textarea v-model="form.block_message" rows="3" class="field-input" placeholder="Diese Nachricht wird gesperrten Nutzern angezeigt."></textarea>
|
||||
</div>
|
||||
<div class="form-col full">
|
||||
<label class="field-label">Weiterleitungs-URL <span class="optional">(optional)</span></label>
|
||||
<input type="url" v-model="form.redirect_url" placeholder="https://example.com/gesperrt" class="field-input">
|
||||
<p class="field-hint">Wenn angegeben, werden gesperrte Nutzer dorthin weitergeleitet statt eine Fehlermeldung zu sehen.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-foot">
|
||||
<button class="btn-primary" @click="submit" :disabled="form.processing">
|
||||
<i data-lucide="save"></i>
|
||||
{{ form.processing ? 'Wird gespeichert...' : 'Einstellungen speichern' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CasinoAdminLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-wrap { max-width: 860px; margin: 0 auto; display: flex; flex-direction: column; gap: 20px; }
|
||||
|
||||
.alert-success {
|
||||
background: rgba(0, 200, 100, 0.1); border: 1px solid rgba(0, 200, 100, 0.3);
|
||||
color: #00c864; padding: 12px 16px; border-radius: 10px;
|
||||
display: flex; align-items: center; gap: 10px; font-weight: 600;
|
||||
}
|
||||
.alert-success i { width: 18px; height: 18px; flex-shrink: 0; }
|
||||
|
||||
.card { background: #0f0f10; border: 1px solid #18181b; border-radius: 14px; overflow: hidden; }
|
||||
.card-head {
|
||||
display: flex; align-items: flex-start; justify-content: space-between; gap: 16px;
|
||||
padding: 24px; border-bottom: 1px solid #18181b;
|
||||
}
|
||||
.card-title-group h2 { font-size: 20px; font-weight: 700; color: #fff; margin: 0 0 4px; }
|
||||
.card-subtitle { color: #71717a; font-size: 13px; margin: 0; }
|
||||
.card-foot { padding: 20px 24px; border-top: 1px solid #18181b; display: flex; justify-content: flex-end; }
|
||||
|
||||
.section { padding: 20px 24px; }
|
||||
.divider { height: 1px; background: #18181b; margin: 0; }
|
||||
.dimmed { opacity: 0.4; pointer-events: none; }
|
||||
|
||||
/* Toggle */
|
||||
.toggle-row { display: flex; align-items: center; justify-content: space-between; gap: 20px; }
|
||||
.toggle-label { font-weight: 600; color: #e4e4e7; font-size: 14px; }
|
||||
.toggle-desc { color: #71717a; font-size: 12px; margin-top: 2px; }
|
||||
.toggle-btn {
|
||||
width: 48px; height: 26px; border-radius: 99px; background: #27272a; border: none; cursor: pointer;
|
||||
position: relative; transition: background 0.2s; flex-shrink: 0;
|
||||
}
|
||||
.toggle-btn.active { background: #df006a; }
|
||||
.toggle-knob {
|
||||
position: absolute; top: 3px; left: 3px; width: 20px; height: 20px;
|
||||
background: #fff; border-radius: 50%; transition: transform 0.2s;
|
||||
}
|
||||
.toggle-btn.active .toggle-knob { transform: translateX(22px); }
|
||||
|
||||
/* Mode cards */
|
||||
.mode-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 10px; }
|
||||
.mode-card {
|
||||
display: flex; align-items: flex-start; gap: 14px; padding: 16px;
|
||||
background: #18181b; border: 2px solid #27272a; border-radius: 10px;
|
||||
cursor: pointer; transition: all 0.15s;
|
||||
}
|
||||
.mode-card:hover { border-color: #3f3f46; }
|
||||
.mode-card.selected { border-color: #df006a; background: rgba(223,0,106,0.06); }
|
||||
.mode-card i { width: 20px; height: 20px; color: #71717a; flex-shrink: 0; margin-top: 2px; }
|
||||
.mode-card.selected i { color: #df006a; }
|
||||
.mode-name { font-weight: 700; font-size: 14px; color: #e4e4e7; }
|
||||
.mode-desc { font-size: 12px; color: #71717a; margin-top: 2px; }
|
||||
|
||||
/* Fields */
|
||||
.field-label { display: block; font-size: 12px; font-weight: 700; color: #a1a1aa; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px; }
|
||||
.field-input {
|
||||
width: 100%; background: #18181b; border: 1px solid #27272a; border-radius: 8px;
|
||||
color: #e4e4e7; padding: 10px 14px; font-size: 13px; transition: border-color 0.15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.field-input:focus { outline: none; border-color: #df006a; }
|
||||
.code-input { width: 100px; text-transform: uppercase; letter-spacing: 2px; font-weight: 700; }
|
||||
.field-hint { font-size: 11px; color: #52525b; margin-top: 6px; }
|
||||
.optional { font-weight: 400; color: #52525b; text-transform: none; letter-spacing: 0; }
|
||||
|
||||
/* Country picker */
|
||||
.country-picker-row { margin-bottom: 12px; }
|
||||
.country-search-wrap { position: relative; }
|
||||
.country-search-input { width: 100%; }
|
||||
.country-dropdown {
|
||||
position: absolute; top: calc(100% + 4px); left: 0; right: 0; z-index: 100;
|
||||
background: #18181b; border: 1px solid #3f3f46; border-radius: 10px;
|
||||
overflow: hidden; box-shadow: 0 8px 24px rgba(0,0,0,0.5);
|
||||
}
|
||||
.country-option {
|
||||
display: flex; align-items: center; gap: 10px; width: 100%;
|
||||
padding: 9px 14px; background: transparent; border: none;
|
||||
color: #e4e4e7; cursor: pointer; text-align: left; transition: background 0.1s;
|
||||
}
|
||||
.country-option:hover { background: rgba(223,0,106,0.1); }
|
||||
.option-flag { width: 20px; height: 15px; object-fit: cover; border-radius: 2px; flex-shrink: 0; }
|
||||
.option-name { flex: 1; font-size: 13px; }
|
||||
.option-code { font-size: 11px; color: #71717a; font-weight: 700; letter-spacing: 1px; }
|
||||
.no-results { color: #52525b; font-size: 12px; padding: 10px 14px; margin: 0; }
|
||||
|
||||
.tags { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.tag {
|
||||
display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px; border-radius: 6px;
|
||||
font-size: 12px; font-weight: 600;
|
||||
}
|
||||
.tag-name { font-size: 12px; }
|
||||
.tag-code { font-size: 10px; opacity: 0.6; letter-spacing: 1px; font-weight: 700; }
|
||||
.tag button { background: transparent; border: none; cursor: pointer; display: flex; align-items: center; padding: 0; }
|
||||
.tag button i { width: 12px; height: 12px; }
|
||||
.flag-img { width: 16px; height: 12px; object-fit: cover; border-radius: 2px; flex-shrink: 0; }
|
||||
.tag-red { background: rgba(255,62,62,0.1); color: #ff3e3e; border: 1px solid rgba(255,62,62,0.25); }
|
||||
.tag-red button { color: #ff3e3e; }
|
||||
.tag-green { background: rgba(0,200,100,0.1); color: #00c864; border: 1px solid rgba(0,200,100,0.25); }
|
||||
.tag-green button { color: #00c864; }
|
||||
|
||||
/* VPN Settings */
|
||||
.vpn-settings { margin-top: 16px; }
|
||||
|
||||
/* Form layout */
|
||||
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 8px; }
|
||||
.form-col.full { grid-column: 1 / -1; }
|
||||
|
||||
/* Primary button */
|
||||
.btn-primary {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
background: #df006a; border: none; color: #fff; padding: 10px 20px;
|
||||
border-radius: 8px; font-weight: 700; font-size: 13px; cursor: pointer; transition: all 0.15s;
|
||||
}
|
||||
.btn-primary:hover { background: #b8005a; }
|
||||
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-primary i { width: 16px; height: 16px; }
|
||||
|
||||
.hidden { display: none; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.mode-grid { grid-template-columns: 1fr; }
|
||||
.form-row { grid-template-columns: 1fr; }
|
||||
.card-head { flex-direction: column; }
|
||||
}
|
||||
</style>
|
||||
297
resources/js/pages/Admin/PaymentsSettings.vue
Normal file
297
resources/js/pages/Admin/PaymentsSettings.vue
Normal file
@@ -0,0 +1,297 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, useForm } from '@inertiajs/vue3';
|
||||
import { ref, computed, onMounted, nextTick } from 'vue';
|
||||
import AdminLayout from '@/layouts/admin/CasinoAdminLayout.vue';
|
||||
import { csrfFetch } from '@/utils/csrfFetch';
|
||||
|
||||
const props = defineProps<{
|
||||
settings: {
|
||||
mode: 'live'|'sandbox';
|
||||
api_key?: string;
|
||||
ipn_secret?: string;
|
||||
address_mode?: string;
|
||||
enabled_currencies: string[];
|
||||
global_min_usd: number;
|
||||
global_max_usd: number;
|
||||
btx_per_usd: number;
|
||||
per_currency_overrides: Record<string, { min_usd?: number|null; max_usd?: number|null; btx_per_usd?: number|null }>;
|
||||
success_url: string;
|
||||
cancel_url: string;
|
||||
};
|
||||
defaults: {
|
||||
commonCurrencies: string[];
|
||||
modes: string[];
|
||||
addressModes: string[];
|
||||
}
|
||||
}>();
|
||||
|
||||
const allCurrencies = ref<string[]>(props.defaults.commonCurrencies);
|
||||
const addCurrency = ref('');
|
||||
const showApiKey = ref(false);
|
||||
const showIpnSecret = ref(false);
|
||||
const testStatus = ref<'idle'|'loading'|'ok'|'fail'>('idle');
|
||||
const testMessage = ref('');
|
||||
|
||||
const form = useForm({
|
||||
mode: props.settings.mode || 'live',
|
||||
api_key: props.settings.api_key || '',
|
||||
ipn_secret: props.settings.ipn_secret || '',
|
||||
address_mode: props.settings.address_mode || 'per_payment',
|
||||
enabled_currencies: [...(props.settings.enabled_currencies || [])],
|
||||
global_min_usd: props.settings.global_min_usd ?? 10,
|
||||
global_max_usd: props.settings.global_max_usd ?? 10000,
|
||||
btx_per_usd: props.settings.btx_per_usd ?? 1.0,
|
||||
per_currency_overrides: { ...(props.settings.per_currency_overrides || {}) },
|
||||
success_url: props.settings.success_url || '/wallet?deposit=success',
|
||||
cancel_url: props.settings.cancel_url || '/wallet?deposit=cancel',
|
||||
});
|
||||
|
||||
const pickList = computed(() => {
|
||||
const set = new Set(form.enabled_currencies);
|
||||
return allCurrencies.value.filter(c => !set.has(c));
|
||||
});
|
||||
|
||||
function addToWhitelist(cur: string) {
|
||||
const up = cur.toUpperCase();
|
||||
if (!form.enabled_currencies.includes(up)) form.enabled_currencies.push(up);
|
||||
addCurrency.value = '';
|
||||
}
|
||||
|
||||
function removeFromWhitelist(cur: string) {
|
||||
form.enabled_currencies = form.enabled_currencies.filter(c => c !== cur);
|
||||
if (form.per_currency_overrides[cur]) {
|
||||
const rest: any = { ...(form.per_currency_overrides as any) };
|
||||
delete rest[cur];
|
||||
form.per_currency_overrides = rest as any;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureOverride(cur: string) {
|
||||
if (!form.per_currency_overrides[cur]) {
|
||||
form.per_currency_overrides[cur] = { min_usd: null, max_usd: null, btx_per_usd: null } as any;
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
await form.post('/admin/payments/settings', { preserveScroll: true });
|
||||
}
|
||||
|
||||
async function testConnection() {
|
||||
testStatus.value = 'loading';
|
||||
testMessage.value = '';
|
||||
try {
|
||||
const res = await csrfFetch('/admin/payments/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||
body: JSON.stringify({ api_key: form.api_key }),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (res.ok && json.ok) {
|
||||
testStatus.value = 'ok';
|
||||
testMessage.value = json.message || 'Verbindung erfolgreich!';
|
||||
} else {
|
||||
testStatus.value = 'fail';
|
||||
testMessage.value = json.message || 'Verbindung fehlgeschlagen.';
|
||||
}
|
||||
} catch {
|
||||
testStatus.value = 'fail';
|
||||
testMessage.value = 'Netzwerkfehler.';
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => { if ((window as any).lucide) (window as any).lucide.createIcons(); });
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminLayout>
|
||||
<Head title="Admin – Payment Settings" />
|
||||
|
||||
<section class="content">
|
||||
<div class="wrap">
|
||||
<div class="panel">
|
||||
<header class="page-head">
|
||||
<div class="head-flex">
|
||||
<div class="title-group">
|
||||
<div class="title">NOWPayments – Einstellungen</div>
|
||||
<p class="subtitle">Konfiguriere Live‑Modus, Coins‑Whitelist, Limits und BTX‑Kurs.</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn primary" @click="submit" :disabled="form.processing">
|
||||
<i data-lucide="save"></i>
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- API Credentials -->
|
||||
<div class="section-title">API Zugangsdaten</div>
|
||||
<div class="form-grid">
|
||||
<div class="form-item full">
|
||||
<label class="lbl">NOWPayments API Key</label>
|
||||
<div class="secret-row">
|
||||
<input :type="showApiKey ? 'text' : 'password'" v-model="form.api_key" placeholder="Dein API Key..." />
|
||||
<button type="button" class="btn icon-btn" @click="showApiKey = !showApiKey">
|
||||
<i :data-lucide="showApiKey ? 'eye-off' : 'eye'"></i>
|
||||
</button>
|
||||
<button type="button" class="btn" :class="testStatus === 'ok' ? 'success' : testStatus === 'fail' ? 'danger' : ''" @click="testConnection" :disabled="!form.api_key || testStatus === 'loading'">
|
||||
<i data-lucide="zap"></i>
|
||||
{{ testStatus === 'loading' ? 'Teste...' : 'Verbindung testen' }}
|
||||
</button>
|
||||
</div>
|
||||
<small v-if="testMessage" :class="testStatus === 'ok' ? 'text-green' : 'text-red'">{{ testMessage }}</small>
|
||||
</div>
|
||||
|
||||
<div class="form-item full">
|
||||
<label class="lbl">IPN Secret (Webhook-Signatur)</label>
|
||||
<div class="secret-row">
|
||||
<input :type="showIpnSecret ? 'text' : 'password'" v-model="form.ipn_secret" placeholder="Dein IPN Secret..." />
|
||||
<button type="button" class="btn icon-btn" @click="showIpnSecret = !showIpnSecret">
|
||||
<i :data-lucide="showIpnSecret ? 'eye-off' : 'eye'"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hr"></div>
|
||||
|
||||
<!-- General Settings -->
|
||||
<div class="section-title">Allgemein</div>
|
||||
<div class="form-grid">
|
||||
<div class="form-item">
|
||||
<label class="lbl">Modus</label>
|
||||
<select v-model="form.mode">
|
||||
<option v-for="m in props.defaults.modes" :key="m" :value="m">{{ m.toUpperCase() }}</option>
|
||||
</select>
|
||||
<small>Für Live‑Betrieb auf <b>LIVE</b> stellen.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-item">
|
||||
<label class="lbl">Adress-Modus</label>
|
||||
<select v-model="form.address_mode">
|
||||
<option value="per_payment">Per Zahlung (neu je Transaktion)</option>
|
||||
<option value="per_user">Per Nutzer (fixe Adresse)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-item">
|
||||
<label class="lbl">Globales Minimum (USD/BTX)</label>
|
||||
<input type="number" step="0.01" v-model.number="form.global_min_usd" />
|
||||
</div>
|
||||
|
||||
<div class="form-item">
|
||||
<label class="lbl">Globales Maximum (USD/BTX)</label>
|
||||
<input type="number" step="0.01" v-model.number="form.global_max_usd" />
|
||||
</div>
|
||||
|
||||
<div class="form-item">
|
||||
<label class="lbl">BTX pro USD (global)</label>
|
||||
<input type="number" step="0.00000001" v-model.number="form.btx_per_usd" />
|
||||
<small>Standard 1.0 (1 BTX = 1 USD). Overrides je Währung optional unten.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-item"><!-- spacer --></div>
|
||||
|
||||
<div class="form-item">
|
||||
<label class="lbl">Success URL</label>
|
||||
<input type="text" v-model="form.success_url" />
|
||||
</div>
|
||||
|
||||
<div class="form-item">
|
||||
<label class="lbl">Cancel URL</label>
|
||||
<input type="text" v-model="form.cancel_url" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hr"></div>
|
||||
|
||||
<h3>Coins‑Whitelist</h3>
|
||||
<div class="whitelist">
|
||||
<div class="tags">
|
||||
<span v-for="cur in form.enabled_currencies" :key="cur" class="tag">
|
||||
{{ cur }}
|
||||
<button type="button" class="x" @click="removeFromWhitelist(cur)">×</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="add-row">
|
||||
<select v-model="addCurrency">
|
||||
<option value="" disabled>+ Währung hinzufügen</option>
|
||||
<option v-for="cur in pickList" :key="cur" :value="cur">{{ cur }}</option>
|
||||
</select>
|
||||
<button class="btn" :disabled="!addCurrency" @click="addToWhitelist(addCurrency)">Hinzufügen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hr"></div>
|
||||
|
||||
<h3>Per‑Währung Overrides</h3>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Währung</th>
|
||||
<th>Min USD</th>
|
||||
<th>Max USD</th>
|
||||
<th>BTX pro USD</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="cur in form.enabled_currencies" :key="cur">
|
||||
<td>{{ cur }}</td>
|
||||
<td>
|
||||
<input type="number" step="0.01"
|
||||
:value="form.per_currency_overrides[cur]?.min_usd ?? ''"
|
||||
@input="(e:any)=>{ ensureOverride(cur); form.per_currency_overrides[cur].min_usd = e.target.value ? parseFloat(e.target.value) : null }" />
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" step="0.01"
|
||||
:value="form.per_currency_overrides[cur]?.max_usd ?? ''"
|
||||
@input="(e:any)=>{ ensureOverride(cur); form.per_currency_overrides[cur].max_usd = e.target.value ? parseFloat(e.target.value) : null }" />
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" step="0.00000001"
|
||||
:value="form.per_currency_overrides[cur]?.btx_per_usd ?? ''"
|
||||
@input="(e:any)=>{ ensureOverride(cur); form.per_currency_overrides[cur].btx_per_usd = e.target.value ? parseFloat(e.target.value) : null }" />
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn small" @click="() => { const o=form.per_currency_overrides[cur]; if(o){o.min_usd=null;o.max_usd=null;o.btx_per_usd=null;} }">Zurücksetzen</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="foot">
|
||||
<button class="btn primary" @click="submit" :disabled="form.processing">
|
||||
<i data-lucide="save"></i>
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.content { padding: 20px; }
|
||||
.wrap { max-width: 1100px; margin: 0 auto; }
|
||||
.panel { background: #0f0f10; border: 1px solid #18181b; border-radius: 12px; padding: 16px; }
|
||||
.page-head .title { font-size: 22px; font-weight: 700; }
|
||||
.subtitle { color: #a1a1aa; margin-top: 4px; }
|
||||
.actions { display: flex; gap: 10px; }
|
||||
.form-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; margin-top: 12px; }
|
||||
.form-item .lbl { display: block; font-weight: 600; margin-bottom: 6px; }
|
||||
input, select { width: 100%; background: #0b0b0c; border: 1px solid #1f1f22; border-radius: 8px; padding: 10px; color: #e5e7eb; }
|
||||
.btn { background: #1f2937; border: 1px solid #374151; color: #e5e7eb; padding: 8px 14px; border-radius: 8px; cursor: pointer; }
|
||||
.btn.primary { background: #ff007a; border-color: #ff2b8f; color: white; }
|
||||
.btn.small { padding: 6px 10px; font-size: 12px; }
|
||||
.hr { height: 1px; background: #1f1f22; margin: 16px 0; }
|
||||
.whitelist .tags { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.tag { background: #18181b; border: 1px solid #27272a; padding: 6px 10px; border-radius: 999px; display: inline-flex; align-items: center; gap: 6px; }
|
||||
.tag .x { background: transparent; border: 0; color: #aaa; cursor: pointer; }
|
||||
.table { width: 100%; border-collapse: collapse; }
|
||||
.table th, .table td { border-bottom: 1px solid #1f1f22; padding: 10px; text-align: left; }
|
||||
.foot { display: flex; justify-content: flex-end; margin-top: 16px; }
|
||||
</style>
|
||||
1007
resources/js/pages/Admin/ProfileReportShow.vue
Normal file
1007
resources/js/pages/Admin/ProfileReportShow.vue
Normal file
File diff suppressed because it is too large
Load Diff
359
resources/js/pages/Admin/ProfileReports.vue
Normal file
359
resources/js/pages/Admin/ProfileReports.vue
Normal file
@@ -0,0 +1,359 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, router } from '@inertiajs/vue3';
|
||||
import { ref, computed } from 'vue';
|
||||
import CasinoAdminLayout from '@/layouts/admin/CasinoAdminLayout.vue';
|
||||
import {
|
||||
Search, Flag, Clock, CheckCircle2, XCircle, ChevronRight, UserRound, Eye
|
||||
} from 'lucide-vue-next';
|
||||
|
||||
interface UserRef {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
avatar: string | null;
|
||||
avatar_url: string | null;
|
||||
role?: string;
|
||||
vip_level?: number;
|
||||
}
|
||||
|
||||
interface Report {
|
||||
id: number;
|
||||
reporter: UserRef;
|
||||
profile: UserRef;
|
||||
reason: string;
|
||||
details: string | null;
|
||||
status: 'pending' | 'reviewed' | 'dismissed';
|
||||
admin_note: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
reports: { data: Report[]; links: any[]; meta: any };
|
||||
filters: { status?: string; search?: string };
|
||||
stats: { total: number; pending: number; reviewed: number; dismissed: number };
|
||||
}>();
|
||||
|
||||
const filterStatus = ref(props.filters.status ?? 'pending');
|
||||
const searchInput = ref(props.filters.search ?? '');
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function applyFilter() {
|
||||
router.get('/admin/reports/profiles', {
|
||||
status: filterStatus.value || undefined,
|
||||
search: searchInput.value || undefined,
|
||||
}, { preserveScroll: true });
|
||||
}
|
||||
|
||||
function onSearchInput() {
|
||||
if (searchTimer) clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(applyFilter, 400);
|
||||
}
|
||||
|
||||
function setStatus(s: string) {
|
||||
filterStatus.value = s;
|
||||
applyFilter();
|
||||
}
|
||||
|
||||
function openReport(id: number) {
|
||||
router.visit(`/admin/reports/profiles/${id}`);
|
||||
}
|
||||
|
||||
function avatarSrc(u: UserRef | null) {
|
||||
if (!u) return null;
|
||||
return u.avatar_url || u.avatar || null;
|
||||
}
|
||||
function initials(name?: string) { return (name || '?')[0].toUpperCase(); }
|
||||
function fmt(d: string) {
|
||||
return new Date(d).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
const reasonLabels: Record<string, string> = {
|
||||
spam: 'Spam', harassment: 'Belästigung', inappropriate: 'Unangemessen',
|
||||
fake: 'Fake', other: 'Sonstiges',
|
||||
};
|
||||
function rl(r: string) { return reasonLabels[r] ?? r; }
|
||||
|
||||
const statusMeta: Record<string, { color: string; bg: string; border: string; label: string }> = {
|
||||
pending: { color: '#f59e0b', bg: 'rgba(245,158,11,0.12)', border: 'rgba(245,158,11,0.3)', label: 'Pending' },
|
||||
reviewed: { color: '#22c55e', bg: 'rgba(34,197,94,0.12)', border: 'rgba(34,197,94,0.3)', label: 'Reviewed' },
|
||||
dismissed: { color: '#6b7280', bg: 'rgba(107,114,128,0.1)', border: 'rgba(107,114,128,0.25)', label: 'Dismissed' },
|
||||
};
|
||||
|
||||
const showingLabel = computed(() => {
|
||||
if (!filterStatus.value || filterStatus.value === 'all') return 'Alle';
|
||||
return statusMeta[filterStatus.value]?.label ?? filterStatus.value;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CasinoAdminLayout>
|
||||
<Head title="Admin – Profil Reports" />
|
||||
<template #title>Profil Reports</template>
|
||||
|
||||
<!-- ── Stats row ── -->
|
||||
<div class="stats-row">
|
||||
<button class="stat-card" :class="{ active: !filterStatus || filterStatus === 'all' }" @click="setStatus('all')">
|
||||
<UserRound :size="16" class="sc-icon all" />
|
||||
<div class="sc-body">
|
||||
<span class="sc-val">{{ stats.total }}</span>
|
||||
<span class="sc-label">Gesamt</span>
|
||||
</div>
|
||||
</button>
|
||||
<button class="stat-card" :class="{ active: filterStatus === 'pending' }" @click="setStatus('pending')">
|
||||
<Clock :size="16" class="sc-icon pending" />
|
||||
<div class="sc-body">
|
||||
<span class="sc-val pending">{{ stats.pending }}</span>
|
||||
<span class="sc-label">Offen</span>
|
||||
</div>
|
||||
<span v-if="stats.pending > 0" class="sc-dot"></span>
|
||||
</button>
|
||||
<button class="stat-card" :class="{ active: filterStatus === 'reviewed' }" @click="setStatus('reviewed')">
|
||||
<CheckCircle2 :size="16" class="sc-icon reviewed" />
|
||||
<div class="sc-body">
|
||||
<span class="sc-val reviewed">{{ stats.reviewed }}</span>
|
||||
<span class="sc-label">Erledigt</span>
|
||||
</div>
|
||||
</button>
|
||||
<button class="stat-card" :class="{ active: filterStatus === 'dismissed' }" @click="setStatus('dismissed')">
|
||||
<XCircle :size="16" class="sc-icon dismissed" />
|
||||
<div class="sc-body">
|
||||
<span class="sc-val dismissed">{{ stats.dismissed }}</span>
|
||||
<span class="sc-label">Abgelehnt</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ── Toolbar ── -->
|
||||
<div class="toolbar">
|
||||
<div class="search-wrap">
|
||||
<Search :size="14" class="search-icon" />
|
||||
<input
|
||||
v-model="searchInput"
|
||||
@input="onSearchInput"
|
||||
type="text"
|
||||
placeholder="Case-Nr. oder Username suchen…"
|
||||
class="search-input"
|
||||
/>
|
||||
</div>
|
||||
<div class="showing-pill">
|
||||
<Eye :size="12" />
|
||||
{{ showingLabel }}
|
||||
<span class="showing-count">{{ reports.meta?.total ?? reports.data.length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── List ── -->
|
||||
<div class="reports-list">
|
||||
<div v-if="!reports.data.length" class="empty-state">
|
||||
<Flag :size="28" class="empty-icon-svg" />
|
||||
<div class="empty-text">Keine Reports gefunden</div>
|
||||
<div class="empty-sub" v-if="searchInput">Für „{{ searchInput }}" wurden keine Ergebnisse gefunden.</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="r in reports.data"
|
||||
:key="r.id"
|
||||
class="report-card"
|
||||
:class="`status-${r.status}`"
|
||||
@click="openReport(r.id)"
|
||||
>
|
||||
<div class="card-inner">
|
||||
<!-- Case badge -->
|
||||
<div class="case-badge">#{{ r.id }}</div>
|
||||
|
||||
<!-- Reporter -->
|
||||
<div class="user-block">
|
||||
<div class="av-wrap reporter-av">
|
||||
<img v-if="avatarSrc(r.reporter)" :src="avatarSrc(r.reporter)!" class="av-img" />
|
||||
<span v-else class="av-fb">{{ initials(r.reporter?.username) }}</span>
|
||||
</div>
|
||||
<div class="user-meta">
|
||||
<span class="meta-label">Melder</span>
|
||||
<span class="meta-name">@{{ r.reporter?.username }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sep-arrow">→</div>
|
||||
|
||||
<!-- Reported -->
|
||||
<div class="user-block">
|
||||
<div class="av-wrap reported-av">
|
||||
<img v-if="avatarSrc(r.profile)" :src="avatarSrc(r.profile)!" class="av-img" />
|
||||
<span v-else class="av-fb">{{ initials(r.profile?.username) }}</span>
|
||||
</div>
|
||||
<div class="user-meta">
|
||||
<span class="meta-label">Gemeldet</span>
|
||||
<span class="meta-name">@{{ r.profile?.username }}</span>
|
||||
<span class="role-tag" v-if="r.profile?.role">{{ r.profile.role }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reason -->
|
||||
<span class="reason-chip">{{ rl(r.reason) }}</span>
|
||||
|
||||
<!-- Details snippet -->
|
||||
<span v-if="r.details" class="detail-snip">{{ r.details }}</span>
|
||||
|
||||
<!-- Right meta -->
|
||||
<div class="right-meta">
|
||||
<span class="status-chip"
|
||||
:style="{ color: statusMeta[r.status].color, background: statusMeta[r.status].bg, borderColor: statusMeta[r.status].border }"
|
||||
>{{ statusMeta[r.status].label }}</span>
|
||||
<span class="ts">{{ fmt(r.created_at) }}</span>
|
||||
<ChevronRight :size="14" class="chevron" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Pagination ── -->
|
||||
<div class="pagination" v-if="reports.links && reports.links.length > 3">
|
||||
<template v-for="link in reports.links" :key="link.label">
|
||||
<button
|
||||
class="page-btn"
|
||||
:class="{ active: link.active, disabled: !link.url }"
|
||||
:disabled="!link.url"
|
||||
@click="link.url && router.visit(link.url)"
|
||||
v-html="link.label"
|
||||
></button>
|
||||
</template>
|
||||
</div>
|
||||
</CasinoAdminLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ── Stats ── */
|
||||
.stats-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 10px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.stat-card {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
background: #111113; border: 1px solid #1e1e21; border-radius: 12px;
|
||||
padding: 14px 16px; cursor: pointer; transition: .15s; position: relative;
|
||||
text-align: left;
|
||||
}
|
||||
.stat-card:hover { border-color: #2a2a2f; background: #141416; }
|
||||
.stat-card.active { border-color: #333; background: #161618; }
|
||||
.sc-icon { flex-shrink: 0; }
|
||||
.sc-icon.all { color: #888; }
|
||||
.sc-icon.pending { color: #f59e0b; }
|
||||
.sc-icon.reviewed { color: #22c55e; }
|
||||
.sc-icon.dismissed { color: #6b7280; }
|
||||
.sc-body { display: flex; flex-direction: column; gap: 1px; }
|
||||
.sc-val { font-size: 22px; font-weight: 900; color: #fff; line-height: 1; }
|
||||
.sc-val.pending { color: #f59e0b; }
|
||||
.sc-val.reviewed { color: #22c55e; }
|
||||
.sc-val.dismissed { color: #6b7280; }
|
||||
.sc-label { font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: .5px; font-weight: 700; }
|
||||
.sc-dot {
|
||||
position: absolute; top: 10px; right: 10px;
|
||||
width: 7px; height: 7px; border-radius: 50%; background: #f59e0b;
|
||||
box-shadow: 0 0 6px rgba(245,158,11,.6);
|
||||
animation: pulse-dot 1.5s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse-dot {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: .5; transform: scale(.7); }
|
||||
}
|
||||
|
||||
/* ── Toolbar ── */
|
||||
.toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
|
||||
.search-wrap { flex: 1; position: relative; max-width: 380px; }
|
||||
.search-icon { position: absolute; left: 11px; top: 50%; transform: translateY(-50%); color: #444; pointer-events: none; }
|
||||
.search-input {
|
||||
width: 100%; background: #111113; border: 1px solid #1e1e21; border-radius: 9px;
|
||||
color: #ccc; font-size: 13px; padding: 9px 12px 9px 34px; outline: none; transition: .15s;
|
||||
}
|
||||
.search-input::placeholder { color: #3a3a3f; }
|
||||
.search-input:focus { border-color: #333; }
|
||||
.showing-pill {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
background: #111113; border: 1px solid #1e1e21; border-radius: 20px;
|
||||
padding: 6px 12px; font-size: 12px; color: #555; margin-left: auto;
|
||||
}
|
||||
.showing-count {
|
||||
background: #1a1a1c; border: 1px solid #252528; border-radius: 10px;
|
||||
padding: 1px 7px; color: #888; font-size: 11px; font-weight: 700;
|
||||
}
|
||||
|
||||
/* ── List ── */
|
||||
.reports-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.empty-state { text-align: center; padding: 60px 20px; background: #111113; border: 1px solid #1e1e21; border-radius: 14px; }
|
||||
.empty-icon-svg { color: #2a2a2f; margin: 0 auto 12px; }
|
||||
.empty-text { color: #555; font-size: 14px; font-weight: 700; }
|
||||
.empty-sub { color: #3a3a3f; font-size: 12px; margin-top: 4px; }
|
||||
|
||||
.report-card {
|
||||
background: #111113; border: 1px solid #1e1e21; border-radius: 12px;
|
||||
overflow: hidden; cursor: pointer; transition: .15s;
|
||||
}
|
||||
.report-card:hover { background: #141416; border-color: #272729; }
|
||||
.report-card.status-pending { border-left: 3px solid #f59e0b; }
|
||||
.report-card.status-reviewed { border-left: 3px solid #22c55e; opacity: .65; }
|
||||
.report-card.status-dismissed { border-left: 3px solid #252528; opacity: .45; }
|
||||
|
||||
.card-inner {
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
padding: 12px 16px; flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.case-badge {
|
||||
font-size: 11px; font-weight: 800; color: #666;
|
||||
background: #161618; border: 1px solid #252528;
|
||||
padding: 3px 9px; border-radius: 6px; white-space: nowrap; flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-block { display: flex; align-items: center; gap: 9px; }
|
||||
.av-wrap { width: 34px; height: 34px; border-radius: 50%; overflow: hidden; flex-shrink: 0; }
|
||||
.av-img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.av-fb {
|
||||
width: 34px; height: 34px; border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 13px; font-weight: 800; border: 1px solid;
|
||||
}
|
||||
.reporter-av .av-fb { background: rgba(59,130,246,.12); color: #3b82f6; border-color: rgba(59,130,246,.25); }
|
||||
.reported-av .av-fb { background: rgba(223,0,106,.12); color: #df006a; border-color: rgba(223,0,106,.25); }
|
||||
|
||||
.user-meta { display: flex; flex-direction: column; gap: 1px; }
|
||||
.meta-label { font-size: 9px; color: #555; text-transform: uppercase; letter-spacing: .4px; }
|
||||
.meta-name { font-size: 12px; font-weight: 700; color: #ddd; white-space: nowrap; }
|
||||
.role-tag { font-size: 9px; color: #666; background: #161618; padding: 1px 5px; border-radius: 4px; border: 1px solid #252528; width: fit-content; }
|
||||
|
||||
.sep-arrow { color: #333; font-size: 16px; flex-shrink: 0; }
|
||||
|
||||
.reason-chip {
|
||||
font-size: 10px; font-weight: 700; color: #f59e0b;
|
||||
background: rgba(245,158,11,.08); border: 1px solid rgba(245,158,11,.25);
|
||||
padding: 3px 10px; border-radius: 20px; white-space: nowrap; flex-shrink: 0;
|
||||
}
|
||||
|
||||
.detail-snip {
|
||||
font-size: 11px; color: #555; flex: 1; min-width: 0;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
|
||||
.right-meta { margin-left: auto; display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
|
||||
.status-chip {
|
||||
font-size: 10px; font-weight: 800; padding: 3px 9px;
|
||||
border-radius: 20px; border: 1px solid;
|
||||
text-transform: uppercase; letter-spacing: .4px; white-space: nowrap;
|
||||
}
|
||||
.ts { font-size: 11px; color: #444; white-space: nowrap; }
|
||||
.chevron { color: #333; flex-shrink: 0; }
|
||||
|
||||
/* ── Pagination ── */
|
||||
.pagination { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 18px; justify-content: center; }
|
||||
.page-btn {
|
||||
background: #111; border: 1px solid #222; color: #ccc;
|
||||
padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 12px; transition: .2s;
|
||||
}
|
||||
.page-btn.active { background: #df006a; border-color: #df006a; color: #fff; }
|
||||
.page-btn.disabled { opacity: .4; cursor: default; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.stats-row { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
</style>
|
||||
137
resources/js/pages/Admin/Promos.vue
Normal file
137
resources/js/pages/Admin/Promos.vue
Normal file
@@ -0,0 +1,137 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, router } from '@inertiajs/vue3';
|
||||
import { ref } from 'vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import UserLayout from '@/layouts/admin/CasinoAdminLayout.vue';
|
||||
|
||||
const props = defineProps<{ promos: any }>();
|
||||
|
||||
const form = ref({
|
||||
code: '',
|
||||
description: '',
|
||||
bonus_amount: 0,
|
||||
wager_multiplier: 30,
|
||||
per_user_limit: 1,
|
||||
global_limit: null as number | null,
|
||||
starts_at: '',
|
||||
ends_at: '',
|
||||
min_deposit: null as number | null,
|
||||
bonus_expires_days: 7 as number | null,
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
function toUtcIso(dt: string): string | null {
|
||||
if (!dt) return null;
|
||||
// Interpret the datetime-local as local time and convert to UTC ISO string
|
||||
const d = new Date(dt);
|
||||
if (isNaN(d.getTime())) return null;
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
function submitCreate() {
|
||||
const payload = {
|
||||
...form.value,
|
||||
starts_at: toUtcIso(form.value.starts_at),
|
||||
ends_at: toUtcIso(form.value.ends_at),
|
||||
} as any;
|
||||
router.post('/admin/promos', payload, {
|
||||
preserveScroll: true,
|
||||
});
|
||||
}
|
||||
|
||||
function updatePromo(id: number, data: Record<string, any>) {
|
||||
const payload: Record<string, any> = { ...data };
|
||||
if (typeof payload.starts_at === 'string') payload.starts_at = toUtcIso(payload.starts_at);
|
||||
if (typeof payload.ends_at === 'string') payload.ends_at = toUtcIso(payload.ends_at);
|
||||
router.patch(`/admin/promos/${id}`, payload, { preserveScroll: true });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UserLayout>
|
||||
<Head title="Admin · Promos" />
|
||||
|
||||
<div class="p-4 sm:p-6 lg:p-8">
|
||||
<h1 class="text-2xl font-bold text-white mb-6">Admin · Promos</h1>
|
||||
|
||||
<div class="grid gap-8 md:grid-cols-2">
|
||||
<!-- Create new promo -->
|
||||
<div class="rounded-lg border border-[#1f1f1f] p-4 space-y-3 bg-[#0f0f0f]">
|
||||
<h2 class="font-semibold text-lg text-white">Neue Promo erstellen</h2>
|
||||
<form class="space-y-3" @submit.prevent="submitCreate">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<label class="space-y-1">
|
||||
<span class="text-sm text-gray-300">Code</span>
|
||||
<Input v-model="form.code" placeholder="WELCOME10" required />
|
||||
</label>
|
||||
<label class="space-y-1">
|
||||
<span class="text-sm text-gray-300">Bonus Betrag</span>
|
||||
<Input v-model.number="form.bonus_amount" type="number" min="0" step="0.0001" required />
|
||||
</label>
|
||||
<label class="space-y-1">
|
||||
<span class="text-sm text-gray-300">Wager x</span>
|
||||
<Input v-model.number="form.wager_multiplier" type="number" min="0" />
|
||||
</label>
|
||||
<label class="space-y-1">
|
||||
<span class="text-sm text-gray-300">Pro User Limit</span>
|
||||
<Input v-model.number="form.per_user_limit" type="number" min="1" />
|
||||
</label>
|
||||
<label class="space-y-1">
|
||||
<span class="text-sm text-gray-300">Globales Limit</span>
|
||||
<Input v-model.number="form.global_limit" type="number" min="1" placeholder="optional" />
|
||||
</label>
|
||||
<label class="space-y-1">
|
||||
<span class="text-sm text-gray-300">Min. Einzahlung</span>
|
||||
<Input v-model.number="form.min_deposit" type="number" min="0" step="0.0001" placeholder="optional" />
|
||||
</label>
|
||||
<label class="space-y-1">
|
||||
<span class="text-sm text-gray-300">Startet am</span>
|
||||
<Input v-model="form.starts_at" type="datetime-local" />
|
||||
</label>
|
||||
<label class="space-y-1">
|
||||
<span class="text-sm text-gray-300">Endet am</span>
|
||||
<Input v-model="form.ends_at" type="datetime-local" />
|
||||
</label>
|
||||
<label class="space-y-1">
|
||||
<span class="text-sm text-gray-300">Bonus Ablauf (Tage)</span>
|
||||
<Input v-model.number="form.bonus_expires_days" type="number" min="1" placeholder="optional" />
|
||||
</label>
|
||||
</div>
|
||||
<label class="inline-flex items-center gap-2 text-gray-300">
|
||||
<input v-model="form.is_active" type="checkbox" />
|
||||
<span>Aktiv</span>
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<Button type="submit">Erstellen</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- List promos -->
|
||||
<div class="rounded-lg border border-[#1f1f1f] p-4 space-y-4 bg-[#0f0f0f]">
|
||||
<h2 class="font-semibold text-lg text-white">Promos</h2>
|
||||
<div v-if="props.promos?.data?.length" class="space-y-3">
|
||||
<div v-for="p in props.promos.data" :key="p.id" class="rounded-md border border-[#1f1f1f] bg-[#141414] p-3 flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-mono text-sm px-2 py-0.5 rounded bg-[#1f1f1f] text-gray-200">{{ p.code }}</span>
|
||||
<span class="text-xs text-gray-400">Bonus: {{ p.bonus_amount }} · Wager x{{ p.wager_multiplier }}</span>
|
||||
<span class="ml-auto text-xs" :class="p.is_active ? 'text-emerald-500' : 'text-gray-500'">{{ p.is_active ? 'aktiv' : 'inaktiv' }}</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs text-gray-300">
|
||||
<div>Pro User: {{ p.per_user_limit }}</div>
|
||||
<div v-if="p.global_limit">Global: {{ p.global_limit }}</div>
|
||||
<div v-if="p.starts_at">Start: {{ p.starts_at }}</div>
|
||||
<div v-if="p.ends_at">Ende: {{ p.ends_at }}</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="secondary" @click="updatePromo(p.id, { is_active: !p.is_active })">{{ p.is_active ? 'Deaktivieren' : 'Aktivieren' }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-sm text-gray-400">Keine Promos vorhanden.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UserLayout>
|
||||
</template>
|
||||
281
resources/js/pages/Admin/SiteSettings.vue
Normal file
281
resources/js/pages/Admin/SiteSettings.vue
Normal file
@@ -0,0 +1,281 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, useForm } from '@inertiajs/vue3';
|
||||
import { onMounted, nextTick } from 'vue';
|
||||
import CasinoAdminLayout from '@/layouts/admin/CasinoAdminLayout.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
settings: {
|
||||
site_name: string;
|
||||
site_tagline: string;
|
||||
primary_color: string;
|
||||
logo_url: string;
|
||||
favicon_url: string;
|
||||
maintenance_mode: boolean;
|
||||
registration_open: boolean;
|
||||
min_deposit_usd: number;
|
||||
max_deposit_usd: number;
|
||||
min_withdrawal_usd: number;
|
||||
max_withdrawal_usd: number;
|
||||
max_bet_usd: number;
|
||||
house_edge_percent: number;
|
||||
footer_text: string;
|
||||
support_email: string;
|
||||
terms_url: string;
|
||||
privacy_url: string;
|
||||
currency_symbol: string;
|
||||
};
|
||||
}>();
|
||||
|
||||
const form = useForm({ ...props.settings });
|
||||
|
||||
function submit() {
|
||||
form.post('/admin/settings/site', { preserveScroll: true });
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => { if ((window as any).lucide) (window as any).lucide.createIcons(); });
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CasinoAdminLayout>
|
||||
<Head title="Admin – Site Settings" />
|
||||
|
||||
<template #title>Site Einstellungen</template>
|
||||
|
||||
<div class="page-wrap">
|
||||
<!-- Flash -->
|
||||
<div v-if="($page.props as any).flash?.success" class="alert-success">
|
||||
<i data-lucide="check-circle"></i>
|
||||
{{ ($page.props as any).flash.success }}
|
||||
</div>
|
||||
|
||||
<!-- General -->
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<h2>Allgemein</h2>
|
||||
<p class="card-subtitle">Name, Branding und Design.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="form-grid">
|
||||
<div class="field">
|
||||
<label class="field-label">Site-Name</label>
|
||||
<input type="text" v-model="form.site_name" class="field-input" maxlength="100">
|
||||
<div class="field-error" v-if="form.errors.site_name">{{ form.errors.site_name }}</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label">Slogan</label>
|
||||
<input type="text" v-model="form.site_tagline" class="field-input" maxlength="200">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label">Primärfarbe (Hex)</label>
|
||||
<div class="color-row">
|
||||
<input type="color" v-model="form.primary_color" class="color-picker">
|
||||
<input type="text" v-model="form.primary_color" class="field-input" maxlength="7" placeholder="#df006a">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label">Währungssymbol</label>
|
||||
<input type="text" v-model="form.currency_symbol" class="field-input" maxlength="10" placeholder="BTX">
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label class="field-label">Logo-URL</label>
|
||||
<input type="url" v-model="form.logo_url" class="field-input" placeholder="https://...">
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label class="field-label">Favicon-URL</label>
|
||||
<input type="url" v-model="form.favicon_url" class="field-input" placeholder="https://...">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Toggles -->
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<h2>Status</h2>
|
||||
<p class="card-subtitle">Wartungsmodus und Registrierung.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="toggle-list">
|
||||
<div class="toggle-row">
|
||||
<div class="toggle-info">
|
||||
<div class="toggle-label">
|
||||
<i data-lucide="wrench"></i>
|
||||
Wartungsmodus
|
||||
</div>
|
||||
<div class="toggle-desc">Nur Admins haben Zugang zur Seite.</div>
|
||||
</div>
|
||||
<button class="toggle-btn" :class="{ active: form.maintenance_mode }" @click="form.maintenance_mode = !form.maintenance_mode">
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="toggle-row">
|
||||
<div class="toggle-info">
|
||||
<div class="toggle-label">
|
||||
<i data-lucide="user-plus"></i>
|
||||
Registrierung offen
|
||||
</div>
|
||||
<div class="toggle-desc">Neue Nutzer können sich registrieren.</div>
|
||||
</div>
|
||||
<button class="toggle-btn" :class="{ active: form.registration_open }" @click="form.registration_open = !form.registration_open">
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Limits -->
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<h2>Limits & House Edge</h2>
|
||||
<p class="card-subtitle">Einzahlungs-, Auszahlungs- und Wettlimits sowie den Hausvorteil.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="form-grid">
|
||||
<div class="field">
|
||||
<label class="field-label">Min. Einzahlung (USD)</label>
|
||||
<input type="number" step="0.01" min="0" v-model.number="form.min_deposit_usd" class="field-input">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label">Max. Einzahlung (USD)</label>
|
||||
<input type="number" step="0.01" min="0" v-model.number="form.max_deposit_usd" class="field-input">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label">Min. Auszahlung (USD)</label>
|
||||
<input type="number" step="0.01" min="0" v-model.number="form.min_withdrawal_usd" class="field-input">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label">Max. Auszahlung (USD)</label>
|
||||
<input type="number" step="0.01" min="0" v-model.number="form.max_withdrawal_usd" class="field-input">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label">Max. Wette (USD)</label>
|
||||
<input type="number" step="0.01" min="0" v-model.number="form.max_bet_usd" class="field-input">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label">House Edge (%)</label>
|
||||
<input type="number" step="0.01" min="0" max="100" v-model.number="form.house_edge_percent" class="field-input">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contact & Legal -->
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<h2>Kontakt & Rechtliches</h2>
|
||||
<p class="card-subtitle">Support-E-Mail, Fußzeilentext und Links zu AGB/Datenschutz.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="form-grid">
|
||||
<div class="field">
|
||||
<label class="field-label">Support-E-Mail</label>
|
||||
<input type="email" v-model="form.support_email" class="field-input" placeholder="support@example.com">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label">AGB-URL</label>
|
||||
<input type="text" v-model="form.terms_url" class="field-input" placeholder="/terms">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label">Datenschutz-URL</label>
|
||||
<input type="text" v-model="form.privacy_url" class="field-input" placeholder="/privacy">
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label class="field-label">Fußzeilentext</label>
|
||||
<textarea v-model="form.footer_text" rows="3" class="field-input" maxlength="1000" placeholder="© 2024 BetiX Casino. Alle Rechte vorbehalten."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="save-bar">
|
||||
<button class="btn-primary" @click="submit" :disabled="form.processing">
|
||||
<i data-lucide="save"></i>
|
||||
{{ form.processing ? 'Wird gespeichert...' : 'Alle Einstellungen speichern' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</CasinoAdminLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-wrap { max-width: 900px; margin: 0 auto; display: flex; flex-direction: column; gap: 20px; }
|
||||
|
||||
.alert-success {
|
||||
background: rgba(0, 200, 100, 0.1); border: 1px solid rgba(0, 200, 100, 0.3);
|
||||
color: #00c864; padding: 12px 16px; border-radius: 10px;
|
||||
display: flex; align-items: center; gap: 10px; font-weight: 600;
|
||||
}
|
||||
.alert-success i { width: 18px; height: 18px; flex-shrink: 0; }
|
||||
|
||||
.card { background: #0f0f10; border: 1px solid #18181b; border-radius: 14px; overflow: hidden; }
|
||||
.card-head { padding: 20px 24px; border-bottom: 1px solid #18181b; }
|
||||
.card-head h2 { font-size: 16px; font-weight: 700; color: #fff; margin: 0 0 2px; }
|
||||
.card-subtitle { color: #71717a; font-size: 12px; margin: 0; }
|
||||
.card-body { padding: 20px 24px; }
|
||||
|
||||
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.field.full { grid-column: 1 / -1; }
|
||||
.field-label { display: block; font-size: 11px; font-weight: 700; color: #71717a; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px; }
|
||||
.field-input {
|
||||
width: 100%; background: #18181b; border: 1px solid #27272a; border-radius: 8px;
|
||||
color: #e4e4e7; padding: 9px 12px; font-size: 13px; font-family: inherit;
|
||||
transition: border-color 0.15s; box-sizing: border-box;
|
||||
}
|
||||
.field-input:focus { outline: none; border-color: #df006a; }
|
||||
textarea.field-input { resize: vertical; }
|
||||
.field-error { color: #ff3e3e; font-size: 11px; margin-top: 4px; }
|
||||
|
||||
/* Color picker */
|
||||
.color-row { display: flex; gap: 8px; align-items: center; }
|
||||
.color-picker { width: 44px; height: 38px; padding: 2px; border: 1px solid #27272a; border-radius: 8px; background: #18181b; cursor: pointer; }
|
||||
.color-row .field-input { flex: 1; }
|
||||
|
||||
/* Toggles */
|
||||
.toggle-list { display: flex; flex-direction: column; gap: 0; }
|
||||
.toggle-row {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 20px;
|
||||
padding: 14px 0; border-bottom: 1px solid #18181b;
|
||||
}
|
||||
.toggle-row:last-child { border-bottom: none; }
|
||||
.toggle-info { display: flex; flex-direction: column; gap: 2px; }
|
||||
.toggle-label { display: flex; align-items: center; gap: 8px; font-weight: 600; color: #e4e4e7; font-size: 14px; }
|
||||
.toggle-label i { width: 16px; height: 16px; color: #71717a; }
|
||||
.toggle-desc { color: #71717a; font-size: 12px; padding-left: 24px; }
|
||||
.toggle-btn {
|
||||
width: 48px; height: 26px; border-radius: 99px; background: #27272a; border: none; cursor: pointer;
|
||||
position: relative; transition: background 0.2s; flex-shrink: 0;
|
||||
}
|
||||
.toggle-btn.active { background: #df006a; }
|
||||
.toggle-knob {
|
||||
position: absolute; top: 3px; left: 3px; width: 20px; height: 20px;
|
||||
background: #fff; border-radius: 50%; transition: transform 0.2s;
|
||||
}
|
||||
.toggle-btn.active .toggle-knob { transform: translateX(22px); }
|
||||
|
||||
/* Save bar */
|
||||
.save-bar { display: flex; justify-content: flex-end; padding: 4px 0; }
|
||||
.btn-primary {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
background: #df006a; border: none; color: #fff; padding: 11px 22px;
|
||||
border-radius: 8px; font-weight: 700; font-size: 13px; cursor: pointer; transition: all 0.15s;
|
||||
}
|
||||
.btn-primary:hover { background: #b8005a; }
|
||||
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-primary i { width: 16px; height: 16px; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.form-grid { grid-template-columns: 1fr; }
|
||||
.field.full { grid-column: 1; }
|
||||
}
|
||||
</style>
|
||||
632
resources/js/pages/Admin/Support.vue
Normal file
632
resources/js/pages/Admin/Support.vue
Normal file
@@ -0,0 +1,632 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, router } from '@inertiajs/vue3';
|
||||
import { ref, computed, onMounted, nextTick } from 'vue';
|
||||
import UserLayout from '@/layouts/admin/CasinoAdminLayout.vue';
|
||||
import { usePage } from '@inertiajs/vue3';
|
||||
|
||||
const props = defineProps<{ enabled: boolean; threads: any[]; ollama?: { host: string; model: string; healthy: boolean; error?: string | null } }>();
|
||||
|
||||
const page = usePage();
|
||||
const user = computed(() => (page.props.auth as any).user || {});
|
||||
|
||||
const enabled = ref<boolean>(props.enabled);
|
||||
|
||||
function saveEnable() {
|
||||
router.post('/admin/support/settings', { enabled: enabled.value }, { preserveScroll: true });
|
||||
}
|
||||
|
||||
function sendReply(tid: string, textRef: any) {
|
||||
const text = (textRef.value || '').trim();
|
||||
if (!text) return;
|
||||
router.post(`/admin/support/threads/${tid}/message`, { text }, { preserveScroll: true, onFinish: () => { textRef.value = ''; } });
|
||||
}
|
||||
|
||||
function formatTime(isoString: string) {
|
||||
return new Date(isoString).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
const statusMap = {
|
||||
new: { text: 'NEU', color: '#888', glow: 'rgba(136,136,136,0.4)' },
|
||||
ai: { text: 'KI AKTIV', color: '#00f2ff', glow: 'rgba(0,242,255,0.4)' },
|
||||
stopped: { text: 'KI GESTOPPT', color: '#ffd700', glow: 'rgba(255,215,0,0.4)' },
|
||||
handoff: { text: 'ÜBERGABE', color: '#ff9f43', glow: 'rgba(255,159,67,0.4)' },
|
||||
agent: { text: 'AGENT', color: '#ff007a', glow: 'rgba(255,0,122,0.4)' },
|
||||
closed: { text: 'GESCHLOSSEN', color: '#ff3e3e', glow: 'rgba(255,62,62,0.4)' },
|
||||
};
|
||||
|
||||
const getStatus = (status: string) => statusMap[status as keyof typeof statusMap] || statusMap.new;
|
||||
|
||||
const getAvatarFallback = (name: string, background: string = 'random', color: string = 'fff') => {
|
||||
const cleanName = name ? name.replace(/\s/g, '+') : 'User';
|
||||
return `https://ui-avatars.com/api/?name=${cleanName}&background=${background}&color=${color}`;
|
||||
};
|
||||
|
||||
// Helper to find the correct avatar property
|
||||
const getUserAvatar = (u: any) => {
|
||||
if (!u) return null;
|
||||
// Try multiple common property names
|
||||
return u.avatar || u.avatar_url || u.profile_photo_url || null;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
console.log('Support Threads Data:', props.threads); // Debugging output
|
||||
nextTick(() => { if ((window as any).lucide) (window as any).lucide.createIcons(); });
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UserLayout>
|
||||
<Head title="Admin · Support" />
|
||||
|
||||
<div class="admin-support-container">
|
||||
<!-- Header Section -->
|
||||
<div class="page-header">
|
||||
<div class="header-content">
|
||||
<h1 class="page-title">SUPPORT <span class="highlight">DASHBOARD</span></h1>
|
||||
<p class="page-subtitle">VERWALTUNG & LIVE-CHAT ÜBERSICHT</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<!-- Global Toggle -->
|
||||
<div class="status-pill" @click="enabled = !enabled; saveEnable();">
|
||||
<div class="sp-label">CHAT SYSTEM</div>
|
||||
<div class="sp-indicator" :class="{ active: enabled }">
|
||||
<div class="sp-dot"></div>
|
||||
<span>{{ enabled ? 'AKTIV' : 'INAKTIV' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats / Info Grid -->
|
||||
<div class="stats-grid">
|
||||
<!-- Ollama Card -->
|
||||
<div class="stat-card">
|
||||
<div class="sc-icon">
|
||||
<i data-lucide="bot"></i>
|
||||
</div>
|
||||
<div class="sc-info">
|
||||
<div class="sc-label">KI STATUS (OLLAMA)</div>
|
||||
<div class="sc-value" :class="props.ollama?.healthy ? 'text-cyan' : 'text-red'">
|
||||
{{ props.ollama?.healthy ? 'ONLINE' : 'OFFLINE' }}
|
||||
</div>
|
||||
<div class="sc-sub">{{ props.ollama?.model || 'Kein Modell' }}</div>
|
||||
</div>
|
||||
<div class="sc-glow" :class="props.ollama?.healthy ? 'glow-cyan' : 'glow-red'"></div>
|
||||
</div>
|
||||
|
||||
<!-- Active Threads Card -->
|
||||
<div class="stat-card">
|
||||
<div class="sc-icon">
|
||||
<i data-lucide="message-square"></i>
|
||||
</div>
|
||||
<div class="sc-info">
|
||||
<div class="sc-label">AKTIVE CHATS</div>
|
||||
<div class="sc-value text-magenta">{{ props.threads?.length || 0 }}</div>
|
||||
<div class="sc-sub">OFFENE ANFRAGEN</div>
|
||||
</div>
|
||||
<div class="sc-glow glow-magenta"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Threads List -->
|
||||
<div class="threads-section">
|
||||
<h2 class="section-title"><i data-lucide="layers"></i> LAUFENDE UNTERHALTUNGEN</h2>
|
||||
|
||||
<div v-if="!props.threads || props.threads.length === 0" class="empty-state">
|
||||
<div class="es-icon"><i data-lucide="inbox"></i></div>
|
||||
<h3>Keine aktiven Support-Anfragen</h3>
|
||||
<p>Alles ruhig! Warte auf neue Nachrichten von Benutzern.</p>
|
||||
</div>
|
||||
|
||||
<div class="threads-grid">
|
||||
<div v-for="t in props.threads" :key="t.id" class="thread-card" :class="{ 'handoff-mode': t.status === 'handoff' }">
|
||||
<!-- Thread Header -->
|
||||
<div class="tc-header">
|
||||
<div class="user-profile">
|
||||
<div class="up-avatar">
|
||||
<img
|
||||
:src="getUserAvatar(t.user) || getAvatarFallback(t.user?.username)"
|
||||
:alt="t.user?.username"
|
||||
:onerror="`this.onerror=null; this.src='${getAvatarFallback(t.user?.username)}'`"
|
||||
/>
|
||||
</div>
|
||||
<div class="up-info">
|
||||
<div class="up-name">{{ t.user?.username }}</div>
|
||||
<div class="up-id">ID: {{ t.user?.id }} · {{ t.user?.email }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tc-status" :style="{ color: getStatus(t.status).color, borderColor: getStatus(t.status).color, boxShadow: `0 0 10px ${getStatus(t.status).glow}` }">
|
||||
{{ getStatus(t.status).text }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Topic Badge -->
|
||||
<div class="tc-topic" v-if="t.topic">
|
||||
<i data-lucide="hash"></i> {{ t.topic }}
|
||||
</div>
|
||||
|
||||
<!-- Chat Area -->
|
||||
<div class="tc-chat-window">
|
||||
<div v-for="m in t.messages" :key="m.id" class="chat-row" :class="m.sender === 'agent' ? 'right' : 'left'">
|
||||
|
||||
<!-- Avatar Left (User/AI) -->
|
||||
<div class="chat-avatar" v-if="m.sender !== 'agent' && m.sender !== 'system'">
|
||||
<img v-if="m.sender === 'user'"
|
||||
:src="getUserAvatar(t.user) || getAvatarFallback(t.user?.username)"
|
||||
:onerror="`this.onerror=null; this.src='${getAvatarFallback(t.user?.username)}'`"
|
||||
/>
|
||||
<img v-else-if="m.sender === 'ai'" :src="getAvatarFallback('AI', '00f2ff', '000')" />
|
||||
</div>
|
||||
|
||||
<div class="msg-bubble" :class="m.sender">
|
||||
<div class="msg-text">{{ m.body }}</div>
|
||||
<div class="msg-meta">
|
||||
<span>{{ formatTime(m.at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Avatar Right (Agent) -->
|
||||
<div class="chat-avatar" v-if="m.sender === 'agent'">
|
||||
<img
|
||||
:src="getUserAvatar(user) || getAvatarFallback(user.name, 'ff007a')"
|
||||
:onerror="`this.onerror=null; this.src='${getAvatarFallback(user.name, 'ff007a')}'`"
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="tc-actions">
|
||||
<div class="input-group">
|
||||
<input
|
||||
type="text"
|
||||
:ref="el => t._ref = el"
|
||||
:disabled="t.status==='closed'"
|
||||
placeholder="Antwort schreiben..."
|
||||
@keydown.enter="sendReply(t.id, t._ref)"
|
||||
/>
|
||||
<button class="btn-send" :disabled="t.status==='closed'" @click="sendReply(t.id, t._ref)">
|
||||
<i data-lucide="send"></i>
|
||||
</button>
|
||||
</div>
|
||||
<button class="btn-close" @click="router.post(`/admin/support/threads/${t.id}/close`, {}, { preserveScroll: true })" title="Chat schließen">
|
||||
<i data-lucide="x-circle"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</UserLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* --- Variables & Base --- */
|
||||
.admin-support-container {
|
||||
padding: 30px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
color: #fff;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
/* --- Header --- */
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 40px;
|
||||
border-bottom: 1px solid #1f1f1f;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 32px;
|
||||
font-weight: 900;
|
||||
letter-spacing: -1px;
|
||||
margin: 0;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
color: #ff007a;
|
||||
text-shadow: 0 0 20px rgba(255, 0, 122, 0.4);
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
color: #888;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
/* --- Status Pill (Toggle) --- */
|
||||
.status-pill {
|
||||
background: #0f0f0f;
|
||||
border: 1px solid #222;
|
||||
border-radius: 12px;
|
||||
padding: 6px 8px 6px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
transition: 0.3s;
|
||||
}
|
||||
.status-pill:hover {
|
||||
border-color: #333;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 15px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.sp-label {
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.sp-indicator {
|
||||
background: #1a1a1a;
|
||||
padding: 6px 12px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
color: #555;
|
||||
transition: 0.3s;
|
||||
}
|
||||
|
||||
.sp-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #444;
|
||||
transition: 0.3s;
|
||||
}
|
||||
|
||||
.sp-indicator.active {
|
||||
background: rgba(0, 242, 255, 0.1);
|
||||
color: #00f2ff;
|
||||
}
|
||||
.sp-indicator.active .sp-dot {
|
||||
background: #00f2ff;
|
||||
box-shadow: 0 0 8px #00f2ff;
|
||||
}
|
||||
|
||||
/* --- Stats Grid --- */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: #0f0f0f;
|
||||
border: 1px solid #1f1f1f;
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: 0.3s;
|
||||
}
|
||||
.stat-card:hover {
|
||||
transform: translateY(-3px);
|
||||
border-color: #333;
|
||||
}
|
||||
|
||||
.sc-icon {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
background: #141414;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #666;
|
||||
z-index: 2;
|
||||
}
|
||||
.sc-icon i { width: 24px; height: 24px; }
|
||||
|
||||
.sc-info { z-index: 2; }
|
||||
.sc-label { font-size: 10px; font-weight: 800; color: #555; letter-spacing: 1px; margin-bottom: 4px; }
|
||||
.sc-value { font-size: 20px; font-weight: 900; letter-spacing: -0.5px; }
|
||||
.sc-sub { font-size: 11px; color: #666; margin-top: 2px; font-family: monospace; }
|
||||
|
||||
.text-cyan { color: #00f2ff; text-shadow: 0 0 10px rgba(0,242,255,0.3); }
|
||||
.text-red { color: #ff3e3e; text-shadow: 0 0 10px rgba(255,62,62,0.3); }
|
||||
.text-magenta { color: #ff007a; text-shadow: 0 0 10px rgba(255,0,122,0.3); }
|
||||
|
||||
.sc-glow {
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
right: -20%;
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
border-radius: 50%;
|
||||
filter: blur(60px);
|
||||
opacity: 0.15;
|
||||
z-index: 1;
|
||||
}
|
||||
.glow-cyan { background: #00f2ff; }
|
||||
.glow-red { background: #ff3e3e; }
|
||||
.glow-magenta { background: #ff007a; }
|
||||
|
||||
/* --- Threads Section --- */
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
color: #888;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.section-title i { width: 16px; }
|
||||
|
||||
.empty-state {
|
||||
background: #0f0f0f;
|
||||
border: 1px solid #1f1f1f;
|
||||
border-radius: 16px;
|
||||
padding: 60px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.es-icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
background: #141414;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #333;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.es-icon i { width: 28px; height: 28px; }
|
||||
.empty-state h3 { font-size: 18px; font-weight: 700; margin: 0 0 8px 0; color: #fff; }
|
||||
.empty-state p { font-size: 13px; color: #666; margin: 0; }
|
||||
|
||||
.threads-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* --- Thread Card --- */
|
||||
.thread-card {
|
||||
background: #0f0f0f;
|
||||
border: 1px solid #1f1f1f;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: 0.3s;
|
||||
height: 600px; /* Taller for better chat view */
|
||||
}
|
||||
.thread-card:hover {
|
||||
border-color: #333;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
|
||||
}
|
||||
.thread-card.handoff-mode {
|
||||
border-color: #ff9f43;
|
||||
box-shadow: 0 0 20px rgba(255, 159, 67, 0.15);
|
||||
}
|
||||
|
||||
.tc-header {
|
||||
padding: 16px;
|
||||
background: #141414;
|
||||
border-bottom: 1px solid #1f1f1f;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.user-profile { display: flex; align-items: center; gap: 12px; }
|
||||
.up-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
border: 2px solid #222;
|
||||
}
|
||||
.up-avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.up-info { display: flex; flex-direction: column; }
|
||||
.up-name { font-size: 14px; font-weight: 800; color: #fff; }
|
||||
.up-id { font-size: 10px; color: #666; font-family: monospace; }
|
||||
|
||||
.tc-status {
|
||||
font-size: 9px;
|
||||
font-weight: 900;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid;
|
||||
background: rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.tc-topic {
|
||||
padding: 8px 16px;
|
||||
background: #111;
|
||||
border-bottom: 1px solid #1f1f1f;
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.tc-topic i { width: 12px; }
|
||||
|
||||
/* --- Chat Window --- */
|
||||
.tc-chat-window {
|
||||
flex: 1;
|
||||
background: #0a0a0a;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.chat-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-end;
|
||||
max-width: 85%;
|
||||
}
|
||||
|
||||
.chat-row.left { align-self: flex-start; }
|
||||
.chat-row.right { align-self: flex-end; flex-direction: row-reverse; }
|
||||
|
||||
.chat-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid #333;
|
||||
}
|
||||
.chat-avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||||
|
||||
.msg-bubble {
|
||||
padding: 12px 16px;
|
||||
border-radius: 16px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
position: relative;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* User Bubble */
|
||||
.user {
|
||||
background: #1a1a1a;
|
||||
color: #ddd;
|
||||
border-bottom-left-radius: 2px;
|
||||
border: 1px solid #222;
|
||||
}
|
||||
|
||||
/* Agent Bubble */
|
||||
.agent {
|
||||
background: rgba(255, 0, 122, 0.1);
|
||||
color: #fff;
|
||||
border-bottom-right-radius: 2px;
|
||||
border: 1px solid rgba(255, 0, 122, 0.3);
|
||||
}
|
||||
|
||||
/* AI Bubble */
|
||||
.ai {
|
||||
background: rgba(0, 242, 255, 0.05);
|
||||
color: #00f2ff;
|
||||
border-bottom-left-radius: 2px;
|
||||
border: 1px solid rgba(0, 242, 255, 0.2);
|
||||
}
|
||||
|
||||
/* System Bubble */
|
||||
.system {
|
||||
background: transparent;
|
||||
color: #666;
|
||||
font-size: 11px;
|
||||
font-style: italic;
|
||||
padding: 4px;
|
||||
border: none;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
.chat-row:has(.system) { max-width: 100%; align-self: center; }
|
||||
|
||||
.msg-meta {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 8px;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
opacity: 0.5;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* --- Actions --- */
|
||||
.tc-actions {
|
||||
padding: 12px;
|
||||
background: #141414;
|
||||
border-top: 1px solid #1f1f1f;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
background: #0a0a0a;
|
||||
border: 1px solid #222;
|
||||
border-radius: 10px;
|
||||
padding: 4px;
|
||||
transition: 0.2s;
|
||||
}
|
||||
.input-group:focus-within {
|
||||
border-color: #ff007a;
|
||||
box-shadow: 0 0 10px rgba(255,0,122,0.1);
|
||||
}
|
||||
|
||||
.input-group input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #fff;
|
||||
padding: 0 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.btn-send {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: #ff007a;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: 0.2s;
|
||||
}
|
||||
.btn-send:hover { background: #d40065; }
|
||||
.btn-send:disabled { background: #333; cursor: not-allowed; }
|
||||
.btn-send i { width: 16px; }
|
||||
|
||||
.btn-close {
|
||||
width: 42px;
|
||||
background: rgba(255, 62, 62, 0.1);
|
||||
border: 1px solid rgba(255, 62, 62, 0.2);
|
||||
border-radius: 10px;
|
||||
color: #ff3e3e;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: 0.2s;
|
||||
}
|
||||
.btn-close:hover {
|
||||
background: rgba(255, 62, 62, 0.2);
|
||||
border-color: #ff3e3e;
|
||||
}
|
||||
.btn-close i { width: 20px; }
|
||||
|
||||
/* Scrollbar for chat */
|
||||
.tc-chat-window::-webkit-scrollbar { width: 4px; }
|
||||
.tc-chat-window::-webkit-scrollbar-track { background: transparent; }
|
||||
.tc-chat-window::-webkit-scrollbar-thumb { background: #333; border-radius: 2px; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page-header { flex-direction: column; align-items: flex-start; gap: 20px; }
|
||||
.threads-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
420
resources/js/pages/Admin/UserShow.vue
Normal file
420
resources/js/pages/Admin/UserShow.vue
Normal file
@@ -0,0 +1,420 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, Link, useForm } from '@inertiajs/vue3';
|
||||
import { ref } from 'vue';
|
||||
import CasinoAdminLayout from '@/layouts/admin/CasinoAdminLayout.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
user: any;
|
||||
restrictions: any[];
|
||||
wallets: any[];
|
||||
vaultTransfers: any[];
|
||||
kycDocuments: any[];
|
||||
}>();
|
||||
|
||||
const activeTab = ref('overview');
|
||||
|
||||
const form = useForm({
|
||||
username: props.user.username,
|
||||
email: props.user.email,
|
||||
first_name: props.user.first_name || '',
|
||||
last_name: props.user.last_name || '',
|
||||
birthdate: props.user.birthdate || '',
|
||||
gender: props.user.gender || '',
|
||||
phone: props.user.phone || '',
|
||||
country: props.user.country || '',
|
||||
address_line1: props.user.address_line1 || '',
|
||||
address_line2: props.user.address_line2 || '',
|
||||
city: props.user.city || '',
|
||||
postal_code: props.user.postal_code || '',
|
||||
currency: props.user.currency || '',
|
||||
role: props.user.role || 'User',
|
||||
vip_level: props.user.vip_level,
|
||||
balance: props.user.balance,
|
||||
vault_balance: props.user.vault_balance,
|
||||
is_banned: props.user.is_banned,
|
||||
is_chat_banned: props.user.is_chat_banned,
|
||||
ban_reason: '',
|
||||
ban_ends_at: '',
|
||||
chat_ban_ends_at: '',
|
||||
});
|
||||
|
||||
const saveUser = () => {
|
||||
form.post(`/admin/users/${props.user.id}`, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
// Optional: Show toast
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
if (!dateString) return 'N/A';
|
||||
return new Date(dateString).toLocaleString();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CasinoAdminLayout>
|
||||
<Head :title="`User: ${user.username}`" />
|
||||
|
||||
<template #title>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="avatar large">{{ user.username.charAt(0).toUpperCase() }}</div>
|
||||
<div>
|
||||
<div>{{ user.username }} <span class="badge text-xs ml-2" :class="user.is_banned ? 'bg-red-500 text-white' : 'bg-gray-800 text-gray-400'">{{ user.is_banned ? 'BANNED' : user.role }}</span></div>
|
||||
<div class="text-sm text-gray-500 font-normal mt-1">{{ user.email }} • ID: {{ user.id }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<Link href="/admin/users" class="btn-ghost">
|
||||
<i data-lucide="arrow-left"></i> Back to Users
|
||||
</Link>
|
||||
</template>
|
||||
|
||||
<div class="user-layout">
|
||||
<!-- Sidebar Navigation -->
|
||||
<aside class="user-sidebar">
|
||||
<div class="panel">
|
||||
<nav class="side-nav">
|
||||
<button :class="{active: activeTab === 'overview'}" @click="activeTab = 'overview'">
|
||||
<i data-lucide="user"></i> Overview
|
||||
</button>
|
||||
<button :class="{active: activeTab === 'financials'}" @click="activeTab = 'financials'">
|
||||
<i data-lucide="wallet"></i> Financials & Wallets
|
||||
</button>
|
||||
<button :class="{active: activeTab === 'security'}" @click="activeTab = 'security'">
|
||||
<i data-lucide="shield-alert"></i> Security & Bans
|
||||
</button>
|
||||
<button :class="{active: activeTab === 'deposits'}" @click="activeTab = 'deposits'">
|
||||
<i data-lucide="coins"></i> Deposits
|
||||
</button>
|
||||
<button :class="{active: activeTab === 'kyc'}" @click="activeTab = 'kyc'">
|
||||
<i data-lucide="file-check"></i> KYC Documents
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="panel mt-4 p-4">
|
||||
<h4 class="text-xs font-bold text-gray-500 uppercase mb-3">Quick Stats</h4>
|
||||
<div class="space-y-3 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-400">Joined</span>
|
||||
<span class="text-white">{{ new Date(user.created_at).toLocaleDateString() }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-400">Last Login</span>
|
||||
<span class="text-white">{{ formatDate(user.last_login_at) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-400">VIP Level</span>
|
||||
<span class="text-white">{{ user.vip_level }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<main class="user-content">
|
||||
<!-- Overview Tab -->
|
||||
<div v-show="activeTab === 'overview'" class="panel p-6">
|
||||
<h3 class="section-title">General Information</h3>
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label>Username</label>
|
||||
<input type="text" v-model="form.username" class="input-field" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Email Address</label>
|
||||
<input type="email" v-model="form.email" class="input-field" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>First Name</label>
|
||||
<input type="text" v-model="form.first_name" class="input-field" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Last Name</label>
|
||||
<input type="text" v-model="form.last_name" class="input-field" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Birthdate</label>
|
||||
<input type="date" v-model="form.birthdate" class="input-field" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Gender</label>
|
||||
<select v-model="form.gender" class="input-field">
|
||||
<option value="">—</option>
|
||||
<option value="male">Male</option>
|
||||
<option value="female">Female</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Phone</label>
|
||||
<input type="text" v-model="form.phone" class="input-field" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Country</label>
|
||||
<input type="text" v-model="form.country" class="input-field" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Address line 1</label>
|
||||
<input type="text" v-model="form.address_line1" class="input-field" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Address line 2</label>
|
||||
<input type="text" v-model="form.address_line2" class="input-field" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>City</label>
|
||||
<input type="text" v-model="form.city" class="input-field" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Postal Code</label>
|
||||
<input type="text" v-model="form.postal_code" class="input-field" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Currency</label>
|
||||
<input type="text" v-model="form.currency" class="input-field" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Role</label>
|
||||
<select v-model="form.role" class="input-field">
|
||||
<option value="Admin">Admin</option>
|
||||
<option value="Moderator">Moderator</option>
|
||||
<option value="User">User</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 flex justify-end">
|
||||
<button class="btn-primary" @click="saveUser" :disabled="form.processing">Save Changes</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Financials Tab -->
|
||||
<div v-show="activeTab === 'financials'" class="space-y-6">
|
||||
<div class="panel p-6">
|
||||
<h3 class="section-title">Balances</h3>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="form-group">
|
||||
<label>Main Balance ($)</label>
|
||||
<input type="text" v-model="form.balance" class="input-field font-mono text-lg" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Vault Balance ($)</label>
|
||||
<input type="text" v-model="form.vault_balance" class="input-field font-mono text-lg" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex justify-end">
|
||||
<button class="btn-primary" @click="saveUser" :disabled="form.processing">Update Balances</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="p-6 border-b border-[#1f1f22]">
|
||||
<h3 class="section-title m-0">Vault History</h3>
|
||||
</div>
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Type</th>
|
||||
<th class="text-right">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="t in vaultTransfers" :key="t.id">
|
||||
<td class="text-gray-400">{{ formatDate(t.created_at) }}</td>
|
||||
<td><span class="badge bg-gray-800">{{ t.type }}</span></td>
|
||||
<td class="text-right font-mono font-bold" :class="t.amount > 0 ? 'text-green-400' : 'text-red-400'">
|
||||
{{ t.amount > 0 ? '+' : '' }}{{ t.amount }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!vaultTransfers?.length">
|
||||
<td colspan="3" class="text-center py-6 text-gray-500">No vault transfers found.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Security & Bans Tab -->
|
||||
<div v-show="activeTab === 'security'" class="space-y-6">
|
||||
<div class="panel p-6 border border-orange-900/30 bg-orange-900/5">
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h3 class="text-orange-400 font-bold text-lg mb-1 flex items-center gap-2">
|
||||
<i data-lucide="message-circle-off"></i> Chat Ban
|
||||
</h3>
|
||||
<p class="text-sm text-gray-400">Mute this user from sending live chat messages.</p>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" v-model="form.is_chat_banned" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="form.is_chat_banned" class="space-y-4 pt-4 border-t border-orange-900/20">
|
||||
<div class="form-group">
|
||||
<label class="text-orange-400">Chat Ban Expiration</label>
|
||||
<input type="datetime-local" v-model="form.chat_ban_ends_at" class="input-field border-orange-900/50 focus:border-orange-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex justify-end">
|
||||
<button class="bg-orange-600 hover:bg-orange-500 text-white px-4 py-2 rounded-lg font-bold transition" @click="saveUser">Apply Chat Ban</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel p-6 border border-red-900/30 bg-red-900/5">
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h3 class="text-red-500 font-bold text-lg mb-1 flex items-center gap-2">
|
||||
<i data-lucide="shield-alert"></i> Account Ban
|
||||
</h3>
|
||||
<p class="text-sm text-gray-400">Completely lock this user out of the platform.</p>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" v-model="form.is_banned" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="form.is_banned" class="space-y-4 pt-4 border-t border-red-900/20">
|
||||
<div class="form-group">
|
||||
<label class="text-red-400">Ban Reason (Shown to user)</label>
|
||||
<input type="text" v-model="form.ban_reason" class="input-field border-red-900/50 focus:border-red-500" placeholder="e.g. Fraudulent Activity" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="text-red-400">Ban Expiration (Leave empty for permanent)</label>
|
||||
<input type="datetime-local" v-model="form.ban_ends_at" class="input-field border-red-900/50 focus:border-red-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex justify-end">
|
||||
<button class="bg-red-600 hover:bg-red-500 text-white px-4 py-2 rounded-lg font-bold transition" @click="saveUser">Apply Security Settings</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel p-6">
|
||||
<h3 class="section-title">Active Restrictions Log</h3>
|
||||
<div class="space-y-3">
|
||||
<div v-for="res in restrictions" :key="res.id" class="p-3 bg-[#161618] rounded-lg border border-[#27272a] flex justify-between items-center">
|
||||
<div>
|
||||
<div class="font-bold text-white">{{ res.type }}</div>
|
||||
<div class="text-xs text-gray-400">{{ res.reason || 'No reason provided' }}</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-xs" :class="res.active ? 'text-red-400' : 'text-gray-500'">{{ res.active ? 'ACTIVE' : 'EXPIRED' }}</div>
|
||||
<div class="text-xs text-gray-500">{{ formatDate(res.created_at) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!restrictions?.length" class="text-gray-500 text-sm">No restrictions on record.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Deposits Tab -->
|
||||
<div v-show="activeTab === 'deposits'" class="panel">
|
||||
<div class="p-6 border-b border-[#1f1f22]">
|
||||
<h3 class="section-title m-0">Crypto Deposits</h3>
|
||||
</div>
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Created</th>
|
||||
<th>Status</th>
|
||||
<th>Currency</th>
|
||||
<th class="text-right">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="d in (props as any).deposits" :key="d.id">
|
||||
<td class="text-gray-400">{{ formatDate(d.created_at) }}</td>
|
||||
<td><span class="badge" :class="{'bg-green-600 text-white': d.status==='finished', 'bg-yellow-600 text-white': d.status==='waiting'}">{{ d.status }}</span></td>
|
||||
<td class="text-gray-300">{{ d.pay_currency }}</td>
|
||||
<td class="text-right font-mono">{{ d.price_amount }}</td>
|
||||
</tr>
|
||||
<tr v-if="!(props as any).deposits?.length">
|
||||
<td colspan="4" class="text-center py-6 text-gray-500">No deposits yet.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- KYC Tab -->
|
||||
<div v-show="activeTab === 'kyc'" class="panel p-6">
|
||||
<h3 class="section-title">KYC Documents</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div v-for="doc in kycDocuments" :key="doc.id" class="border border-[#27272a] rounded-xl p-4 bg-[#161618]">
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
<div>
|
||||
<div class="font-bold text-white">{{ doc.document_type }}</div>
|
||||
<div class="text-xs text-gray-400">Uploaded: {{ formatDate(doc.created_at) }}</div>
|
||||
</div>
|
||||
<span class="badge" :class="{'bg-green-500': doc.status === 'approved', 'bg-yellow-500': doc.status === 'pending', 'bg-red-500': doc.status === 'rejected'}">{{ doc.status }}</span>
|
||||
</div>
|
||||
<div class="aspect-video bg-black rounded-lg flex items-center justify-center overflow-hidden border border-[#27272a]">
|
||||
<img v-if="doc.file_url" :src="doc.file_url" class="w-full h-full object-cover" />
|
||||
<i v-else data-lucide="file-image" class="w-10 h-10 text-gray-600"></i>
|
||||
</div>
|
||||
<div class="mt-4 flex gap-2">
|
||||
<button class="flex-1 bg-green-600 hover:bg-green-500 text-white py-2 rounded-lg font-bold text-sm transition">Approve</button>
|
||||
<button class="flex-1 bg-red-600 hover:bg-red-500 text-white py-2 rounded-lg font-bold text-sm transition">Reject</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!kycDocuments?.length" class="col-span-full text-center py-10 text-gray-500">
|
||||
No KYC documents submitted yet.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</CasinoAdminLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.user-layout { display: grid; grid-template-columns: 240px 1fr; gap: 24px; align-items: start; }
|
||||
@media (max-width: 900px) { .user-layout { grid-template-columns: 1fr; } }
|
||||
|
||||
.panel { background: #111113; border: 1px solid #1f1f22; border-radius: 16px; overflow: hidden; }
|
||||
|
||||
.side-nav { display: flex; flex-direction: column; padding: 12px; gap: 4px; }
|
||||
.side-nav button { display: flex; align-items: center; gap: 10px; padding: 12px 16px; border-radius: 10px; background: transparent; border: none; color: #a1a1aa; font-weight: 600; font-size: 14px; cursor: pointer; transition: 0.2s; text-align: left; }
|
||||
.side-nav button i { width: 18px; height: 18px; }
|
||||
.side-nav button:hover { background: #1a1a1d; color: #fff; }
|
||||
.side-nav button.active { background: #27272a; color: #fff; }
|
||||
|
||||
.section-title { font-size: 16px; font-weight: 800; color: #fff; margin-bottom: 20px; border-bottom: 1px solid #1f1f22; padding-bottom: 12px; }
|
||||
|
||||
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
|
||||
.form-group { display: flex; flex-direction: column; gap: 6px; }
|
||||
.form-group label { font-size: 12px; font-weight: 700; color: #a1a1aa; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.input-field { background: #09090b; border: 1px solid #27272a; border-radius: 10px; padding: 12px 16px; color: #fff; outline: none; font-size: 14px; transition: border-color 0.2s; }
|
||||
.input-field:focus { border-color: #3b82f6; }
|
||||
.input-field.disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.btn-primary { background: #3b82f6; color: white; border: none; padding: 10px 20px; border-radius: 8px; font-weight: 700; cursor: pointer; transition: 0.2s; }
|
||||
.btn-primary:hover:not(:disabled) { background: #2563eb; }
|
||||
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.btn-ghost { background: transparent; border: none; color: #a1a1aa; font-weight: 600; font-size: 14px; cursor: pointer; display: flex; align-items: center; gap: 6px; text-decoration: none; }
|
||||
.btn-ghost:hover { color: #fff; }
|
||||
.btn-ghost i { width: 16px; height: 16px; }
|
||||
|
||||
.avatar { background: #27272a; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: 800; color: #fff; }
|
||||
.avatar.large { width: 48px; height: 48px; font-size: 20px; background: rgba(59, 130, 246, 0.2); color: #3b82f6; border: 1px solid rgba(59, 130, 246, 0.3); }
|
||||
|
||||
.badge { padding: 4px 8px; border-radius: 6px; font-weight: 800; }
|
||||
|
||||
.admin-table { width: 100%; border-collapse: collapse; text-align: left; }
|
||||
.admin-table th { padding: 12px 24px; font-size: 12px; font-weight: 600; color: #a1a1aa; text-transform: uppercase; background: #0c0c0e; border-bottom: 1px solid #1f1f22; }
|
||||
.admin-table td { padding: 16px 24px; font-size: 14px; border-bottom: 1px solid #1f1f22; color: #e4e4e7; }
|
||||
.admin-table tr:last-child td { border-bottom: none; }
|
||||
|
||||
/* Toggle Switch */
|
||||
.toggle-switch { position: relative; display: inline-block; width: 44px; height: 24px; }
|
||||
.toggle-switch input { opacity: 0; width: 0; height: 0; }
|
||||
.slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #27272a; transition: .4s; border-radius: 34px; }
|
||||
.slider:before { position: absolute; content: ""; height: 18px; width: 18px; left: 3px; bottom: 3px; background-color: white; transition: .4s; border-radius: 50%; }
|
||||
input:checked + .slider { background-color: #ef4444; }
|
||||
input:checked + .slider:before { transform: translateX(20px); }
|
||||
</style>
|
||||
142
resources/js/pages/Admin/Users.vue
Normal file
142
resources/js/pages/Admin/Users.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, Link, router } from '@inertiajs/vue3';
|
||||
import { ref } from 'vue';
|
||||
import CasinoAdminLayout from '@/layouts/admin/CasinoAdminLayout.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
users: any;
|
||||
roles: string[];
|
||||
filters: { search: string, role: string };
|
||||
}>();
|
||||
|
||||
const search = ref(props.filters?.search || '');
|
||||
const filterRole = ref(props.filters?.role || '');
|
||||
|
||||
const searchUsers = () => {
|
||||
router.get(route('admin.users.index'), {
|
||||
search: search.value,
|
||||
role: filterRole.value
|
||||
}, {
|
||||
preserveState: true,
|
||||
replace: true
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CasinoAdminLayout>
|
||||
<Head title="Admin – Users" />
|
||||
<template #title>
|
||||
User Management
|
||||
</template>
|
||||
<template #actions>
|
||||
<button class="btn-primary">
|
||||
<i data-lucide="user-plus"></i> Add User
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<div class="filters-bar">
|
||||
<div class="search-input">
|
||||
<i data-lucide="search"></i>
|
||||
<input type="text" v-model="search" placeholder="Search by ID, Username, Email..." @keyup.enter="searchUsers" />
|
||||
</div>
|
||||
<select v-model="filterRole" @change="searchUsers" class="filter-select">
|
||||
<option value="">All Roles</option>
|
||||
<option v-for="r in roles" :key="r" :value="r">{{ r }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>User</th>
|
||||
<th>Email</th>
|
||||
<th>Role</th>
|
||||
<th>Balance</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="user in users.data" :key="user.id">
|
||||
<td class="font-mono text-muted">{{ user.id }}</td>
|
||||
<td class="font-bold text-white flex items-center gap-3">
|
||||
<div class="avatar">{{ user.username.charAt(0).toUpperCase() }}</div>
|
||||
{{ user.username }}
|
||||
</td>
|
||||
<td class="text-muted">{{ user.email }}</td>
|
||||
<td>
|
||||
<span class="badge" :class="{'bg-red': user.role === 'Admin', 'bg-blue': user.role === 'Moderator', 'bg-gray': user.role === 'User'}">
|
||||
{{ user.role }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="font-mono text-white">${{ user.balance }}</td>
|
||||
<td>
|
||||
<span v-if="user.is_banned" class="badge bg-red-dim text-red">BANNED</span>
|
||||
<span v-else-if="user.is_chat_banned" class="badge bg-orange-dim text-orange">MUTED</span>
|
||||
<span v-else class="badge bg-green-dim text-green">ACTIVE</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex items-center gap-2">
|
||||
<Link :href="`/admin/users/${user.id}`" class="btn-icon" title="View Profile">
|
||||
<i data-lucide="eye"></i>
|
||||
</Link>
|
||||
<Link :href="`/admin/users/${user.id}?tab=finances`" class="btn-icon text-green-400" title="Manage Finances">
|
||||
<i data-lucide="wallet"></i>
|
||||
</Link>
|
||||
<button class="btn-icon text-red-400" title="Ban User" v-if="!user.is_banned">
|
||||
<i data-lucide="ban"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="pagination" v-if="users.last_page > 1">
|
||||
<!-- Pagination logic here -->
|
||||
<span class="text-muted">Page {{ users.current_page }} of {{ users.last_page }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CasinoAdminLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.btn-primary { background: #ff007a; color: white; border: none; padding: 10px 16px; border-radius: 8px; font-weight: 700; cursor: pointer; display: inline-flex; align-items: center; gap: 8px; transition: 0.2s; }
|
||||
.btn-primary:hover { background: #e6006e; }
|
||||
|
||||
.filters-bar { display: flex; gap: 16px; margin-bottom: 24px; }
|
||||
.search-input { position: relative; flex: 1; max-width: 400px; }
|
||||
.search-input i { position: absolute; left: 14px; top: 50%; transform: translateY(-50%); color: #a1a1aa; width: 18px; height: 18px; }
|
||||
.search-input input { width: 100%; background: #111113; border: 1px solid #1f1f22; border-radius: 10px; padding: 12px 12px 12px 40px; color: #fff; font-size: 14px; outline: none; }
|
||||
.search-input input:focus { border-color: #3f3f46; }
|
||||
.filter-select { background: #111113; border: 1px solid #1f1f22; border-radius: 10px; padding: 12px 16px; color: #fff; font-size: 14px; outline: none; min-width: 150px; }
|
||||
|
||||
.table-container { background: #111113; border: 1px solid #1f1f22; border-radius: 16px; overflow: hidden; }
|
||||
.admin-table { width: 100%; border-collapse: collapse; text-align: left; }
|
||||
.admin-table th { background: #0c0c0e; padding: 16px; font-size: 12px; font-weight: 700; color: #a1a1aa; text-transform: uppercase; border-bottom: 1px solid #1f1f22; }
|
||||
.admin-table td { padding: 16px; border-bottom: 1px solid #1f1f22; font-size: 14px; color: #e4e4e7; }
|
||||
.admin-table tr:last-child td { border-bottom: none; }
|
||||
.admin-table tr:hover td { background: #18181b; }
|
||||
|
||||
.avatar { width: 32px; height: 32px; background: #27272a; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: 800; color: #fff; }
|
||||
|
||||
.badge { padding: 4px 8px; border-radius: 6px; font-size: 11px; font-weight: 800; text-transform: uppercase; }
|
||||
.bg-red { background: #ef4444; color: #fff; }
|
||||
.bg-blue { background: #3b82f6; color: #fff; }
|
||||
.bg-gray { background: #27272a; color: #a1a1aa; }
|
||||
.bg-red-dim { background: rgba(239, 68, 68, 0.1); border: 1px solid rgba(239, 68, 68, 0.2); }
|
||||
.bg-orange-dim { background: rgba(245, 158, 11, 0.1); border: 1px solid rgba(245, 158, 11, 0.2); }
|
||||
.bg-green-dim { background: rgba(16, 185, 129, 0.1); border: 1px solid rgba(16, 185, 129, 0.2); }
|
||||
.text-red { color: #ef4444; }
|
||||
.text-orange { color: #f59e0b; }
|
||||
.text-green { color: #10b981; }
|
||||
.text-muted { color: #a1a1aa; }
|
||||
|
||||
.btn-icon { display: inline-flex; background: transparent; border: none; color: #a1a1aa; cursor: pointer; padding: 8px; border-radius: 8px; transition: 0.2s; text-decoration: none; }
|
||||
.btn-icon:hover { background: #27272a; color: #fff; }
|
||||
|
||||
.pagination { padding: 16px; border-top: 1px solid #1f1f22; display: flex; justify-content: center; }
|
||||
</style>
|
||||
139
resources/js/pages/Admin/WalletsSettings.vue
Normal file
139
resources/js/pages/Admin/WalletsSettings.vue
Normal file
@@ -0,0 +1,139 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, useForm } from '@inertiajs/vue3';
|
||||
import { onMounted, nextTick } from 'vue';
|
||||
import AdminLayout from '@/layouts/admin/CasinoAdminLayout.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
settings: {
|
||||
pin_max_attempts: number;
|
||||
pin_lock_minutes: number;
|
||||
min_tx_btx: number;
|
||||
max_tx_btx: number;
|
||||
daily_max_btx: number;
|
||||
actions_per_minute: number;
|
||||
reason_required: boolean;
|
||||
};
|
||||
defaults: Record<string, any>;
|
||||
}>();
|
||||
|
||||
const form = useForm({
|
||||
pin_max_attempts: props.settings.pin_max_attempts ?? 5,
|
||||
pin_lock_minutes: props.settings.pin_lock_minutes ?? 15,
|
||||
min_tx_btx: props.settings.min_tx_btx ?? 0.0001,
|
||||
max_tx_btx: props.settings.max_tx_btx ?? 100000,
|
||||
daily_max_btx: props.settings.daily_max_btx ?? 100000,
|
||||
actions_per_minute: props.settings.actions_per_minute ?? 20,
|
||||
reason_required: props.settings.reason_required ?? true,
|
||||
});
|
||||
|
||||
async function submit() {
|
||||
await form.post('/admin/wallets/settings', { preserveScroll: true });
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => { if ((window as any).lucide) (window as any).lucide.createIcons(); });
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminLayout>
|
||||
<Head title="Admin – Wallet Einstellungen" />
|
||||
|
||||
<section class="content">
|
||||
<div class="wrap">
|
||||
<div class="panel">
|
||||
<header class="page-head">
|
||||
<div class="head-flex">
|
||||
<div class="title-group">
|
||||
<div class="title">Wallet/Vault – Einstellungen</div>
|
||||
<p class="subtitle">PIN‑Policy, Limits, Throttles und Vorgaben für manuelle Anpassungen.</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn primary" @click="submit" :disabled="form.processing">
|
||||
<i data-lucide="save"></i>
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<h3>Vault PIN Policy</h3>
|
||||
<div class="row">
|
||||
<label>Max Fehlversuche</label>
|
||||
<input type="number" min="1" max="20" v-model.number="form.pin_max_attempts" />
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Sperrdauer (Minuten)</label>
|
||||
<input type="number" min="1" max="1440" v-model.number="form.pin_lock_minutes" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Transfer‑Limits (BTX)</h3>
|
||||
<div class="row">
|
||||
<label>Minimum pro Transaktion</label>
|
||||
<input type="number" step="0.0001" min="0" v-model.number="form.min_tx_btx" />
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Maximum pro Transaktion</label>
|
||||
<input type="number" step="0.0001" min="0" v-model.number="form.max_tx_btx" />
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Tagesmaximum</label>
|
||||
<input type="number" step="0.0001" min="0" v-model.number="form.daily_max_btx" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Throttling</h3>
|
||||
<div class="row">
|
||||
<label>Aktionen pro Minute</label>
|
||||
<input type="number" min="1" max="600" v-model.number="form.actions_per_minute" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Manuelle Anpassungen</h3>
|
||||
<div class="row checkbox">
|
||||
<label>
|
||||
<input type="checkbox" v-model="form.reason_required" />
|
||||
Grund verpflichtend
|
||||
</label>
|
||||
</div>
|
||||
<small>Empfohlen aktiviert: Alle manuellen Gutschriften/Abzüge erfordern einen dokumentierten Grund.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="foot">
|
||||
<button class="btn primary" @click="submit" :disabled="form.processing">
|
||||
<i data-lucide="save"></i>
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.content { padding: 20px; }
|
||||
.wrap { max-width: 1100px; margin: 0 auto; }
|
||||
.panel { background: #0f0f10; border: 1px solid #18181b; border-radius: 12px; padding: 16px; }
|
||||
.page-head .title { font-size: 22px; font-weight: 700; }
|
||||
.subtitle { color: #a1a1aa; margin-top: 4px; }
|
||||
.actions { display: flex; gap: 10px; }
|
||||
.grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; margin-top: 16px; }
|
||||
@media (max-width: 900px) { .grid { grid-template-columns: 1fr; } }
|
||||
.card { background: #0b0b0c; border: 1px solid #1f1f22; border-radius: 10px; padding: 14px; }
|
||||
.card h3 { margin: 0 0 10px 0; font-size: 16px; }
|
||||
.row { display: grid; grid-template-columns: 220px 1fr; align-items: center; gap: 10px; margin: 10px 0; }
|
||||
.row.checkbox { grid-template-columns: 1fr; }
|
||||
label { font-weight: 600; color: #cbd5e1; }
|
||||
input, select { width: 100%; background: #0b0b0c; border: 1px solid #1f1f22; border-radius: 8px; padding: 10px; color: #e5e7eb; }
|
||||
.btn { background: #1f2937; border: 1px solid #374151; color: #e5e7eb; padding: 8px 14px; border-radius: 8px; cursor: pointer; }
|
||||
.btn.primary { background: #ff007a; border-color: #ff2b8f; color: white; }
|
||||
.foot { display: flex; justify-content: flex-end; margin-top: 16px; }
|
||||
</style>
|
||||
415
resources/js/pages/Bonus.vue
Normal file
415
resources/js/pages/Bonus.vue
Normal file
@@ -0,0 +1,415 @@
|
||||
<script setup lang="ts">
|
||||
import { Head } from '@inertiajs/vue3';
|
||||
import { Gift, Clock, Lock, Zap } from 'lucide-vue-next';
|
||||
import { ref, onMounted } from 'vue';
|
||||
import Button from '@/components/ui/button.vue';
|
||||
import UserLayout from '@/layouts/user/userlayout.vue';
|
||||
import { csrfFetch } from '@/utils/csrfFetch';
|
||||
|
||||
const activeTab = ref<'available' | 'active' | 'history'>('available');
|
||||
|
||||
// Reactive state from API
|
||||
const loading = ref(true);
|
||||
const error = ref<string | null>(null);
|
||||
const available = ref<any[]>([]);
|
||||
const active = ref<any[]>([]);
|
||||
const history = ref<any[]>([]);
|
||||
|
||||
function formatAmount(b: any): string {
|
||||
if (!b) return '';
|
||||
const unit = b.amount_unit;
|
||||
const val = b.amount_value;
|
||||
if (!unit || val == null) return '';
|
||||
if (unit === 'PERCENT') return `${val}%` + (b.max_amount ? ` up to ${formatCurrency(b.max_amount, b.currency)}` : '');
|
||||
if (unit === 'SPINS') return `${val} Free Spins`;
|
||||
// currency amounts
|
||||
return `${formatCurrency(val, b.currency || 'USD')}`;
|
||||
}
|
||||
function formatCurrency(v: number, cur: string): string {
|
||||
try {
|
||||
return new Intl.NumberFormat(undefined, { style: 'currency', currency: (cur || 'USD') }).format(v);
|
||||
} catch {
|
||||
return `${v} ${cur || ''}`.trim();
|
||||
}
|
||||
}
|
||||
function formatMinDeposit(b: any): string {
|
||||
if (b.min_deposit == null) return '—';
|
||||
if (b.currency) return formatCurrency(b.min_deposit, b.currency);
|
||||
return `${b.min_deposit}`;
|
||||
}
|
||||
|
||||
async function loadBonuses() {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const res = await fetch('/api/bonuses/app', { headers: { 'Accept': 'application/json' } });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const json = await res.json();
|
||||
available.value = json.available || [];
|
||||
active.value = json.active || [];
|
||||
history.value = json.history || [];
|
||||
} catch (e: any) {
|
||||
error.value = e?.message || 'Failed to load bonuses';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadBonuses);
|
||||
|
||||
// Promo redemption UI state
|
||||
const redeemCode = ref<string>('');
|
||||
const redeemProcessing = ref<boolean>(false);
|
||||
const redeemMessage = ref<string | null>(null);
|
||||
const redeemError = ref<string | null>(null);
|
||||
|
||||
async function applyPromo() {
|
||||
redeemProcessing.value = true;
|
||||
redeemMessage.value = null;
|
||||
redeemError.value = null;
|
||||
try {
|
||||
const code = (redeemCode.value || '').trim().toUpperCase();
|
||||
if (!code) {
|
||||
redeemProcessing.value = false;
|
||||
redeemError.value = 'Bitte gib einen Code ein.';
|
||||
return;
|
||||
}
|
||||
const res = await csrfFetch('/api/promos/apply', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ code }),
|
||||
});
|
||||
const json = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
let msg = json?.message || '';
|
||||
if (res.status === 401) msg = 'Bitte einloggen, um einen Promo-Code einzulösen.';
|
||||
else if (res.status === 403) msg = msg || 'Du hast keine Berechtigung, diesen Code einzulösen.';
|
||||
else if (res.status === 419) msg = 'Sicherheits-Token abgelaufen. Bitte Seite neu laden und erneut versuchen.';
|
||||
else if (res.status === 422) msg = msg || 'Ungültiger oder inaktiver Promo-Code.';
|
||||
else if (res.status === 429) msg = msg || 'Zu viele Versuche. Bitte später erneut versuchen.';
|
||||
throw new Error(msg || `Fehler (${res.status}).`);
|
||||
}
|
||||
redeemMessage.value = json?.message || 'Promo applied successfully';
|
||||
redeemCode.value = '';
|
||||
await loadBonuses();
|
||||
} catch (e: any) {
|
||||
redeemError.value = e?.message || 'Failed to apply promo';
|
||||
} finally {
|
||||
redeemProcessing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Static placeholder for coming soon
|
||||
const comingSoon = [
|
||||
{ id: 4, title: 'VIP Cashback', amount: '10% Weekly', unlock: 'Level 5' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UserLayout>
|
||||
<Head :title="$t('bonuses.title')" />
|
||||
|
||||
<div class="bonus-content">
|
||||
<div class="glow-orb orb-1"></div>
|
||||
<div class="glow-orb orb-2"></div>
|
||||
|
||||
<h1 class="page-title">{{ $t('bonuses.title') }}</h1>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="tabs-container">
|
||||
<div class="tabs">
|
||||
<button @click="activeTab = 'available'" :class="{ active: activeTab === 'available' }"><Gift class="w-4 h-4" /> {{ $t('bonuses.tabs.available') }}</button>
|
||||
<button @click="activeTab = 'active'" :class="{ active: activeTab === 'active' }"><Zap class="w-4 h-4" /> {{ $t('bonuses.tabs.active') }}</button>
|
||||
<button @click="activeTab = 'history'" :class="{ active: activeTab === 'history' }"><Clock class="w-4 h-4" /> {{ $t('bonuses.tabs.history') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-content">
|
||||
<!-- Redeem promo code -->
|
||||
<div class="glass-card p-4 mb-6">
|
||||
<h2 class="text-lg font-semibold mb-2">{{ $t('bonus.promo_title') }}</h2>
|
||||
<form class="flex flex-col sm:flex-row gap-3 items-stretch sm:items-end" @submit.prevent="applyPromo">
|
||||
<input
|
||||
v-model="redeemCode"
|
||||
type="text"
|
||||
:placeholder="$t('bonus.promo_placeholder')"
|
||||
class="flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm focus:outline-none"
|
||||
required
|
||||
/>
|
||||
<Button type="submit" :disabled="redeemProcessing || !redeemCode">
|
||||
{{ redeemProcessing ? $t('bonus.promo_redeeming') : $t('bonus.promo_redeem') }}
|
||||
</Button>
|
||||
</form>
|
||||
<p v-if="redeemMessage" class="mt-2 text-emerald-500 text-sm">{{ redeemMessage }}</p>
|
||||
<p v-if="redeemError" class="mt-2 text-rose-400 text-sm">{{ redeemError }}</p>
|
||||
</div>
|
||||
<!-- Loading & Error -->
|
||||
<div v-if="loading" class="bonus-grid">
|
||||
<div v-for="i in 3" :key="i" class="bonus-card glass-card skeleton"></div>
|
||||
</div>
|
||||
<div v-else-if="error" class="glass-card" style="padding:16px;color:#fca5a5;border-color:#7f1d1d;background:rgba(127,29,29,0.2)">
|
||||
{{ $t('bonuses.errorLoad') }}: {{ error }}
|
||||
</div>
|
||||
|
||||
<!-- Available -->
|
||||
<transition name="fade" mode="out-in">
|
||||
<div v-if="!loading && activeTab === 'available'" class="bonus-grid">
|
||||
<!-- Hero Bonus (first available if any) -->
|
||||
<div v-if="available.length" class="bonus-card hero">
|
||||
<div class="card-bg"></div>
|
||||
<div class="card-content">
|
||||
<div class="badge">{{ $t('bonuses.featured') }}</div>
|
||||
<h2>{{ available[0].title }}</h2>
|
||||
<p class="amount">{{ formatAmount(available[0]) }}</p>
|
||||
<p class="desc">{{ $t('bonuses.minDeposit') }}: {{ formatMinDeposit(available[0]) }}</p>
|
||||
<Button class="neon-button w-full mt-4">{{ $t('bonuses.claim') }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-for="b in available.slice(1)" :key="b.id" class="bonus-card glass-card">
|
||||
<div class="card-header">
|
||||
<div class="icon-box"><Gift class="w-6 h-6" /></div>
|
||||
<div class="type">{{ b.type || $t('bonuses.bonus') }}</div>
|
||||
</div>
|
||||
<h3>{{ b.title }}</h3>
|
||||
<div class="amount-small">{{ formatAmount(b) }}</div>
|
||||
<div class="meta">{{ $t('bonuses.minDeposit') }}: {{ formatMinDeposit(b) }}</div>
|
||||
<Button variant="secondary" class="w-full mt-4">{{ $t('bonuses.activate') }}</Button>
|
||||
</div>
|
||||
|
||||
<!-- Coming Soon -->
|
||||
<div v-for="b in comingSoon" :key="b.id" class="bonus-card glass-card locked">
|
||||
<div class="lock-overlay"><Lock class="w-8 h-8 text-[#666]" /></div>
|
||||
<h3>{{ b.title }}</h3>
|
||||
<div class="amount-small">{{ b.amount }}</div>
|
||||
<div class="meta">{{ $t('bonus.unlock_at') }} {{ b.unlock }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- Active -->
|
||||
<transition name="fade" mode="out-in">
|
||||
<div v-if="!loading && activeTab === 'active'" class="active-list">
|
||||
<div v-for="b in active" :key="b.id" class="active-card glass-card">
|
||||
<div class="active-header">
|
||||
<h3>{{ b.title }}</h3>
|
||||
<div class="expires" v-if="b.expires_at"><Clock class="w-3 h-3 inline" />
|
||||
{{ new Date(b.expires_at).toLocaleString() }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="active-amount">{{ formatAmount(b) }}</div>
|
||||
|
||||
<div class="wager-section" v-if="b.progress != null">
|
||||
<div class="wager-info">
|
||||
<span>{{ $t('bonuses.wagerProgress') }}</span>
|
||||
<span>{{ b.progress }}%</span>
|
||||
</div>
|
||||
<div class="progress-bar">
|
||||
<div class="fill" :style="{ width: `${b.progress}%` }"></div>
|
||||
</div>
|
||||
<div class="wager-details" v-if="b.wagered != null && b.wager_total != null">
|
||||
{{ $t('bonuses.wageredOf', { wagered: b.wagered, total: b.wager_total }) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="active.length === 0" class="empty-state">
|
||||
{{ $t('bonuses.noActive') }}
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- History -->
|
||||
<transition name="fade" mode="out-in">
|
||||
<div v-if="!loading && activeTab === 'history'" class="active-list">
|
||||
<div v-if="history.length === 0" class="empty-state">{{ $t('bonuses.noHistory') }}</div>
|
||||
<div v-for="b in history" :key="b.id" class="active-card glass-card">
|
||||
<div class="active-header">
|
||||
<h3>{{ b.title }}</h3>
|
||||
<div class="expires"><Clock class="w-3 h-3 inline" /> {{ $t('bonuses.ended') }}</div>
|
||||
</div>
|
||||
<div class="active-amount">{{ formatAmount(b) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</UserLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bonus-content {
|
||||
padding: 30px;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 28px;
|
||||
font-weight: 900;
|
||||
color: white;
|
||||
margin-bottom: 30px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
position: relative; z-index: 2;
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.tabs-container { margin-bottom: 30px; position: relative; z-index: 2; }
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
background: rgba(10, 10, 10, 0.8);
|
||||
backdrop-filter: blur(10px);
|
||||
padding: 5px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #151515;
|
||||
width: fit-content;
|
||||
}
|
||||
.tabs button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #666;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tabs button:hover { color: white; background: rgba(255,255,255,0.05); }
|
||||
.tabs button.active {
|
||||
background: #1a1a1a;
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
|
||||
border: 1px solid #222;
|
||||
}
|
||||
.tabs button.active svg { color: #ff007a; }
|
||||
|
||||
/* Grid */
|
||||
.bonus-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.bonus-card {
|
||||
border-radius: 20px;
|
||||
padding: 25px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.glass-card {
|
||||
background: rgba(10, 10, 10, 0.6);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
.glass-card:hover { transform: translateY(-5px); border-color: #333; }
|
||||
|
||||
/* Hero Card */
|
||||
.bonus-card.hero {
|
||||
grid-column: 1 / -1;
|
||||
background: linear-gradient(135deg, #0f0f0f 0%, #1a1a1a 100%);
|
||||
border: 1px solid #222;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 250px;
|
||||
}
|
||||
.card-bg {
|
||||
position: absolute; inset: 0;
|
||||
background: radial-gradient(circle at 80% 50%, rgba(255,0,122,0.15) 0%, transparent 60%);
|
||||
}
|
||||
.card-content { position: relative; z-index: 2; max-width: 500px; }
|
||||
.badge { background: #ff007a; color: white; padding: 4px 10px; border-radius: 4px; font-size: 10px; font-weight: 900; display: inline-block; margin-bottom: 15px; }
|
||||
.hero h2 { font-size: 32px; font-weight: 900; color: white; margin-bottom: 5px; }
|
||||
.hero .amount { font-size: 18px; color: #00f2ff; font-weight: 700; margin-bottom: 10px; }
|
||||
.hero .desc { font-size: 14px; color: #888; }
|
||||
|
||||
/* Standard Card */
|
||||
.card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 15px; }
|
||||
.icon-box { width: 40px; height: 40px; background: rgba(255,255,255,0.05); border-radius: 10px; display: flex; align-items: center; justify-content: center; }
|
||||
.type { font-size: 10px; font-weight: 700; color: #666; text-transform: uppercase; border: 1px solid #333; padding: 2px 6px; border-radius: 4px; }
|
||||
.bonus-card h3 { font-size: 18px; font-weight: 800; color: white; margin-bottom: 5px; }
|
||||
.amount-small { font-size: 14px; color: #00f2ff; font-weight: 700; margin-bottom: 15px; }
|
||||
.meta { font-size: 12px; color: #666; }
|
||||
|
||||
/* Locked Card */
|
||||
.locked { opacity: 0.5; cursor: not-allowed; }
|
||||
.lock-overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,0.5); z-index: 10; }
|
||||
|
||||
/* Active Bonus */
|
||||
.active-list { display: flex; flex-direction: column; gap: 15px; }
|
||||
.active-card { padding: 25px; }
|
||||
.active-header { display: flex; justify-content: space-between; margin-bottom: 10px; }
|
||||
.active-header h3 { font-size: 16px; font-weight: 800; color: white; }
|
||||
.expires { font-size: 12px; color: #ff007a; font-weight: 600; }
|
||||
.active-amount { font-size: 24px; font-weight: 900; color: #00f2ff; margin-bottom: 20px; }
|
||||
.wager-info { display: flex; justify-content: space-between; font-size: 12px; color: #ccc; margin-bottom: 5px; font-weight: 600; }
|
||||
.progress-bar { height: 6px; background: #222; border-radius: 3px; overflow: hidden; margin-bottom: 8px; }
|
||||
.fill { height: 100%; background: linear-gradient(90deg, #00f2ff, #00ff9d); border-radius: 3px; }
|
||||
.wager-details { font-size: 11px; color: #666; text-align: right; }
|
||||
|
||||
/* Neon Button */
|
||||
.neon-button { background: linear-gradient(90deg, #ff007a, #be005b); border: none; position: relative; overflow: hidden; transition: all 0.3s ease; color: white; }
|
||||
.neon-button:hover { transform: translateY(-2px); box-shadow: 0 0 30px rgba(255, 0, 122, 0.6); filter: brightness(1.1); }
|
||||
|
||||
/* Background Orbs */
|
||||
.glow-orb {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(100px);
|
||||
opacity: 0.3;
|
||||
z-index: 0;
|
||||
animation: float 10s infinite ease-in-out;
|
||||
}
|
||||
.orb-1 { width: 400px; height: 400px; background: #ff007a; top: -100px; left: -100px; }
|
||||
.orb-2 { width: 500px; height: 500px; background: #00f2ff; bottom: -100px; right: -100px; animation-delay: -5s; }
|
||||
|
||||
@keyframes float { 0%, 100% { transform: translate(0, 0); } 50% { transform: translate(30px, 50px); } }
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s ease; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
|
||||
/* Skeleton loader */
|
||||
.skeleton {
|
||||
min-height: 160px;
|
||||
background: linear-gradient(90deg, rgba(255,255,255,0.04) 25%, rgba(255,255,255,0.08) 37%, rgba(255,255,255,0.04) 63%);
|
||||
background-size: 400% 100%;
|
||||
animation: shimmer 1.2s ease-in-out infinite;
|
||||
border-radius: 20px;
|
||||
}
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 100% 0; }
|
||||
100% { background-position: -100% 0; }
|
||||
}
|
||||
|
||||
.empty-state { color: #777; text-align: center; padding: 20px; }
|
||||
|
||||
/* Mobile tweaks */
|
||||
@media (max-width: 900px) {
|
||||
.bonus-content { padding: 20px 16px; }
|
||||
.page-title { font-size: clamp(20px, 5.6vw, 26px); }
|
||||
.tabs { width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
||||
.tabs::-webkit-scrollbar { display: none; }
|
||||
.tabs button { padding: 8px 12px; font-size: 12px; }
|
||||
.bonus-grid { grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 14px; }
|
||||
.bonus-card { padding: 18px; }
|
||||
.bonus-card.hero { min-height: 200px; }
|
||||
.hero h2 { font-size: clamp(18px, 5.5vw, 26px); }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.bonus-grid { grid-template-columns: 1fr; }
|
||||
.card-content { max-width: 100%; }
|
||||
.active-card { padding: 18px; }
|
||||
.active-amount { font-size: 20px; }
|
||||
}
|
||||
</style>
|
||||
1189
resources/js/pages/Dashboard.vue
Normal file
1189
resources/js/pages/Dashboard.vue
Normal file
File diff suppressed because it is too large
Load Diff
176
resources/js/pages/Errors/Banned.vue
Normal file
176
resources/js/pages/Errors/Banned.vue
Normal file
@@ -0,0 +1,176 @@
|
||||
<script setup lang="ts">
|
||||
import { Head } from '@inertiajs/vue3';
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
reason?: string;
|
||||
ends_at?: string | null;
|
||||
}>();
|
||||
|
||||
const nowTs = ref<number>(Date.now());
|
||||
let ticker: number | undefined;
|
||||
|
||||
onMounted(() => {
|
||||
// Tick every second to update countdown
|
||||
ticker = window.setInterval(() => { nowTs.value = Date.now(); }, 1000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (ticker) window.clearInterval(ticker);
|
||||
});
|
||||
|
||||
const endsAtDate = computed<Date | null>(() => {
|
||||
if (!props.ends_at) return null;
|
||||
const d = new Date(props.ends_at);
|
||||
return isNaN(d.getTime()) ? null : d;
|
||||
});
|
||||
|
||||
const remainingMs = computed<number>(() => {
|
||||
if (!endsAtDate.value) return 0;
|
||||
return Math.max(0, endsAtDate.value.getTime() - nowTs.value);
|
||||
});
|
||||
|
||||
function formatCountdown(ms: number): string {
|
||||
const totalSec = Math.floor(ms / 1000);
|
||||
const days = Math.floor(totalSec / 86400);
|
||||
const hours = Math.floor((totalSec % 86400) / 3600);
|
||||
const minutes = Math.floor((totalSec % 3600) / 60);
|
||||
const seconds = totalSec % 60;
|
||||
const pad = (n: number) => n.toString().padStart(2, '0');
|
||||
if (days > 0) return `${days}d ${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
|
||||
return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
|
||||
}
|
||||
|
||||
const countdownText = computed<string | null>(() => {
|
||||
if (!endsAtDate.value || remainingMs.value <= 0) return null;
|
||||
return formatCountdown(remainingMs.value);
|
||||
});
|
||||
|
||||
const exactEndText = computed<string | null>(() => {
|
||||
if (!endsAtDate.value) return null;
|
||||
try {
|
||||
return endsAtDate.value.toLocaleString();
|
||||
} catch {
|
||||
return endsAtDate.value.toISOString();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head title="Account Suspended" />
|
||||
<div class="banned-screen">
|
||||
<div class="overlay"></div>
|
||||
<div class="content">
|
||||
<div class="stamp">BANNED</div>
|
||||
<h1>Account Suspended</h1>
|
||||
<p class="reason" v-if="reason">Reason: {{ reason }}</p>
|
||||
<p v-if="countdownText && exactEndText" class="sub">Ends in <strong>{{ countdownText }}</strong> ({{ exactEndText }})</p>
|
||||
<p v-else class="sub">Your account has been permanently suspended due to a violation of our Terms of Service.</p>
|
||||
|
||||
<div class="actions">
|
||||
<a href="/logout" class="btn-logout" onclick="event.preventDefault(); document.getElementById('logout-form').submit();">Sign Out</a>
|
||||
<form id="logout-form" action="/logout" method="POST" style="display: none;">
|
||||
<input type="hidden" name="_token" :value="$page.props.csrf_token">
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.banned-screen {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
background-size: cover;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
font-family: 'Impact', sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: absolute; inset: 0;
|
||||
background: radial-gradient(circle, rgba(0,0,0,0.4) 0%, rgba(0,0,0,0.9) 100%);
|
||||
backdrop-filter: blur(3px);
|
||||
}
|
||||
|
||||
.content {
|
||||
position: relative; z-index: 10;
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
animation: zoomIn 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
}
|
||||
|
||||
.stamp {
|
||||
font-size: 80px;
|
||||
color: #ff3e3e;
|
||||
border: 8px solid #ff3e3e;
|
||||
display: inline-block;
|
||||
padding: 10px 40px;
|
||||
transform: rotate(-10deg);
|
||||
margin-bottom: 30px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 5px;
|
||||
opacity: 0;
|
||||
animation: stamp-fall 0.4s cubic-bezier(0.6, 0.04, 0.98, 0.335) 0.5s forwards;
|
||||
mask-image: url('https://s3-us-west-2.amazonaws.com/s.cdpn.io/8399/grunge.png'); /* Grunge texture effect */
|
||||
mask-size: 900px;
|
||||
mix-blend-mode: multiply;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-family: 'Helvetica Neue', sans-serif;
|
||||
font-size: 32px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 2px;
|
||||
margin-bottom: 10px;
|
||||
text-shadow: 0 2px 10px rgba(0,0,0,0.8);
|
||||
}
|
||||
|
||||
.reason {
|
||||
font-size: 18px;
|
||||
color: #ffaaaa;
|
||||
background: rgba(255, 0, 0, 0.1);
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
display: inline-block;
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid rgba(255, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.sub {
|
||||
font-family: sans-serif;
|
||||
font-size: 14px;
|
||||
color: #ccc;
|
||||
max-width: 400px;
|
||||
margin: 0 auto 40px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.btn-logout {
|
||||
background: #fff;
|
||||
color: #000;
|
||||
padding: 12px 30px;
|
||||
text-decoration: none;
|
||||
font-weight: 900;
|
||||
text-transform: uppercase;
|
||||
border-radius: 4px;
|
||||
transition: 0.2s;
|
||||
}
|
||||
.btn-logout:hover {
|
||||
background: #ccc;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
@keyframes stamp-fall {
|
||||
0% { opacity: 0; transform: rotate(-10deg) scale(3); }
|
||||
100% { opacity: 1; transform: rotate(-10deg) scale(1); }
|
||||
}
|
||||
|
||||
@keyframes zoomIn {
|
||||
from { opacity: 0; transform: scale(0.8); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
</style>
|
||||
120
resources/js/pages/Faq.vue
Normal file
120
resources/js/pages/Faq.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<script setup lang="ts">
|
||||
import { Head } from '@inertiajs/vue3';
|
||||
import { ref, onMounted, nextTick } from 'vue';
|
||||
import UserLayout from '../layouts/user/userlayout.vue';
|
||||
|
||||
const faqs = ref([
|
||||
{
|
||||
q: 'What is BetiX?',
|
||||
a: 'BetiX is an online gaming platform. Create an account, verify your email, deposit funds responsibly, and enjoy games. Always follow your local laws and our terms.'
|
||||
},
|
||||
{
|
||||
q: 'How do I secure my account?',
|
||||
a: 'Use a strong, unique password and enable two-factor authentication (2FA). We also email you when a new login is detected so you can react quickly if it was not you.'
|
||||
},
|
||||
{
|
||||
q: 'I did not receive an email. What should I do?',
|
||||
a: 'Check your spam folder and ensure your email address is correct. If you still have issues, try again in a few minutes or contact support at the address in the footer.'
|
||||
},
|
||||
{
|
||||
q: 'What are responsible gaming limits?',
|
||||
a: 'You can set optional limits for losses, wagers, session duration, and timeouts to help manage your play. Adjust these from the Responsible Gaming section in your account.'
|
||||
},
|
||||
{
|
||||
q: 'Why do I see “Too many requests” (429)?',
|
||||
a: 'Rate limiting protects the platform from abuse. If you hit this limit, wait a minute and try again. Avoid running automated scripts or rapidly refreshing pages.'
|
||||
},
|
||||
{
|
||||
q: 'Which browsers are supported?',
|
||||
a: 'We support the latest versions of Chrome, Firefox, Safari, and Edge. Enable cookies and JavaScript for the best experience.'
|
||||
},
|
||||
{
|
||||
q: 'How do I contact support?',
|
||||
a: 'You can reach us via the email address listed in the site footer or the Help/Support section of your account once logged in.'
|
||||
},
|
||||
]);
|
||||
|
||||
const expanded = ref<Record<number, boolean>>({});
|
||||
|
||||
const toggle = (idx: number) => {
|
||||
expanded.value[idx] = !expanded.value[idx];
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
if (window.lucide) window.lucide.createIcons();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UserLayout>
|
||||
<Head title="FAQ" />
|
||||
<section class="content">
|
||||
<div class="wrap">
|
||||
<div class="panel">
|
||||
<header class="page-head">
|
||||
<div class="title">Frequently Asked Questions</div>
|
||||
<p class="subtitle">Answers to common questions about accounts, security, and responsible gaming.</p>
|
||||
</header>
|
||||
|
||||
<div class="grid">
|
||||
<div class="left">
|
||||
<div v-for="(item, idx) in faqs" :key="idx" class="faq-item" :class="{ open: expanded[idx] }">
|
||||
<button class="q" @click="toggle(idx)" :aria-expanded="expanded[idx] ? 'true' : 'false'" :aria-controls="'faq-'+idx">
|
||||
<span class="q-text">{{ item.q }}</span>
|
||||
<i data-lucide="chevron-down" class="chev" aria-hidden="true"></i>
|
||||
</button>
|
||||
<div class="a" :id="'faq-'+idx" v-show="expanded[idx]">
|
||||
<p>{{ item.a }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<aside class="right">
|
||||
<div class="side-card">
|
||||
<div class="card-head">Need more help?</div>
|
||||
<p class="card-text">If your question isn’t listed here, reach out to our support team.</p>
|
||||
<ul class="help-list">
|
||||
<li><i data-lucide="mail" class="icon"></i> Email: <a href="mailto:support@example.com">Contact Support</a></li>
|
||||
<li><i data-lucide="shield" class="icon"></i> Read our <a href="/self-exclusion">Responsible Gaming</a> info</li>
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</UserLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.content { padding: 30px; }
|
||||
.wrap { max-width: 1100px; margin: 0 auto; }
|
||||
.panel { background: var(--bg-card, #0a0a0a); border: 1px solid var(--border, #151515); border-radius: 16px; overflow: hidden; }
|
||||
.page-head { padding: 20px; border-bottom: 1px solid var(--border, #151515); }
|
||||
.title { font-size: 20px; font-weight: 900; color: #fff; letter-spacing: 2px; text-transform: uppercase; }
|
||||
.subtitle { color: #9aa0a6; margin-top: 6px; }
|
||||
|
||||
.grid { padding: 20px; display: grid; grid-template-columns: 1.6fr .8fr; gap: 22px; }
|
||||
.left { display: flex; flex-direction: column; gap: 12px; }
|
||||
|
||||
.faq-item { border: 1px solid var(--border, #151515); background: #0a0a0a; border-radius: 14px; overflow: hidden; }
|
||||
.q { width: 100%; text-align: left; background: transparent; color: #e6e6e6; padding: 16px 18px; border: 0; display: flex; align-items: center; justify-content: space-between; gap: 12px; cursor: pointer; font-weight: 800; }
|
||||
.q:hover { background: rgba(255,255,255,0.02); }
|
||||
.q-text { font-size: 14px; }
|
||||
.chev { width: 18px; color: #aaa; transition: transform .2s ease; }
|
||||
.faq-item.open .chev { transform: rotate(180deg); }
|
||||
.a { padding: 0 18px 16px; color: #cfcfcf; font-size: 14px; line-height: 1.6; }
|
||||
|
||||
.side-card { border: 1px solid var(--border, #151515); background: #0a0a0a; border-radius: 14px; padding: 16px; position: sticky; top: 20px; }
|
||||
.card-head { color: #fff; font-weight: 900; margin-bottom: 8px; }
|
||||
.card-text { color: #9aa0a6; margin-bottom: 8px; }
|
||||
.help-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 10px; }
|
||||
.help-list a { color: var(--cyan, #00f2ff); text-decoration: none; }
|
||||
.icon { width: 16px; color: #666; margin-right: 6px; vertical-align: -2px; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.grid { grid-template-columns: 1fr; padding: 16px; }
|
||||
.side-card { position: static; }
|
||||
}
|
||||
</style>
|
||||
1560
resources/js/pages/GamePlay.vue
Normal file
1560
resources/js/pages/GamePlay.vue
Normal file
File diff suppressed because it is too large
Load Diff
154
resources/js/pages/GeoBlocked.vue
Normal file
154
resources/js/pages/GeoBlocked.vue
Normal file
@@ -0,0 +1,154 @@
|
||||
<script setup lang="ts">
|
||||
import { Head } from '@inertiajs/vue3';
|
||||
|
||||
const props = defineProps<{
|
||||
message?: string;
|
||||
reason?: 'vpn' | 'country' | string;
|
||||
}>();
|
||||
|
||||
const title = props.reason === 'vpn' ? 'VPN erkannt' : 'Region gesperrt';
|
||||
const icon = props.reason === 'vpn' ? '🛡️' : '🌍';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head title="Zugang gesperrt" />
|
||||
|
||||
<div class="blocked-page">
|
||||
<div class="noise"></div>
|
||||
<div class="glow"></div>
|
||||
|
||||
<div class="card">
|
||||
<div class="icon-wrap">
|
||||
<span class="icon">{{ icon }}</span>
|
||||
</div>
|
||||
|
||||
<h1 class="title">{{ title }}</h1>
|
||||
|
||||
<p class="message">
|
||||
{{ message || 'Dieser Dienst ist in deiner Region nicht verfügbar.' }}
|
||||
</p>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<p class="sub">
|
||||
<span v-if="reason === 'vpn'">
|
||||
Bitte deaktiviere dein VPN oder deinen Proxy und versuche es erneut.
|
||||
</span>
|
||||
<span v-else>
|
||||
Falls du glaubst, dass dies ein Fehler ist, kontaktiere bitte den Support.
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div class="code">403</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
.blocked-page {
|
||||
min-height: 100vh;
|
||||
background: #050505;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: 'Inter', sans-serif;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.noise {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='0.03'/%3E%3C/svg%3E");
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.glow {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -60%);
|
||||
width: 600px;
|
||||
height: 600px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(223,0,106,0.12) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
background: rgba(15, 15, 16, 0.95);
|
||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||
border-radius: 20px;
|
||||
padding: 48px 40px;
|
||||
max-width: 480px;
|
||||
width: 90%;
|
||||
text-align: center;
|
||||
box-shadow: 0 40px 80px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(223,0,106,0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.icon-wrap {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: rgba(223,0,106,0.08);
|
||||
border: 1px solid rgba(223,0,106,0.2);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 24px;
|
||||
animation: pulse 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(223,0,106,0.2); }
|
||||
50% { box-shadow: 0 0 0 12px rgba(223,0,106,0); }
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 36px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 28px;
|
||||
font-weight: 900;
|
||||
color: #fff;
|
||||
letter-spacing: -0.5px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.message {
|
||||
color: #a1a1aa;
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: rgba(255,255,255,0.06);
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
|
||||
.sub {
|
||||
color: #52525b;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.code {
|
||||
font-size: 72px;
|
||||
font-weight: 900;
|
||||
color: rgba(223,0,106,0.12);
|
||||
letter-spacing: -4px;
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
30
resources/js/pages/Maintenance.vue
Normal file
30
resources/js/pages/Maintenance.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { Head } from '@inertiajs/vue3';
|
||||
defineProps<{ message?: string }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head title="Wartung" />
|
||||
<div class="page">
|
||||
<div class="glow"></div>
|
||||
<div class="card">
|
||||
<div class="icon-wrap"><span class="icon">🔧</span></div>
|
||||
<h1>Wartungsmodus</h1>
|
||||
<p>{{ message || 'Wir führen gerade Wartungsarbeiten durch. Bitte komm später zurück.' }}</p>
|
||||
<div class="code">503</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
.page { min-height: 100vh; background: #050505; display: flex; align-items: center; justify-content: center; font-family: 'Inter', sans-serif; position: relative; overflow: hidden; }
|
||||
.glow { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -60%); width: 500px; height: 500px; border-radius: 50%; background: radial-gradient(circle, rgba(223,0,106,0.1) 0%, transparent 70%); pointer-events: none; }
|
||||
.card { position: relative; z-index: 10; background: rgba(15,15,16,0.95); border: 1px solid rgba(255,255,255,0.07); border-radius: 20px; padding: 48px 40px; max-width: 460px; width: 90%; text-align: center; box-shadow: 0 40px 80px rgba(0,0,0,0.6); }
|
||||
.icon-wrap { width: 80px; height: 80px; background: rgba(223,0,106,0.08); border: 1px solid rgba(223,0,106,0.2); border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto 24px; animation: pulse 3s ease-in-out infinite; }
|
||||
@keyframes pulse { 0%, 100% { box-shadow: 0 0 0 0 rgba(223,0,106,0.2); } 50% { box-shadow: 0 0 0 12px rgba(223,0,106,0); } }
|
||||
.icon { font-size: 36px; }
|
||||
h1 { font-size: 28px; font-weight: 900; color: #fff; letter-spacing: -0.5px; margin-bottom: 16px; }
|
||||
p { color: #a1a1aa; font-size: 15px; line-height: 1.6; margin-bottom: 32px; }
|
||||
.code { font-size: 72px; font-weight: 900; color: rgba(223,0,106,0.12); letter-spacing: -4px; line-height: 1; user-select: none; }
|
||||
</style>
|
||||
1472
resources/js/pages/Social/Hub.vue
Normal file
1472
resources/js/pages/Social/Hub.vue
Normal file
File diff suppressed because it is too large
Load Diff
930
resources/js/pages/Social/Profile.vue
Normal file
930
resources/js/pages/Social/Profile.vue
Normal file
@@ -0,0 +1,930 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, useForm, usePage } from '@inertiajs/vue3';
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import html2canvas from 'html2canvas';
|
||||
import { useNotifications } from '@/composables/useNotifications';
|
||||
import {
|
||||
Flag, X, CheckCircle2, AlertCircle, ChevronRight, Loader2,
|
||||
MessageSquareOff, UserX, ShieldAlert, BadgeDollarSign, HelpCircle
|
||||
} from 'lucide-vue-next';
|
||||
import UserLayout from '@/layouts/user/userlayout.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
profile: any;
|
||||
isOwnProfile: boolean;
|
||||
isFriend: boolean;
|
||||
isPending?: boolean;
|
||||
theyRequestedMe?: boolean;
|
||||
friendRowId?: number | null;
|
||||
hasLiked: boolean;
|
||||
}>();
|
||||
|
||||
const { notify } = useNotifications();
|
||||
const stats = props.profile.stats;
|
||||
const bestWins = props.profile.best_wins || [];
|
||||
const comments = ref(props.profile.comments || []);
|
||||
|
||||
// Animation for stats
|
||||
const animatedWager = ref(0);
|
||||
onMounted(() => {
|
||||
const target = parseFloat(stats.wagered || 0);
|
||||
const duration = 1500;
|
||||
const start = performance.now();
|
||||
|
||||
const step = (timestamp: number) => {
|
||||
const progress = Math.min((timestamp - start) / duration, 1);
|
||||
animatedWager.value = target * (1 - Math.pow(1 - progress, 3));
|
||||
if (progress < 1) requestAnimationFrame(step);
|
||||
};
|
||||
requestAnimationFrame(step);
|
||||
});
|
||||
|
||||
// Rank & Role Logic
|
||||
const vipLevelsConfig = [
|
||||
{ name: 'Newbie', color: '#888888' },
|
||||
{ name: 'Bronze', color: '#cd7f32' },
|
||||
{ name: 'Silver', color: '#c0c0c0' },
|
||||
{ name: 'Gold', color: '#ffd700' },
|
||||
{ name: 'Platinum', color: '#00f2ff' },
|
||||
{ name: 'Diamond', color: '#ff007a' },
|
||||
{ name: 'Obsidian', color: '#ff3e3e' }
|
||||
];
|
||||
|
||||
const currentVipStyle = computed(() => {
|
||||
const level = props.profile.vip_level || 0;
|
||||
const idx = Math.min(Math.max(level, 0), vipLevelsConfig.length - 1);
|
||||
return vipLevelsConfig[idx];
|
||||
});
|
||||
|
||||
const roleConfig = computed(() => {
|
||||
const role = (props.profile.role || 'User').toLowerCase(); // Case insensitive check
|
||||
let color = '#888';
|
||||
let effectClass = '';
|
||||
let displayName = props.profile.role || 'User';
|
||||
|
||||
if (role === 'admin') {
|
||||
color = '#ff3e3e';
|
||||
effectClass = 'role-admin';
|
||||
displayName = 'ADMIN';
|
||||
} else if (role === 'mod' || role === 'staff') {
|
||||
color = '#00f2ff';
|
||||
effectClass = 'role-staff';
|
||||
displayName = 'STAFF';
|
||||
} else if (role === 'streamer') {
|
||||
color = '#a855f7';
|
||||
effectClass = 'role-streamer';
|
||||
displayName = 'STREAMER';
|
||||
}
|
||||
|
||||
return { name: displayName, color, effectClass };
|
||||
});
|
||||
|
||||
// Comment Logic
|
||||
const commentForm = useForm({ content: '' });
|
||||
const submitComment = () => {
|
||||
if (!commentForm.content.trim()) return;
|
||||
commentForm.post(`/profile/${props.profile.id}/comment`, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
commentForm.reset();
|
||||
notify({ type: 'green', title: 'Posted', desc: 'Comment added.', icon: 'message-circle' });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Report Logic
|
||||
const isReportOpen = ref(false);
|
||||
const reportReason = ref('');
|
||||
const reportSubmitting = ref(false);
|
||||
const reportDone = ref(false);
|
||||
|
||||
const reportReasons = [
|
||||
{ value: 'spam', label: 'Spam', desc: 'Unerwünschte Werbung oder Wiederholungen', icon: MessageSquareOff, color: '#f59e0b' },
|
||||
{ value: 'harassment', label: 'Belästigung', desc: 'Belästigendes oder feindseliges Verhalten', icon: UserX, color: '#ef4444' },
|
||||
{ value: 'inappropriate', label: 'Unangemessenes Profil', desc: 'Anstößige Profilbilder oder Inhalte', icon: ShieldAlert, color: '#f97316' },
|
||||
{ value: 'fake', label: 'Fake-Profil', desc: 'Vortäuschung einer anderen Identität', icon: BadgeDollarSign, color: '#a855f7' },
|
||||
{ value: 'other', label: 'Sonstiges', desc: 'Anderer Verstoß gegen die Nutzungsregeln', icon: HelpCircle, color: '#6b7280' },
|
||||
];
|
||||
|
||||
function openReportModal() {
|
||||
isReportOpen.value = true;
|
||||
reportReason.value = '';
|
||||
reportDone.value = false;
|
||||
}
|
||||
|
||||
function closeReportModal() {
|
||||
isReportOpen.value = false;
|
||||
}
|
||||
|
||||
const submitReport = async () => {
|
||||
if (!reportReason.value || reportSubmitting.value) return;
|
||||
reportSubmitting.value = true;
|
||||
|
||||
// Build snapshot + screenshot
|
||||
const snapshot = {
|
||||
id: props.profile.id,
|
||||
username: props.profile.username,
|
||||
avatar: props.profile.avatar,
|
||||
banner: props.profile.banner,
|
||||
bio: props.profile.bio,
|
||||
role: props.profile.role,
|
||||
vip_level: props.profile.vip_level,
|
||||
clan_tag: props.profile.clan_tag,
|
||||
stats: {
|
||||
wagered: props.profile.stats?.wagered,
|
||||
wins: props.profile.stats?.wins,
|
||||
biggest_win: props.profile.stats?.biggest_win,
|
||||
likes_count: props.profile.stats?.likes_count,
|
||||
join_date: props.profile.stats?.join_date,
|
||||
},
|
||||
best_wins: bestWins.slice(0, 5),
|
||||
comments: comments.value.slice(0, 10).map((c: any) => ({
|
||||
id: c.id,
|
||||
content: c.content,
|
||||
created_at: c.created_at,
|
||||
user: { id: c.user?.id, username: c.user?.username, avatar: c.user?.avatar },
|
||||
})),
|
||||
captured_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
let screenshot: string | null = null;
|
||||
try {
|
||||
const profileEl = document.querySelector('.profile-page') as HTMLElement | null;
|
||||
if (profileEl) {
|
||||
const canvas = await html2canvas(profileEl, {
|
||||
useCORS: true, allowTaint: false, scale: 1, backgroundColor: '#0a0a0c',
|
||||
});
|
||||
screenshot = canvas.toDataURL('image/png');
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
const csrf = (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement)?.content || '';
|
||||
try {
|
||||
const res = await fetch(`/profile/${props.profile.id}/report`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'X-CSRF-TOKEN': csrf,
|
||||
},
|
||||
body: JSON.stringify({ reason: reportReason.value, snapshot, screenshot }),
|
||||
});
|
||||
if (res.ok) {
|
||||
reportDone.value = true;
|
||||
setTimeout(closeReportModal, 1800);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
reportSubmitting.value = false;
|
||||
};
|
||||
|
||||
// Like Logic
|
||||
const toggleLike = () => {
|
||||
useForm({}).post(`/profile/${props.profile.id}/like`, { preserveScroll: true });
|
||||
};
|
||||
|
||||
// Tip Logic
|
||||
const isTipOpen = ref(false);
|
||||
const page = usePage();
|
||||
const myWallets = computed(() => (page.props as any)?.auth?.user?.wallets || []);
|
||||
const currencies = computed<string[]>(() => myWallets.value.map((w: any) => w.currency));
|
||||
const tipForm = useForm<{ currency: string; amount: string | number; note?: string }>({
|
||||
currency: '',
|
||||
amount: '',
|
||||
note: ''
|
||||
});
|
||||
|
||||
// Friend request
|
||||
const localPending = ref(!!props.isPending);
|
||||
const localIsFriend = ref(props.isFriend);
|
||||
const localTheyRequested = ref(!!props.theyRequestedMe);
|
||||
|
||||
const sendFriendRequest = () => {
|
||||
if (localIsFriend.value || localPending.value) return;
|
||||
useForm({ user_id: props.profile.id }).post('/friends/request', {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
localPending.value = true;
|
||||
notify({ type: 'magenta', title: 'Friend request', desc: 'Request sent to ' + props.profile.username, icon: 'user-plus' });
|
||||
},
|
||||
onError: (e) => {
|
||||
notify({ type: 'red', title: 'Error', desc: (e && Object.values(e)[0]) as any || 'Failed to send request', icon: 'alert-triangle' });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const acceptFriendRequest = () => {
|
||||
if (!props.friendRowId) return;
|
||||
useForm({}).post(`/friends/${props.friendRowId}/accept`, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
localTheyRequested.value = false;
|
||||
localIsFriend.value = true;
|
||||
notify({ type: 'green', title: 'Friends!', desc: `You and ${props.profile.username} are now friends.`, icon: 'user-check' });
|
||||
},
|
||||
onError: () => {
|
||||
notify({ type: 'red', title: 'Error', desc: 'Could not accept request.', icon: 'x' });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const declineFriendRequest = () => {
|
||||
if (!props.friendRowId) return;
|
||||
useForm({}).post(`/friends/${props.friendRowId}/decline`, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
localTheyRequested.value = false;
|
||||
notify({ type: 'gray', title: 'Declined', desc: 'Friend request declined.', icon: 'x' });
|
||||
},
|
||||
onError: () => {
|
||||
notify({ type: 'red', title: 'Error', desc: 'Could not decline request.', icon: 'x' });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
// Default to first available currency
|
||||
if (currencies.value.length && !tipForm.currency) {
|
||||
tipForm.currency = currencies.value[0];
|
||||
}
|
||||
});
|
||||
|
||||
const submitTip = () => {
|
||||
// Basic client validation
|
||||
const amt = parseFloat(String(tipForm.amount).replace(',', '.'));
|
||||
if (!amt || amt <= 0) {
|
||||
notify({ type: 'red', title: 'Invalid amount', desc: 'Please enter a positive amount.', icon: 'alert-triangle' });
|
||||
return;
|
||||
}
|
||||
tipForm.amount = amt;
|
||||
tipForm.post(`/profile/${props.profile.id}/tip`, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
isTipOpen.value = false;
|
||||
tipForm.reset('amount', 'note');
|
||||
notify({ type: 'green', title: 'Tip sent', desc: 'Your tip was sent successfully.', icon: 'send' });
|
||||
},
|
||||
onError: (errs) => {
|
||||
const first = errs?.amount || Object.values(errs)[0] || 'Transfer failed';
|
||||
notify({ type: 'red', title: 'Error', desc: String(first), icon: 'x' });
|
||||
}
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UserLayout>
|
||||
<Head :title="`${profile.username}'s Profile`" />
|
||||
|
||||
<div class="profile-page" :class="roleConfig.effectClass">
|
||||
<div class="bg-fx"></div>
|
||||
|
||||
<!-- Banner -->
|
||||
<div class="banner" :style="{ backgroundImage: `url(${profile.banner || '/img/default-banner.jpg'})` }">
|
||||
<div class="banner-overlay"></div>
|
||||
</div>
|
||||
|
||||
<div class="content-container">
|
||||
<!-- Header Section -->
|
||||
<div class="profile-header">
|
||||
<div class="avatar-wrapper">
|
||||
<div class="avatar" :style="{ borderColor: roleConfig.effectClass ? roleConfig.color : currentVipStyle.color, boxShadow: `0 0 20px ${roleConfig.effectClass ? roleConfig.color : currentVipStyle.color}40` }">
|
||||
<img v-if="profile.avatar" :src="profile.avatar" alt="Avatar">
|
||||
<span v-else>{{ profile.username.charAt(0) }}</span>
|
||||
</div>
|
||||
<div class="online-status"></div>
|
||||
</div>
|
||||
|
||||
<div class="header-info">
|
||||
<div class="name-row">
|
||||
<h1 class="username" :class="roleConfig.effectClass" :style="{ color: roleConfig.effectClass ? roleConfig.color : '#fff' }">
|
||||
<span v-if="profile.clan_tag" class="clan-tag">[{{ profile.clan_tag }}]</span>
|
||||
{{ profile.username }}
|
||||
</h1>
|
||||
<div class="badges">
|
||||
<span class="badge vip" :style="{ color: currentVipStyle.color, borderColor: currentVipStyle.color, background: `${currentVipStyle.color}15` }">
|
||||
VIP {{ profile.vip_level }}
|
||||
</span>
|
||||
<span class="badge role" :class="roleConfig.effectClass" :style="{ color: roleConfig.color, borderColor: roleConfig.color, background: `${roleConfig.color}15` }">
|
||||
{{ roleConfig.name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="bio">{{ profile.bio || 'No bio provided.' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="header-actions">
|
||||
<button v-if="!isOwnProfile" class="btn-action like" :class="{ active: hasLiked }" @click="toggleLike">
|
||||
<i data-lucide="heart" :class="{ 'fill-current': hasLiked }"></i>
|
||||
<span>{{ stats.likes_count }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Incoming friend request: show Accept / Decline -->
|
||||
<template v-if="!isOwnProfile && localTheyRequested">
|
||||
<button class="btn-action friend accept" @click="acceptFriendRequest">
|
||||
<i data-lucide="user-check"></i> Accept
|
||||
</button>
|
||||
<button class="btn-action friend decline" @click="declineFriendRequest">
|
||||
<i data-lucide="x"></i> Decline
|
||||
</button>
|
||||
</template>
|
||||
<!-- Already friends / outgoing request / no request -->
|
||||
<button v-else-if="!isOwnProfile" class="btn-action friend" :class="{ added: localIsFriend || localPending }" @click="sendFriendRequest" :disabled="localIsFriend || localPending">
|
||||
<i data-lucide="user-plus" v-if="!localIsFriend && !localPending"></i>
|
||||
<i data-lucide="check" v-else></i>
|
||||
{{ localIsFriend ? 'Friends' : (localPending ? 'Pending' : 'Add') }}
|
||||
</button>
|
||||
|
||||
<button v-if="!isOwnProfile && isFriend" class="btn-action tip" @click="isTipOpen = true" title="Send Tip">
|
||||
<i data-lucide="coins"></i>
|
||||
Tip
|
||||
</button>
|
||||
|
||||
<button v-if="!isOwnProfile" class="btn-action report" @click="openReportModal" title="Report Profile">
|
||||
<i data-lucide="flag"></i>
|
||||
</button>
|
||||
|
||||
<a :href="isOwnProfile ? '/trophy' : `/trophy/${profile.username}`" class="btn-action trophy" title="Trophy Room">
|
||||
<i data-lucide="trophy"></i>
|
||||
</a>
|
||||
|
||||
<a href="/settings" v-if="isOwnProfile" class="btn-edit">
|
||||
<i data-lucide="edit-2"></i> Edit
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Grid -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card highlight">
|
||||
<div class="stat-icon"><i data-lucide="coins"></i></div>
|
||||
<div class="stat-data">
|
||||
<div class="stat-label">Total Wagered</div>
|
||||
<div class="stat-value">${{ animatedWager.toFixed(2) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon"><i data-lucide="trophy"></i></div>
|
||||
<div class="stat-data">
|
||||
<div class="stat-label">Total Wins</div>
|
||||
<div class="stat-value">{{ stats.wins }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon"><i data-lucide="star"></i></div>
|
||||
<div class="stat-data">
|
||||
<div class="stat-label">Biggest Win</div>
|
||||
<div class="stat-value text-gold">${{ parseFloat(stats.biggest_win || 0).toFixed(2) }}</div>
|
||||
<div class="stat-sub" v-if="stats.biggest_win_game">in {{ stats.biggest_win_game }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon"><i data-lucide="calendar"></i></div>
|
||||
<div class="stat-data">
|
||||
<div class="stat-label">Joined</div>
|
||||
<div class="stat-value">{{ stats.join_date }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content Columns -->
|
||||
<div class="profile-cols">
|
||||
<!-- Left: Wins -->
|
||||
<div class="col-wins">
|
||||
<h3><i data-lucide="crown" class="text-gold"></i> Best Wins</h3>
|
||||
<div class="wins-grid">
|
||||
<div v-for="(win, index) in bestWins" :key="index" class="win-card">
|
||||
<div class="win-img" :style="{ backgroundImage: `url('${win.image}')` }"></div>
|
||||
<div class="win-info">
|
||||
<div class="win-amount text-gold">${{ win.amount.toFixed(2) }}</div>
|
||||
<div class="win-game">{{ win.game }}</div>
|
||||
<div class="win-multi">{{ win.multiplier }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="bestWins.length === 0" class="win-card empty">
|
||||
<i data-lucide="ghost"></i>
|
||||
<span>No big wins yet</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Comments -->
|
||||
<div class="col-comments">
|
||||
<h3><i data-lucide="message-square"></i> Comments</h3>
|
||||
|
||||
<div class="comment-input" v-if="!isOwnProfile">
|
||||
<textarea v-model="commentForm.content" placeholder="Write a comment..." rows="2"></textarea>
|
||||
<button @click="submitComment" :disabled="commentForm.processing"><i data-lucide="send"></i></button>
|
||||
</div>
|
||||
|
||||
<div class="comments-list">
|
||||
<div v-for="comment in comments" :key="comment.id" class="comment-item">
|
||||
<div class="c-avatar">
|
||||
<img v-if="comment.user?.avatar" :src="comment.user.avatar">
|
||||
<span v-else>{{ (comment.user?.username || '?').charAt(0) }}</span>
|
||||
</div>
|
||||
<div class="c-content">
|
||||
<div class="c-head">
|
||||
<span class="c-name">{{ comment.user?.username || '?' }}</span>
|
||||
<span class="c-time">Just now</span>
|
||||
</div>
|
||||
<div class="c-text">{{ comment.content }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="comments.length === 0" class="no-comments">No comments yet. Be the first!</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Tip Modal -->
|
||||
<div v-if="isTipOpen" class="modal-overlay" @click.self="isTipOpen = false">
|
||||
<div class="modal-card">
|
||||
<h3>Send Tip to {{ profile.username }}</h3>
|
||||
<div class="form-row" style="display:grid; grid-template-columns: 120px 1fr; gap:10px; align-items:center; margin-bottom:10px;">
|
||||
<label class="lbl">Currency</label>
|
||||
<select v-model="tipForm.currency">
|
||||
<option v-for="c in currencies" :key="c" :value="c">{{ c }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row" style="display:grid; grid-template-columns: 120px 1fr; gap:10px; align-items:center; margin-bottom:10px;">
|
||||
<label class="lbl">Amount</label>
|
||||
<input v-model="tipForm.amount" type="number" min="0" step="0.00000001" placeholder="0.00" class="input" />
|
||||
</div>
|
||||
<div class="form-row" style="margin-bottom:10px;">
|
||||
<textarea v-model="tipForm.note" rows="2" placeholder="Add a note (optional)" class="input"></textarea>
|
||||
</div>
|
||||
<div v-if="tipForm.errors && Object.keys(tipForm.errors).length" class="msg err">{{ tipForm.errors.amount || Object.values(tipForm.errors)[0] }}</div>
|
||||
<div class="modal-actions">
|
||||
<button @click="isTipOpen = false" class="btn-cancel">Cancel</button>
|
||||
<button @click="submitTip" class="btn-confirm" :disabled="tipForm.processing">{{ tipForm.processing ? 'Sending...' : 'Send Tip' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Report Modal (teleported to body) -->
|
||||
<teleport to="body">
|
||||
<transition name="modal-fade">
|
||||
<div v-if="isReportOpen" class="gc-report-overlay" @click.self="closeReportModal">
|
||||
<div class="gc-report-modal" role="dialog" aria-modal="true" aria-labelledby="pr-title">
|
||||
|
||||
<!-- Success -->
|
||||
<div v-if="reportDone" class="report-success">
|
||||
<div class="success-ring">
|
||||
<CheckCircle2 :size="36" />
|
||||
</div>
|
||||
<h3>Erfolgreich gemeldet</h3>
|
||||
<p>Danke für deinen Hinweis.<br>Unser Moderationsteam wird den Fall prüfen.</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Header -->
|
||||
<div class="rm-head">
|
||||
<div class="rm-head-left">
|
||||
<div class="rm-head-icon">
|
||||
<Flag :size="16" />
|
||||
</div>
|
||||
<span id="pr-title" class="rm-title">Profil melden</span>
|
||||
</div>
|
||||
<button class="rm-close" @click="closeReportModal" aria-label="Schließen">
|
||||
<X :size="15" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- User card -->
|
||||
<div class="rm-user">
|
||||
<div class="rm-avatar-wrap">
|
||||
<div class="rm-avatar">
|
||||
<img v-if="profile.avatar" :src="profile.avatar" :alt="profile.username" />
|
||||
<div v-else class="rm-avatar-fallback">{{ profile.username[0].toUpperCase() }}</div>
|
||||
</div>
|
||||
<div class="rm-avatar-glow"></div>
|
||||
</div>
|
||||
<div class="rm-user-info">
|
||||
<span class="rm-username">@{{ profile.username }}</span>
|
||||
<div class="rm-badges">
|
||||
<span v-if="profile.role === 'admin'" class="rm-badge admin">ADMIN</span>
|
||||
<span v-else-if="profile.role === 'staff' || profile.role === 'mod'" class="rm-badge staff">STAFF</span>
|
||||
<span v-else-if="profile.role === 'streamer'" class="rm-badge streamer">STREAMER</span>
|
||||
<span v-else class="rm-badge user">USER</span>
|
||||
<span v-if="profile.vip_level > 0" class="rm-badge vip">★ VIP {{ profile.vip_level }}</span>
|
||||
<span v-if="profile.clan_tag" class="rm-badge clan">[{{ profile.clan_tag }}]</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reason grid -->
|
||||
<div class="rm-section">
|
||||
<div class="rm-section-label">
|
||||
<Flag :size="12" />
|
||||
Meldegrund auswählen
|
||||
</div>
|
||||
<div class="rm-reasons">
|
||||
<button
|
||||
v-for="r in reportReasons"
|
||||
:key="r.value"
|
||||
class="rm-reason-btn"
|
||||
:class="{ selected: reportReason === r.value }"
|
||||
:style="reportReason === r.value ? `--reason-color: ${r.color}` : ''"
|
||||
@click="reportReason = r.value"
|
||||
>
|
||||
<div class="reason-icon-wrap" :style="`color: ${r.color}`">
|
||||
<component :is="r.icon" :size="18" />
|
||||
</div>
|
||||
<div class="reason-text">
|
||||
<span class="reason-label">{{ r.label }}</span>
|
||||
<span class="reason-desc">{{ r.desc }}</span>
|
||||
</div>
|
||||
<div class="reason-check-wrap" v-if="reportReason === r.value">
|
||||
<CheckCircle2 :size="16" />
|
||||
</div>
|
||||
<ChevronRight v-else :size="14" class="reason-chevron" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="rm-actions">
|
||||
<button class="rm-btn-cancel" @click="closeReportModal">
|
||||
<X :size="14" />
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
class="rm-btn-submit"
|
||||
:disabled="!reportReason || reportSubmitting"
|
||||
@click="submitReport"
|
||||
>
|
||||
<Loader2 v-if="reportSubmitting" :size="15" class="rm-spin" />
|
||||
<Flag v-else :size="15" />
|
||||
{{ reportSubmitting ? 'Wird gesendet…' : 'Jetzt melden' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</teleport>
|
||||
|
||||
</div>
|
||||
</UserLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.profile-page { min-height: 100vh; background: #050505; padding-bottom: 60px; position: relative; overflow: hidden; }
|
||||
|
||||
/* Role Effects & Backgrounds */
|
||||
.bg-fx { position: absolute; inset: 0; pointer-events: none; z-index: 0; opacity: 0; transition: opacity 0.5s; }
|
||||
|
||||
.role-admin .bg-fx {
|
||||
opacity: 1;
|
||||
background:
|
||||
radial-gradient(circle at 20% 30%, rgba(255, 62, 62, 0.15), transparent 60%),
|
||||
radial-gradient(circle at 80% 70%, rgba(255, 62, 62, 0.1), transparent 60%);
|
||||
animation: pulse-bg-red 4s infinite alternate;
|
||||
}
|
||||
.role-staff .bg-fx {
|
||||
opacity: 1;
|
||||
background:
|
||||
radial-gradient(circle at 20% 30%, rgba(0, 242, 255, 0.15), transparent 60%),
|
||||
radial-gradient(circle at 80% 70%, rgba(0, 242, 255, 0.1), transparent 60%);
|
||||
animation: pulse-bg-cyan 4s infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes pulse-bg-red { 0% { opacity: 0.6; transform: scale(1); } 100% { opacity: 1; transform: scale(1.05); } }
|
||||
@keyframes pulse-bg-cyan { 0% { opacity: 0.6; transform: scale(1); } 100% { opacity: 1; transform: scale(1.05); } }
|
||||
|
||||
/* Glitter Text */
|
||||
.username.role-admin {
|
||||
text-shadow: 0 0 20px rgba(255, 62, 62, 0.8);
|
||||
animation: glitter-red 2s infinite alternate;
|
||||
}
|
||||
.username.role-staff {
|
||||
text-shadow: 0 0 20px rgba(0, 242, 255, 0.8);
|
||||
animation: glitter-cyan 2s infinite alternate;
|
||||
}
|
||||
|
||||
/* Badge Glitter */
|
||||
.badge.role.role-admin {
|
||||
box-shadow: 0 0 15px rgba(255, 62, 62, 0.5);
|
||||
animation: border-pulse-red 1.5s infinite;
|
||||
background: rgba(255, 62, 62, 0.15) !important;
|
||||
}
|
||||
.badge.role.role-staff {
|
||||
box-shadow: 0 0 15px rgba(0, 242, 255, 0.5);
|
||||
animation: border-pulse-cyan 1.5s infinite;
|
||||
background: rgba(0, 242, 255, 0.15) !important;
|
||||
}
|
||||
|
||||
@keyframes glitter-red { 0% { filter: brightness(1); text-shadow: 0 0 10px rgba(255,62,62,0.5); } 100% { filter: brightness(1.5); text-shadow: 0 0 25px rgba(255,62,62,1); } }
|
||||
@keyframes glitter-cyan { 0% { filter: brightness(1); text-shadow: 0 0 10px rgba(0,242,255,0.5); } 100% { filter: brightness(1.5); text-shadow: 0 0 25px rgba(0,242,255,1); } }
|
||||
@keyframes border-pulse-red { 0% { border-color: rgba(255,62,62,0.4); box-shadow: 0 0 5px rgba(255,62,62,0.2); } 100% { border-color: rgba(255,62,62,1); box-shadow: 0 0 20px rgba(255,62,62,0.6); } }
|
||||
@keyframes border-pulse-cyan { 0% { border-color: rgba(0,242,255,0.4); box-shadow: 0 0 5px rgba(0,242,255,0.2); } 100% { border-color: rgba(0,242,255,1); box-shadow: 0 0 20px rgba(0,242,255,0.6); } }
|
||||
|
||||
.banner { height: 280px; background-size: cover; background-position: center; position: relative; z-index: 1; }
|
||||
.banner-overlay { position: absolute; inset: 0; background: linear-gradient(to bottom, transparent 0%, #050505 100%); }
|
||||
|
||||
.content-container { max-width: 1100px; margin: -100px auto 0; padding: 0 20px; position: relative; z-index: 10; }
|
||||
|
||||
/* Header */
|
||||
.profile-header { display: flex; align-items: flex-end; gap: 30px; margin-bottom: 50px; }
|
||||
|
||||
.avatar-wrapper { position: relative; }
|
||||
.avatar {
|
||||
width: 160px; height: 160px; border-radius: 50%; border: 6px solid #050505; background: #111;
|
||||
overflow: hidden; display: flex; align-items: center; justify-content: center;
|
||||
font-size: 56px; font-weight: 900; color: #333; transition: 0.3s;
|
||||
}
|
||||
.avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.online-status { position: absolute; bottom: 12px; right: 12px; width: 24px; height: 24px; background: #00ff9d; border: 4px solid #050505; border-radius: 50%; box-shadow: 0 0 10px #00ff9d; }
|
||||
|
||||
.header-info { flex: 1; padding-bottom: 15px; }
|
||||
.name-row { display: flex; align-items: center; gap: 16px; margin-bottom: 10px; flex-wrap: wrap; }
|
||||
.username { font-size: 36px; font-weight: 900; color: #fff; letter-spacing: -1px; line-height: 1; display: flex; align-items: center; gap: 10px; }
|
||||
.clan-tag { color: inherit; opacity: 0.8; font-size: 0.6em; background: rgba(255,255,255,0.1); padding: 2px 8px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.1); vertical-align: middle; }
|
||||
|
||||
.badges { display: flex; gap: 8px; }
|
||||
.badge { font-size: 11px; font-weight: 900; padding: 4px 10px; border-radius: 6px; text-transform: uppercase; border: 1px solid transparent; letter-spacing: 0.5px; }
|
||||
|
||||
.bio { color: #888; font-size: 15px; max-width: 600px; line-height: 1.6; }
|
||||
|
||||
.header-actions { display: flex; gap: 12px; padding-bottom: 20px; }
|
||||
.btn-action, .btn-edit { display: flex; align-items: center; gap: 8px; padding: 12px 20px; border-radius: 12px; font-weight: 800; font-size: 13px; cursor: pointer; transition: 0.2s; border: none; }
|
||||
.btn-action { background: #111; border: 1px solid #333; color: #fff; }
|
||||
.btn-action:hover { background: #1a1a1a; border-color: #555; }
|
||||
.btn-action.like.active { color: #ff007a; border-color: #ff007a; background: rgba(255,0,122,0.1); }
|
||||
.btn-action.friend { background: #ff007a; color: #fff; border-color: #ff007a; }
|
||||
.btn-action.friend:hover { background: #d40065; }
|
||||
.btn-action.friend.added { background: #111; border-color: #333; color: #fff; }
|
||||
.btn-action.friend.accept { background: #00c853; border-color: #00c853; }
|
||||
.btn-action.friend.accept:hover { background: #00a844; }
|
||||
.btn-action.friend.decline { background: #111; border-color: #ff3e3e; color: #ff3e3e; }
|
||||
.btn-action.friend.decline:hover { background: rgba(255,62,62,0.1); }
|
||||
.btn-action.report:hover { color: #ff3e3e; border-color: #ff3e3e; }
|
||||
.btn-action.trophy:hover { color: #ffd700; border-color: #ffd700; }
|
||||
|
||||
.btn-edit { background: #111; border: 1px solid #333; color: #fff; text-decoration: none; }
|
||||
.btn-edit:hover { background: #1a1a1a; border-color: #444; }
|
||||
|
||||
/* Stats Grid */
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; margin-bottom: 50px; }
|
||||
.stat-card { background: #0f0f0f; border: 1px solid #1f1f1f; padding: 24px; border-radius: 20px; display: flex; align-items: center; gap: 16px; transition: 0.3s; position: relative; overflow: hidden; }
|
||||
.stat-card:hover { transform: translateY(-4px); border-color: #333; box-shadow: 0 10px 30px -10px rgba(0,0,0,0.5); }
|
||||
.stat-card.highlight { background: linear-gradient(135deg, rgba(255,0,122,0.05), transparent); border-color: rgba(255,0,122,0.2); }
|
||||
.stat-icon { width: 52px; height: 52px; background: #18181b; border-radius: 14px; display: flex; align-items: center; justify-content: center; color: #666; flex-shrink: 0; }
|
||||
.stat-card.highlight .stat-icon { color: #ff007a; background: rgba(255,0,122,0.1); }
|
||||
.stat-label { font-size: 11px; font-weight: 700; color: #666; text-transform: uppercase; margin-bottom: 4px; letter-spacing: 0.5px; }
|
||||
.stat-value { font-size: 20px; font-weight: 900; color: #fff; }
|
||||
.stat-sub { font-size: 11px; color: #555; margin-top: 2px; }
|
||||
.text-magenta { color: #ff007a; }
|
||||
.text-gold { color: #ffd700; }
|
||||
|
||||
/* Columns */
|
||||
.profile-cols { display: grid; grid-template-columns: 1fr 350px; gap: 30px; }
|
||||
|
||||
/* Wins Section */
|
||||
.col-wins h3, .col-comments h3 { font-size: 20px; font-weight: 800; color: #fff; margin-bottom: 24px; display: flex; align-items: center; gap: 10px; }
|
||||
.wins-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 20px; }
|
||||
|
||||
.win-card { background: #0f0f0f; border: 1px solid #1f1f1f; border-radius: 16px; overflow: hidden; transition: 0.3s; position: relative; }
|
||||
.win-card:hover { transform: translateY(-4px); border-color: #ffd700; box-shadow: 0 10px 40px -10px rgba(255, 215, 0, 0.15); }
|
||||
.win-img { height: 140px; background-size: cover; background-position: center; position: relative; }
|
||||
.win-img::after { content: ''; position: absolute; inset: 0; background: linear-gradient(to bottom, transparent, #0f0f0f); }
|
||||
|
||||
.win-info { padding: 16px; position: relative; z-index: 2; margin-top: -40px; }
|
||||
.win-amount { font-size: 20px; font-weight: 900; margin-bottom: 4px; text-shadow: 0 2px 10px rgba(0,0,0,0.8); }
|
||||
.win-game { font-size: 12px; color: #ccc; font-weight: 600; }
|
||||
.win-multi { position: absolute; top: 16px; right: 16px; background: rgba(255, 215, 0, 0.1); color: #ffd700; border: 1px solid rgba(255, 215, 0, 0.3); padding: 4px 8px; border-radius: 6px; font-size: 11px; font-weight: 800; }
|
||||
|
||||
.win-card.empty { height: 200px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; color: #333; border: 1px dashed #222; }
|
||||
.win-card.empty i { width: 32px; height: 32px; }
|
||||
|
||||
/* Comments */
|
||||
.comment-input { display: flex; gap: 10px; background: #111; padding: 10px; border-radius: 12px; border: 1px solid #222; margin-bottom: 20px; }
|
||||
.comment-input textarea { flex: 1; background: transparent; border: none; color: #fff; font-size: 13px; resize: none; outline: none; }
|
||||
.comment-input button { background: #ff007a; color: #fff; border: none; width: 36px; height: 36px; border-radius: 8px; cursor: pointer; display: flex; align-items: center; justify-content: center; }
|
||||
.comment-input button:hover { background: #d40065; }
|
||||
|
||||
.comments-list { display: flex; flex-direction: column; gap: 16px; }
|
||||
.comment-item { display: flex; gap: 12px; }
|
||||
.c-avatar { width: 36px; height: 36px; border-radius: 10px; background: #222; display: flex; align-items: center; justify-content: center; font-weight: 700; color: #fff; overflow: hidden; flex-shrink: 0; }
|
||||
.c-avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.c-content { background: #111; padding: 12px; border-radius: 12px; border: 1px solid #222; flex: 1; }
|
||||
.c-head { display: flex; justify-content: space-between; margin-bottom: 4px; }
|
||||
.c-name { font-size: 12px; font-weight: 700; color: #fff; }
|
||||
.c-time { font-size: 10px; color: #555; }
|
||||
.c-text { font-size: 13px; color: #ccc; line-height: 1.4; }
|
||||
.no-comments { text-align: center; color: #444; font-size: 12px; padding: 20px; }
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.8); z-index: 100; display: flex; align-items: center; justify-content: center; backdrop-filter: blur(5px); }
|
||||
.modal-card { background: #111; border: 1px solid #333; padding: 24px; border-radius: 16px; width: 100%; max-width: 400px; }
|
||||
.modal-card h3 { color: #fff; font-size: 18px; font-weight: 800; margin-bottom: 16px; }
|
||||
.modal-card select { width: 100%; background: #000; border: 1px solid #333; color: #fff; padding: 12px; border-radius: 8px; margin-bottom: 20px; outline: none; }
|
||||
.modal-actions { display: flex; gap: 10px; justify-content: flex-end; }
|
||||
.btn-cancel { background: transparent; border: 1px solid #333; color: #ccc; padding: 10px 16px; border-radius: 8px; cursor: pointer; }
|
||||
.btn-confirm { background: #ff3e3e; border: none; color: #fff; padding: 10px 16px; border-radius: 8px; cursor: pointer; font-weight: 700; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.stats-grid { grid-template-columns: 1fr 1fr; }
|
||||
.profile-cols { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.profile-header { flex-direction: column; align-items: center; text-align: center; margin-top: -80px; }
|
||||
.header-info { padding-bottom: 0; width: 100%; }
|
||||
.name-row { justify-content: center; }
|
||||
.header-actions { width: 100%; justify-content: center; flex-wrap: wrap; }
|
||||
.avatar { width: 120px; height: 120px; font-size: 40px; }
|
||||
.banner { height: 200px; }
|
||||
}
|
||||
|
||||
/* Extra small phones */
|
||||
@media (max-width: 420px) {
|
||||
.username { font-size: clamp(18px, 7vw, 24px); word-break: break-word; }
|
||||
.bio { font-size: 14px; }
|
||||
.wins-grid { grid-template-columns: 1fr; }
|
||||
.stats-grid { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
|
||||
/* ===== Report Modal ===== */
|
||||
.gc-report-overlay {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
backdrop-filter: blur(8px) saturate(0.8);
|
||||
-webkit-backdrop-filter: blur(8px) saturate(0.8);
|
||||
z-index: 2147483647;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
.gc-report-modal {
|
||||
background: #0c0c0e;
|
||||
border: 1px solid rgba(255,255,255,0.07);
|
||||
border-radius: 22px;
|
||||
width: 100%; max-width: 460px;
|
||||
box-shadow: 0 40px 100px rgba(0,0,0,0.9), 0 0 0 1px rgba(223,0,106,0.05);
|
||||
overflow: hidden;
|
||||
animation: modal-pop 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
@keyframes modal-pop {
|
||||
from { opacity: 0; transform: scale(0.9) translateY(16px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
.modal-fade-enter-active { transition: opacity 0.25s ease; }
|
||||
.modal-fade-leave-active { transition: opacity 0.15s ease; }
|
||||
.modal-fade-enter-from, .modal-fade-leave-to { opacity: 0; }
|
||||
|
||||
.rm-head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 16px 18px;
|
||||
background: linear-gradient(135deg, rgba(223,0,106,0.12), rgba(0,0,0,0));
|
||||
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||
}
|
||||
.rm-head-left { display: flex; align-items: center; gap: 10px; }
|
||||
.rm-head-icon {
|
||||
width: 30px; height: 30px; border-radius: 9px;
|
||||
background: rgba(223,0,106,0.18); border: 1px solid rgba(223,0,106,0.35);
|
||||
color: #df006a; display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.rm-title { font-size: 14px; font-weight: 800; color: #fff; letter-spacing: 0.2px; }
|
||||
.rm-close {
|
||||
width: 28px; height: 28px; border-radius: 8px;
|
||||
background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.08);
|
||||
color: #555; cursor: pointer; display: flex; align-items: center; justify-content: center;
|
||||
transition: 0.2s; flex-shrink: 0;
|
||||
}
|
||||
.rm-close:hover { background: rgba(255,255,255,0.1); color: #fff; }
|
||||
|
||||
.rm-user {
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
padding: 14px 18px;
|
||||
background: rgba(255,255,255,0.02);
|
||||
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||
}
|
||||
.rm-avatar-wrap { position: relative; flex-shrink: 0; }
|
||||
.rm-avatar {
|
||||
width: 52px; height: 52px; border-radius: 14px; overflow: hidden;
|
||||
border: 2px solid rgba(255,255,255,0.08); background: #151515; position: relative; z-index: 1;
|
||||
}
|
||||
.rm-avatar img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.rm-avatar-fallback {
|
||||
width: 100%; height: 100%; display: flex; align-items: center; justify-content: center;
|
||||
font-size: 20px; font-weight: 900; color: #555;
|
||||
}
|
||||
.rm-avatar-glow {
|
||||
position: absolute; inset: -4px; border-radius: 18px; z-index: 0;
|
||||
background: radial-gradient(circle, rgba(223,0,106,0.25), transparent 70%);
|
||||
filter: blur(6px);
|
||||
}
|
||||
.rm-user-info { display: flex; flex-direction: column; gap: 7px; min-width: 0; }
|
||||
.rm-username { font-size: 15px; font-weight: 800; color: #fff; }
|
||||
.rm-badges { display: flex; gap: 5px; flex-wrap: wrap; }
|
||||
.rm-badge {
|
||||
font-size: 9px; font-weight: 800; padding: 2px 8px; border-radius: 5px;
|
||||
text-transform: uppercase; letter-spacing: 0.5px; border: 1px solid;
|
||||
}
|
||||
.rm-badge.admin { color: #ff3e3e; border-color: rgba(255,62,62,0.35); background: rgba(255,62,62,0.08); }
|
||||
.rm-badge.staff { color: #3b82f6; border-color: rgba(59,130,246,0.35); background: rgba(59,130,246,0.08); }
|
||||
.rm-badge.streamer { color: #a855f7; border-color: rgba(168,85,247,0.35); background: rgba(168,85,247,0.08); }
|
||||
.rm-badge.user { color: #555; border-color: rgba(255,255,255,0.08); background: rgba(255,255,255,0.03); }
|
||||
.rm-badge.vip { color: #ffd700; border-color: rgba(255,215,0,0.35); background: rgba(255,215,0,0.08); }
|
||||
.rm-badge.clan { color: #00f2ff; border-color: rgba(0,242,255,0.35); background: rgba(0,242,255,0.08); }
|
||||
|
||||
.rm-section { padding: 14px 18px; border-bottom: 1px solid rgba(255,255,255,0.05); }
|
||||
.rm-section-label {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
font-size: 10px; font-weight: 700; text-transform: uppercase;
|
||||
letter-spacing: 0.7px; color: #444; margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.rm-reasons { display: flex; flex-direction: column; gap: 7px; }
|
||||
.rm-reason-btn {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 11px 14px; border-radius: 12px;
|
||||
background: rgba(255,255,255,0.03);
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
color: #888; cursor: pointer; transition: all 0.18s ease;
|
||||
text-align: left; width: 100%;
|
||||
}
|
||||
.rm-reason-btn:hover {
|
||||
background: rgba(255,255,255,0.06);
|
||||
border-color: rgba(255,255,255,0.12);
|
||||
color: #ddd;
|
||||
transform: translateX(2px);
|
||||
}
|
||||
.rm-reason-btn.selected {
|
||||
background: rgba(var(--reason-color, 223 0 106) / 0.1);
|
||||
border-color: color-mix(in srgb, var(--reason-color, #df006a) 50%, transparent);
|
||||
color: #fff;
|
||||
}
|
||||
.reason-icon-wrap {
|
||||
width: 34px; height: 34px; border-radius: 10px;
|
||||
background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.06);
|
||||
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
||||
transition: 0.18s;
|
||||
}
|
||||
.rm-reason-btn.selected .reason-icon-wrap {
|
||||
background: rgba(var(--reason-color, 223 0 106) / 0.15);
|
||||
border-color: color-mix(in srgb, var(--reason-color, #df006a) 40%, transparent);
|
||||
}
|
||||
.reason-text { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
|
||||
.reason-label { font-size: 13px; font-weight: 700; color: inherit; }
|
||||
.reason-desc { font-size: 11px; color: #555; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.rm-reason-btn.selected .reason-desc { color: #888; }
|
||||
.reason-check-wrap { color: #22c55e; flex-shrink: 0; }
|
||||
.reason-chevron { color: #333; flex-shrink: 0; transition: 0.18s; }
|
||||
.rm-reason-btn:hover .reason-chevron { color: #555; transform: translateX(2px); }
|
||||
|
||||
.rm-actions {
|
||||
display: flex; gap: 10px;
|
||||
padding: 14px 18px 18px;
|
||||
}
|
||||
.rm-btn-cancel {
|
||||
flex: 1; padding: 11px 14px; border-radius: 11px;
|
||||
background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08);
|
||||
color: #888; cursor: pointer; font-size: 13px; font-weight: 700;
|
||||
transition: 0.2s; display: flex; align-items: center; justify-content: center; gap: 7px;
|
||||
}
|
||||
.rm-btn-cancel:hover { background: rgba(255,255,255,0.08); color: #ccc; }
|
||||
.rm-btn-submit {
|
||||
flex: 2; padding: 11px 14px; border-radius: 11px;
|
||||
background: linear-gradient(135deg, #df006a, #a8004e);
|
||||
border: 1px solid rgba(223,0,106,0.5);
|
||||
color: #fff; cursor: pointer; font-size: 13px; font-weight: 800;
|
||||
transition: 0.2s; display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||
box-shadow: 0 4px 20px rgba(223,0,106,0.25);
|
||||
}
|
||||
.rm-btn-submit:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #f2007a, #c0005c);
|
||||
box-shadow: 0 6px 30px rgba(223,0,106,0.45);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.rm-btn-submit:disabled { opacity: 0.35; cursor: not-allowed; box-shadow: none; }
|
||||
.rm-spin { animation: rm-spin 0.8s linear infinite; }
|
||||
@keyframes rm-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.report-success {
|
||||
padding: 44px 24px 40px;
|
||||
display: flex; flex-direction: column; align-items: center; gap: 14px; text-align: center;
|
||||
}
|
||||
.success-ring {
|
||||
width: 64px; height: 64px; border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(34,197,94,0.2), rgba(34,197,94,0.05));
|
||||
border: 2px solid rgba(34,197,94,0.4);
|
||||
color: #22c55e; display: flex; align-items: center; justify-content: center;
|
||||
box-shadow: 0 0 30px rgba(34,197,94,0.25);
|
||||
animation: success-bounce 0.5s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
@keyframes success-bounce {
|
||||
0% { transform: scale(0); opacity: 0; }
|
||||
60% { transform: scale(1.12); }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
.report-success h3 { font-size: 18px; font-weight: 900; color: #fff; margin: 0; }
|
||||
.report-success p { font-size: 13px; color: #666; margin: 0; line-height: 1.6; }
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.gc-report-overlay { align-items: flex-end; padding: 0; }
|
||||
.gc-report-modal { border-radius: 22px 22px 0 0; max-width: 100%; }
|
||||
.reason-desc { display: none; }
|
||||
}
|
||||
</style>
|
||||
508
resources/js/pages/Social/Settings.vue
Normal file
508
resources/js/pages/Social/Settings.vue
Normal file
@@ -0,0 +1,508 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { useForm, Head } from '@inertiajs/vue3';
|
||||
import UserLayout from '@/layouts/user/userlayout.vue';
|
||||
import { useNotifications } from '@/composables/useNotifications';
|
||||
|
||||
// Declare route globally for TypeScript if needed, or assume it's available
|
||||
declare function route(name: string, params?: any): string;
|
||||
|
||||
const props = defineProps<{
|
||||
user: any;
|
||||
}>();
|
||||
|
||||
const { notify } = useNotifications();
|
||||
|
||||
// Helper to access global route function safely with fallback
|
||||
const getRoute = (name: string, params?: any) => {
|
||||
// @ts-ignore
|
||||
if (typeof window.route === 'function') {
|
||||
// @ts-ignore
|
||||
return window.route(name, params);
|
||||
}
|
||||
|
||||
// Fallback for known routes if Ziggy fails
|
||||
if (name === 'profile.update') return '/profile/update';
|
||||
if (name === 'profile.upload') return '/profile/upload';
|
||||
|
||||
console.error('Ziggy route function not found on window and no fallback for:', name);
|
||||
return '';
|
||||
};
|
||||
|
||||
const form = useForm({
|
||||
is_public: props.user.is_public || false,
|
||||
bio: props.user.bio || '',
|
||||
avatar: props.user.avatar || '',
|
||||
banner: props.user.banner || '',
|
||||
});
|
||||
|
||||
const isEditingBio = ref(false);
|
||||
const isEditingAvatar = ref(false);
|
||||
const isEditingBanner = ref(false);
|
||||
const isUploading = ref(false);
|
||||
|
||||
const save = () => {
|
||||
const url = getRoute('profile.update');
|
||||
if (!url) return;
|
||||
|
||||
form.post(url, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
isEditingBio.value = false;
|
||||
isEditingAvatar.value = false;
|
||||
isEditingBanner.value = false;
|
||||
notify({ type: 'green', title: 'Saved', desc: 'Profile updated successfully.', icon: 'check' });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const togglePublic = () => {
|
||||
form.is_public = !form.is_public;
|
||||
save();
|
||||
};
|
||||
|
||||
const handleUpload = async (event: Event, type: 'avatar' | 'banner') => {
|
||||
const file = (event.target as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
isUploading.value = true;
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('type', type);
|
||||
|
||||
try {
|
||||
const url = getRoute('profile.upload');
|
||||
if (!url) throw new Error('Route not found');
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement)?.content || '',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
let errorMsg = 'Upload failed';
|
||||
try {
|
||||
const errorData = await res.json();
|
||||
errorMsg = errorData.message || errorMsg;
|
||||
} catch {}
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (type === 'avatar') form.avatar = data.url;
|
||||
else form.banner = data.url;
|
||||
notify({ type: 'green', title: 'Uploaded', desc: `${type} updated successfully.`, icon: 'upload' });
|
||||
|
||||
// Close popover
|
||||
if (type === 'avatar') isEditingAvatar.value = false;
|
||||
if (type === 'banner') isEditingBanner.value = false;
|
||||
} catch (e: any) {
|
||||
notify({ type: 'red', title: 'Error', desc: e.message, icon: 'alert-triangle' });
|
||||
} finally {
|
||||
isUploading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Rank Logic
|
||||
const vipLevelsConfig = [
|
||||
{ name: 'Newbie', color: '#888888' },
|
||||
{ name: 'Bronze', color: '#cd7f32' },
|
||||
{ name: 'Silver', color: '#c0c0c0' },
|
||||
{ name: 'Gold', color: '#ffd700' },
|
||||
{ name: 'Platinum', color: '#00f2ff' },
|
||||
{ name: 'Diamond', color: '#ff007a' },
|
||||
{ name: 'Obsidian', color: '#ff3e3e' }
|
||||
];
|
||||
|
||||
const currentVipStyle = computed(() => {
|
||||
const level = props.user.vip_level || 0;
|
||||
const idx = Math.min(Math.max(level, 0), vipLevelsConfig.length - 1);
|
||||
return vipLevelsConfig[idx];
|
||||
});
|
||||
|
||||
// Role Logic
|
||||
const roleConfig = computed(() => {
|
||||
const role = props.user.role || 'User';
|
||||
let color = '#888';
|
||||
let effectClass = '';
|
||||
|
||||
if (role === 'Admin') {
|
||||
color = '#ff3e3e';
|
||||
effectClass = 'role-admin';
|
||||
} else if (role === 'Mod' || role === 'Staff') {
|
||||
color = '#00f2ff';
|
||||
effectClass = 'role-staff';
|
||||
} else if (role === 'Streamer') {
|
||||
color = '#a855f7';
|
||||
effectClass = 'role-streamer';
|
||||
}
|
||||
|
||||
return { name: role, color, effectClass };
|
||||
});
|
||||
|
||||
// Profile URL Logic
|
||||
const profileUrl = computed(() => {
|
||||
return `${window.location.origin}/profile/${props.user.username}`;
|
||||
});
|
||||
|
||||
const copyProfileUrl = () => {
|
||||
navigator.clipboard.writeText(profileUrl.value);
|
||||
notify({ type: 'green', title: 'Copied', desc: 'Profile URL copied to clipboard.', icon: 'copy' });
|
||||
};
|
||||
|
||||
// Mock stats for preview
|
||||
const stats = {
|
||||
wagered: 12500.50,
|
||||
wins: 450,
|
||||
losses: 320,
|
||||
favorite_game: 'Gates of Olympus',
|
||||
last_played: 'Sweet Bonanza',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UserLayout>
|
||||
<Head title="Edit Profile" />
|
||||
|
||||
<div class="edit-profile-page" :class="roleConfig.effectClass">
|
||||
<div class="bg-fx"></div>
|
||||
|
||||
<!-- Banner Editor -->
|
||||
<div class="banner-editor group" :style="{ backgroundImage: `url(${form.banner || '/img/default-banner.jpg'})` }">
|
||||
<div class="banner-overlay"></div>
|
||||
|
||||
<div class="edit-trigger" @click="isEditingBanner = !isEditingBanner">
|
||||
<i data-lucide="camera"></i>
|
||||
<span>Edit Banner</span>
|
||||
</div>
|
||||
|
||||
<div v-if="isEditingBanner" class="edit-popover banner-pop">
|
||||
<div class="pop-tabs">
|
||||
<label class="pop-tab">
|
||||
<input type="file" class="hidden" accept=".jpg,.jpeg,.png,.gif,.webp,.bmp,image/jpeg,image/png,image/gif,image/webp,image/bmp" @change="handleUpload($event, 'banner')">
|
||||
<i data-lucide="upload"></i> Upload
|
||||
</label>
|
||||
<div class="pop-divider">or</div>
|
||||
<input type="text" v-model="form.banner" placeholder="Paste URL..." class="edit-input" @keyup.enter="save">
|
||||
<button @click="save" class="save-btn"><i data-lucide="check"></i></button>
|
||||
</div>
|
||||
<p class="upload-hint">✓ JPG ✓ PNG ✓ GIF (animiert) ✓ WebP ✓ BMP · Max. <b style="color:#888">5 MB</b></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-container">
|
||||
<!-- Header Section -->
|
||||
<div class="profile-header">
|
||||
|
||||
<!-- Avatar Editor -->
|
||||
<div class="avatar-wrapper group">
|
||||
<div class="avatar" :style="{ borderColor: roleConfig.name !== 'User' ? roleConfig.color : currentVipStyle.color, boxShadow: `0 0 20px ${roleConfig.name !== 'User' ? roleConfig.color : currentVipStyle.color}40` }">
|
||||
<img v-if="form.avatar" :src="form.avatar" alt="Avatar">
|
||||
<span v-else>{{ user.username.charAt(0) }}</span>
|
||||
<div v-if="isUploading" class="upload-overlay"><span class="spinner"></span></div>
|
||||
</div>
|
||||
<div class="avatar-edit-overlay" @click="isEditingAvatar = !isEditingAvatar">
|
||||
<i data-lucide="camera"></i>
|
||||
</div>
|
||||
|
||||
<div v-if="isEditingAvatar" class="edit-popover avatar-pop">
|
||||
<div class="pop-tabs">
|
||||
<label class="pop-tab">
|
||||
<input type="file" class="hidden" accept=".jpg,.jpeg,.png,.gif,.webp,.bmp,image/jpeg,image/png,image/gif,image/webp,image/bmp" @change="handleUpload($event, 'avatar')">
|
||||
<i data-lucide="upload"></i>
|
||||
</label>
|
||||
<input type="text" v-model="form.avatar" placeholder="URL..." class="edit-input" @keyup.enter="save">
|
||||
<button @click="save" class="save-btn"><i data-lucide="check"></i></button>
|
||||
</div>
|
||||
<p class="upload-hint">✓ JPG ✓ PNG ✓ GIF (animiert) ✓ WebP ✓ BMP · Max. <b style="color:#888">5 MB</b></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="header-info">
|
||||
<div class="name-row">
|
||||
<h1 class="username" :class="roleConfig.effectClass" :style="{ color: roleConfig.name !== 'User' ? roleConfig.color : '#fff' }">
|
||||
<span v-if="user.clan_tag" class="clan-tag">[{{ user.clan_tag }}]</span>
|
||||
{{ user.username }}
|
||||
</h1>
|
||||
<div class="badges">
|
||||
<span class="badge vip" :style="{ color: currentVipStyle.color, borderColor: currentVipStyle.color, background: `${currentVipStyle.color}15` }">
|
||||
VIP {{ user.vip_level || 0 }}
|
||||
</span>
|
||||
<span class="badge role" :class="roleConfig.effectClass" :style="{ color: roleConfig.color, borderColor: roleConfig.color, background: `${roleConfig.color}15` }">
|
||||
{{ roleConfig.name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bio Editor -->
|
||||
<div class="bio-editor">
|
||||
<div v-if="!isEditingBio" class="bio-display" @click="isEditingBio = true">
|
||||
{{ form.bio || 'Click here to add a bio...' }}
|
||||
<i data-lucide="edit-2" class="edit-icon"></i>
|
||||
</div>
|
||||
<div v-else class="bio-input-wrap">
|
||||
<textarea v-model="form.bio" rows="2" class="bio-input" placeholder="Tell us about yourself..." maxlength="160"></textarea>
|
||||
<div class="bio-actions">
|
||||
<span class="char-count">{{ form.bio.length }}/160</span>
|
||||
<button @click="save" class="bio-save">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="header-actions">
|
||||
<div class="public-toggle" @click="togglePublic">
|
||||
<span class="toggle-label">Public Profile</span>
|
||||
<div class="toggle-switch" :class="{ active: form.is_public }">
|
||||
<div class="toggle-knob"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Copy Profile URL -->
|
||||
<div class="url-copy-box" @click="copyProfileUrl">
|
||||
<i data-lucide="link"></i>
|
||||
<span>Copy Link</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Grid (Preview) -->
|
||||
<div class="stats-grid opacity-50 pointer-events-none grayscale">
|
||||
<div class="stat-card highlight">
|
||||
<div class="stat-icon"><i data-lucide="coins"></i></div>
|
||||
<div class="stat-data">
|
||||
<div class="stat-label">Total Wagered</div>
|
||||
<div class="stat-value">${{ stats.wagered.toFixed(2) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon"><i data-lucide="trophy"></i></div>
|
||||
<div class="stat-data">
|
||||
<div class="stat-label">Total Wins</div>
|
||||
<div class="stat-value">{{ stats.wins }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon"><i data-lucide="heart"></i></div>
|
||||
<div class="stat-data">
|
||||
<div class="stat-label">Favorite Game</div>
|
||||
<div class="stat-value text-magenta">{{ stats.favorite_game }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon"><i data-lucide="clock"></i></div>
|
||||
<div class="stat-data">
|
||||
<div class="stat-label">Last Played</div>
|
||||
<div class="stat-value">{{ stats.last_played }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preview-hint">
|
||||
<i data-lucide="info"></i> Stats are just a preview here.
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</UserLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.edit-profile-page { min-height: 100vh; background: #050505; padding-bottom: 100px; position: relative; overflow: hidden; }
|
||||
|
||||
/* Role Effects & Backgrounds */
|
||||
.bg-fx { position: absolute; inset: 0; pointer-events: none; z-index: 0; opacity: 0; transition: opacity 0.5s; }
|
||||
|
||||
.role-admin .bg-fx {
|
||||
opacity: 1;
|
||||
background:
|
||||
radial-gradient(circle at 20% 30%, rgba(255, 62, 62, 0.08), transparent 50%),
|
||||
radial-gradient(circle at 80% 70%, rgba(255, 62, 62, 0.05), transparent 50%);
|
||||
animation: pulse-bg-red 5s infinite alternate;
|
||||
}
|
||||
.role-staff .bg-fx {
|
||||
opacity: 1;
|
||||
background:
|
||||
radial-gradient(circle at 20% 30%, rgba(0, 242, 255, 0.08), transparent 50%),
|
||||
radial-gradient(circle at 80% 70%, rgba(0, 242, 255, 0.05), transparent 50%);
|
||||
animation: pulse-bg-cyan 5s infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes pulse-bg-red { 0% { opacity: 0.8; } 100% { opacity: 1; } }
|
||||
@keyframes pulse-bg-cyan { 0% { opacity: 0.8; } 100% { opacity: 1; } }
|
||||
|
||||
/* Glitter Text */
|
||||
.username.role-admin { text-shadow: 0 0 15px rgba(255, 62, 62, 0.6); animation: glitter-red 3s infinite; }
|
||||
.username.role-staff { text-shadow: 0 0 15px rgba(0, 242, 255, 0.6); animation: glitter-cyan 3s infinite; }
|
||||
|
||||
/* Badge Glitter */
|
||||
.badge.role.role-admin { box-shadow: 0 0 10px rgba(255, 62, 62, 0.4); animation: border-pulse-red 2s infinite; }
|
||||
.badge.role.role-staff { box-shadow: 0 0 10px rgba(0, 242, 255, 0.4); animation: border-pulse-cyan 2s infinite; }
|
||||
|
||||
@keyframes glitter-red { 0%, 100% { filter: brightness(1); } 50% { filter: brightness(1.3); } }
|
||||
@keyframes glitter-cyan { 0%, 100% { filter: brightness(1); } 50% { filter: brightness(1.3); } }
|
||||
@keyframes border-pulse-red { 0%, 100% { border-color: rgba(255,62,62,0.3); } 50% { border-color: rgba(255,62,62,0.8); } }
|
||||
@keyframes border-pulse-cyan { 0%, 100% { border-color: rgba(0,242,255,0.3); } 50% { border-color: rgba(0,242,255,0.8); } }
|
||||
|
||||
/* Banner Editor */
|
||||
.banner-editor {
|
||||
height: 300px; background-size: cover; background-position: center; position: relative;
|
||||
border-bottom: 1px solid #222; transition: 0.3s; z-index: 1;
|
||||
}
|
||||
.banner-editor:hover .banner-overlay { opacity: 0.4; }
|
||||
.banner-overlay { position: absolute; inset: 0; background: #000; opacity: 0.2; transition: 0.3s; }
|
||||
|
||||
.edit-trigger {
|
||||
position: absolute; top: 20px; right: 20px; background: rgba(0,0,0,0.6); backdrop-filter: blur(10px);
|
||||
padding: 10px 16px; border-radius: 12px; color: #fff; font-weight: 700; font-size: 13px;
|
||||
display: flex; align-items: center; gap: 8px; cursor: pointer; border: 1px solid rgba(255,255,255,0.1);
|
||||
transition: 0.2s; opacity: 0; transform: translateY(-10px);
|
||||
}
|
||||
.banner-editor:hover .edit-trigger { opacity: 1; transform: translateY(0); }
|
||||
.edit-trigger:hover { background: rgba(255,0,122,0.8); border-color: #ff007a; }
|
||||
.edit-trigger i { width: 16px; height: 16px; }
|
||||
|
||||
/* Edit Popover */
|
||||
.edit-popover {
|
||||
position: absolute; background: #111; border: 1px solid #333; padding: 8px; border-radius: 12px;
|
||||
display: flex; flex-direction: column; gap: 8px; box-shadow: 0 10px 40px rgba(0,0,0,0.5); z-index: 20;
|
||||
animation: popIn 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.banner-pop { top: 70px; right: 20px; width: 320px; }
|
||||
.avatar-pop { bottom: -60px; left: 50%; transform: translateX(-50%); width: 280px; }
|
||||
|
||||
.pop-tabs { display: flex; align-items: center; gap: 8px; }
|
||||
.pop-tab {
|
||||
background: #222; color: #ccc; padding: 8px 12px; border-radius: 8px; font-size: 12px; font-weight: 700;
|
||||
cursor: pointer; display: flex; align-items: center; gap: 6px; transition: 0.2s;
|
||||
}
|
||||
.pop-tab:hover { background: #333; color: #fff; }
|
||||
.pop-tab i { width: 14px; }
|
||||
.pop-divider { font-size: 10px; color: #555; font-weight: 700; text-transform: uppercase; }
|
||||
.upload-hint { font-size: 10px; color: #555; margin: 0; text-align: center; }
|
||||
|
||||
.edit-input {
|
||||
flex: 1; background: #000; border: 1px solid #222; color: #fff; padding: 8px 12px; border-radius: 8px; font-size: 13px; width: 100%;
|
||||
}
|
||||
.edit-input:focus { border-color: #ff007a; outline: none; }
|
||||
.save-btn {
|
||||
background: #ff007a; color: #fff; border: none; width: 36px; height: 36px; border-radius: 8px; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
||||
}
|
||||
.save-btn:hover { background: #d40065; }
|
||||
|
||||
/* Content Container */
|
||||
.content-container { max-width: 1100px; margin: -80px auto 0; padding: 0 20px; position: relative; z-index: 10; }
|
||||
|
||||
/* Header */
|
||||
.profile-header { display: flex; align-items: flex-end; gap: 30px; margin-bottom: 40px; }
|
||||
|
||||
/* Avatar Editor */
|
||||
.avatar-wrapper { position: relative; cursor: pointer; }
|
||||
.avatar {
|
||||
width: 160px; height: 160px; border-radius: 50%; border: 6px solid #050505; background: #111;
|
||||
overflow: hidden; display: flex; align-items: center; justify-content: center;
|
||||
font-size: 48px; font-weight: 900; color: #333; box-shadow: 0 10px 30px rgba(0,0,0,0.5);
|
||||
transition: 0.3s; position: relative;
|
||||
}
|
||||
.avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.upload-overlay { position: absolute; inset: 0; background: rgba(0,0,0,0.7); display: flex; align-items: center; justify-content: center; }
|
||||
.spinner { width: 24px; height: 24px; border: 3px solid rgba(255,255,255,0.3); border-top-color: #fff; border-radius: 50%; animation: spin 1s linear infinite; }
|
||||
|
||||
.avatar-edit-overlay {
|
||||
position: absolute; inset: 6px; border-radius: 50%; background: rgba(0,0,0,0.6);
|
||||
display: flex; align-items: center; justify-content: center; color: #fff; opacity: 0;
|
||||
transition: 0.2s; backdrop-filter: blur(2px);
|
||||
}
|
||||
.avatar-wrapper:hover .avatar-edit-overlay { opacity: 1; }
|
||||
.avatar-edit-overlay i { width: 32px; height: 32px; }
|
||||
|
||||
/* Info */
|
||||
.header-info { flex: 1; padding-bottom: 10px; }
|
||||
.name-row { display: flex; align-items: center; gap: 16px; margin-bottom: 12px; flex-wrap: wrap; }
|
||||
.username { font-size: 32px; font-weight: 900; color: #fff; display: flex; align-items: center; gap: 10px; }
|
||||
.clan-tag { color: inherit; opacity: 0.8; font-size: 0.6em; background: rgba(255,255,255,0.1); padding: 2px 8px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.1); vertical-align: middle; }
|
||||
|
||||
.badge.vip { font-size: 11px; font-weight: 900; padding: 4px 10px; border-radius: 6px; text-transform: uppercase; background: rgba(255,0,122,0.1); color: #ff007a; border: 1px solid rgba(255,0,122,0.2); }
|
||||
.badges { display: flex; gap: 8px; }
|
||||
.badge { font-size: 11px; font-weight: 900; padding: 4px 10px; border-radius: 6px; text-transform: uppercase; border: 1px solid transparent; letter-spacing: 0.5px; }
|
||||
|
||||
/* Bio Editor */
|
||||
.bio-display {
|
||||
color: #888; font-size: 15px; max-width: 600px; line-height: 1.5; cursor: pointer;
|
||||
padding: 8px 12px; border-radius: 8px; border: 1px dashed transparent; transition: 0.2s;
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.bio-display:hover { border-color: #333; background: #111; color: #aaa; }
|
||||
.edit-icon { width: 14px; height: 14px; opacity: 0.5; }
|
||||
|
||||
.bio-input-wrap { max-width: 600px; background: #111; border: 1px solid #333; border-radius: 12px; padding: 12px; }
|
||||
.bio-input { width: 100%; background: transparent; border: none; color: #fff; font-size: 15px; resize: none; font-family: inherit; }
|
||||
.bio-actions { display: flex; justify-content: space-between; align-items: center; margin-top: 8px; }
|
||||
.char-count { font-size: 11px; color: #555; }
|
||||
.bio-save { background: #ff007a; color: #fff; border: none; padding: 6px 16px; border-radius: 6px; font-size: 12px; font-weight: 700; cursor: pointer; }
|
||||
.bio-save:hover { background: #d40065; }
|
||||
|
||||
/* Public Toggle & Copy */
|
||||
.header-actions { padding-bottom: 20px; display: flex; flex-direction: column; gap: 12px; align-items: flex-end; }
|
||||
.public-toggle {
|
||||
display: flex; align-items: center; gap: 12px; background: #111; padding: 10px 16px;
|
||||
border-radius: 12px; border: 1px solid #222; cursor: pointer; transition: 0.2s;
|
||||
}
|
||||
.public-toggle:hover { border-color: #333; }
|
||||
.toggle-label { font-size: 13px; font-weight: 700; color: #ccc; }
|
||||
.toggle-switch { width: 44px; height: 24px; background: #333; border-radius: 99px; position: relative; transition: 0.3s; }
|
||||
.toggle-switch.active { background: #ff007a; }
|
||||
.toggle-knob {
|
||||
width: 18px; height: 18px; background: #fff; border-radius: 50%; position: absolute;
|
||||
top: 3px; left: 3px; transition: 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.toggle-switch.active .toggle-knob { transform: translateX(20px); }
|
||||
|
||||
.url-copy-box {
|
||||
display: flex; align-items: center; gap: 8px; background: #111; padding: 8px 12px;
|
||||
border-radius: 8px; border: 1px solid #222; cursor: pointer; transition: 0.2s;
|
||||
color: #888; font-size: 12px; font-weight: 700;
|
||||
}
|
||||
.url-copy-box:hover { color: #fff; border-color: #333; }
|
||||
.url-copy-box i { width: 14px; height: 14px; }
|
||||
|
||||
/* Stats Grid (Preview) */
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; margin-bottom: 20px; }
|
||||
.stat-card { background: #0f0f0f; border: 1px solid #1f1f1f; padding: 20px; border-radius: 16px; display: flex; align-items: center; gap: 16px; }
|
||||
.stat-card.highlight { background: linear-gradient(135deg, rgba(255,0,122,0.05), transparent); border-color: rgba(255,0,122,0.2); }
|
||||
.stat-icon { width: 48px; height: 48px; background: #18181b; border-radius: 12px; display: flex; align-items: center; justify-content: center; color: #666; }
|
||||
.stat-card.highlight .stat-icon { color: #ff007a; background: rgba(255,0,122,0.1); }
|
||||
.stat-label { font-size: 11px; font-weight: 700; color: #666; text-transform: uppercase; margin-bottom: 4px; }
|
||||
.stat-value { font-size: 18px; font-weight: 900; color: #fff; }
|
||||
.text-magenta { color: #ff007a; }
|
||||
|
||||
.preview-hint { text-align: center; color: #444; font-size: 12px; display: flex; align-items: center; justify-content: center; gap: 6px; margin-top: 20px; }
|
||||
.preview-hint i { width: 14px; }
|
||||
|
||||
@keyframes popIn { from { opacity: 0; transform: scale(0.9); } to { opacity: 1; transform: scale(1); } }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.profile-header { flex-direction: column; align-items: center; text-align: center; margin-top: -60px; }
|
||||
.header-info { padding-bottom: 0; width: 100%; display: flex; flex-direction: column; align-items: center; }
|
||||
.name-row { justify-content: center; }
|
||||
.stats-grid { grid-template-columns: 1fr 1fr; }
|
||||
.banner-editor { height: 200px; }
|
||||
.avatar { width: 120px; height: 120px; }
|
||||
}
|
||||
|
||||
/* Mobile tweaks */
|
||||
@media (max-width: 420px) {
|
||||
.username { font-size: clamp(18px, 7vw, 24px); word-break: break-word; }
|
||||
.stats-grid { grid-template-columns: 1fr 1fr; }
|
||||
.banner-pop { width: min(92vw, 320px); right: 12px; }
|
||||
.avatar-pop { width: min(92vw, 280px); left: 50%; transform: translateX(-50%); }
|
||||
.edit-trigger { padding: 8px 12px; font-size: 12px; top: 12px; right: 12px; }
|
||||
.content-container { margin-top: -50px; }
|
||||
}
|
||||
</style>
|
||||
610
resources/js/pages/Trophy.vue
Normal file
610
resources/js/pages/Trophy.vue
Normal file
@@ -0,0 +1,610 @@
|
||||
<script setup lang="ts">
|
||||
import { Head } from '@inertiajs/vue3';
|
||||
import { computed } from 'vue';
|
||||
import UserLayout from '@/layouts/user/userlayout.vue';
|
||||
|
||||
interface Achievement {
|
||||
key: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
unlocked: boolean;
|
||||
unlocked_at: string | null;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
achievements: Achievement[];
|
||||
total: number;
|
||||
unlocked: number;
|
||||
profileUser?: { username: string; avatar: string | null };
|
||||
}>();
|
||||
|
||||
// Tier & color config per achievement
|
||||
const TIER: Record<string, { color: string; light: string; glow: string; shadow: string; tier: string }> = {
|
||||
first_bet: { tier: 'bronze', color: '#a0522d', light: '#e8883a', glow: 'rgba(205,127,50,0.6)', shadow: '#5c2f10' },
|
||||
first_win: { tier: 'gold', color: '#b8860b', light: '#ffd700', glow: 'rgba(255,200,0,0.7)', shadow: '#7a5600' },
|
||||
big_winner: { tier: 'gold', color: '#b8860b', light: '#ffd700', glow: 'rgba(255,200,0,0.7)', shadow: '#7a5600' },
|
||||
high_roller: { tier: 'silver', color: '#777', light: '#ddd', glow: 'rgba(200,200,200,0.5)', shadow: '#333' },
|
||||
frequent_player: { tier: 'silver', color: '#777', light: '#ddd', glow: 'rgba(200,200,200,0.5)', shadow: '#333' },
|
||||
hundred_bets: { tier: 'gold', color: '#b8860b', light: '#ffd700', glow: 'rgba(255,200,0,0.7)', shadow: '#7a5600' },
|
||||
vault_user: { tier: 'bronze', color: '#a0522d', light: '#e8883a', glow: 'rgba(205,127,50,0.6)', shadow: '#5c2f10' },
|
||||
vip_level2: { tier: 'silver', color: '#777', light: '#ddd', glow: 'rgba(200,200,200,0.5)', shadow: '#333' },
|
||||
vip_level5: { tier: 'gold', color: '#b8860b', light: '#ffd700', glow: 'rgba(255,200,0,0.7)', shadow: '#7a5600' },
|
||||
guild_member: { tier: 'special',color: '#4a0080', light: '#c455ff', glow: 'rgba(180,50,255,0.6)', shadow: '#2a0050' },
|
||||
promo_user: { tier: 'bronze', color: '#a0522d', light: '#e8883a', glow: 'rgba(205,127,50,0.6)', shadow: '#5c2f10' },
|
||||
};
|
||||
|
||||
function getTier(key: string) {
|
||||
return TIER[key] ?? { tier: 'bronze', color: '#a0522d', light: '#e8883a', glow: 'rgba(205,127,50,0.6)', shadow: '#5c2f10' };
|
||||
}
|
||||
|
||||
// Split into rows of 4
|
||||
const rows = computed(() => {
|
||||
const out = [];
|
||||
for (let i = 0; i < props.achievements.length; i += 4) {
|
||||
out.push(props.achievements.slice(i, i + 4));
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
function formatDate(iso: string | null): string {
|
||||
if (!iso) return '';
|
||||
return new Date(iso).toLocaleDateString('de-DE', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
}
|
||||
|
||||
const progressPct = computed(() =>
|
||||
props.total > 0 ? Math.round((props.unlocked / props.total) * 100) : 0
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UserLayout>
|
||||
<Head :title="profileUser ? `${profileUser.username}'s Trophy Room` : 'Trophy Room'" />
|
||||
|
||||
<div class="trophy-page">
|
||||
|
||||
<!-- Page Header -->
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<div v-if="profileUser" class="viewing-badge">
|
||||
<img v-if="profileUser.avatar" :src="profileUser.avatar" class="viewer-avatar" alt="" />
|
||||
<span>{{ profileUser.username }}'s Collection</span>
|
||||
</div>
|
||||
<h1>Trophy Room</h1>
|
||||
<p class="header-sub">{{ unlocked }} of {{ total }} achievements unlocked</p>
|
||||
</div>
|
||||
<div class="header-progress">
|
||||
<div class="progress-ring-wrap">
|
||||
<svg class="progress-ring" viewBox="0 0 80 80">
|
||||
<circle cx="40" cy="40" r="32" fill="none" stroke="#1a1a1a" stroke-width="6"/>
|
||||
<circle
|
||||
cx="40" cy="40" r="32"
|
||||
fill="none"
|
||||
stroke="url(#ringGrad)"
|
||||
stroke-width="6"
|
||||
stroke-linecap="round"
|
||||
:stroke-dasharray="`${progressPct * 2.01} 201`"
|
||||
transform="rotate(-90 40 40)"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient id="ringGrad" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#df006a"/>
|
||||
<stop offset="100%" stop-color="#ffb700"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
<div class="ring-label">{{ progressPct }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Trophy Cabinet -->
|
||||
<div class="cabinet">
|
||||
<!-- Cabinet top frame -->
|
||||
<div class="cabinet-header">
|
||||
<span class="cabinet-label">🏅 Achievement Showcase</span>
|
||||
<div class="cabinet-bolts">
|
||||
<span class="bolt"></span>
|
||||
<span class="bolt"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Glass panel shine -->
|
||||
<div class="glass-shine"></div>
|
||||
|
||||
<!-- Shelves -->
|
||||
<div class="cabinet-body">
|
||||
<div v-for="(row, rowIdx) in rows" :key="rowIdx" class="shelf-row">
|
||||
<!-- Shelf surface -->
|
||||
<div class="shelf-surface"></div>
|
||||
<!-- Shelf underside shadow -->
|
||||
<div class="shelf-shadow"></div>
|
||||
|
||||
<!-- Trophies on this shelf -->
|
||||
<div class="shelf-trophies">
|
||||
<div
|
||||
v-for="ach in row"
|
||||
:key="ach.key"
|
||||
class="trophy-slot"
|
||||
:class="{ unlocked: ach.unlocked, locked: !ach.unlocked }"
|
||||
:title="ach.description"
|
||||
>
|
||||
<!-- 3D Trophy -->
|
||||
<div class="trophy-3d" :data-tier="getTier(ach.key).tier">
|
||||
<!-- Glow aura (unlocked only) -->
|
||||
<div v-if="ach.unlocked" class="trophy-aura" :style="{ background: getTier(ach.key).glow }"></div>
|
||||
|
||||
<!-- Star on top -->
|
||||
<div v-if="ach.unlocked" class="trophy-star">★</div>
|
||||
|
||||
<!-- Cup body with handles -->
|
||||
<div class="cup-wrap">
|
||||
<div
|
||||
class="cup-body"
|
||||
:style="ach.unlocked ? {
|
||||
background: `linear-gradient(135deg, ${getTier(ach.key).light} 0%, ${getTier(ach.key).color} 50%, ${getTier(ach.key).shadow} 100%)`,
|
||||
boxShadow: `inset -4px -4px 8px ${getTier(ach.key).shadow}, inset 4px 4px 8px ${getTier(ach.key).light}55`
|
||||
} : {}"
|
||||
>
|
||||
<!-- Shine highlight -->
|
||||
<div class="cup-shine"></div>
|
||||
<!-- Icon inside cup -->
|
||||
<div class="cup-icon">{{ ach.icon }}</div>
|
||||
</div>
|
||||
<!-- Handles -->
|
||||
<div
|
||||
class="handle handle-left"
|
||||
:style="ach.unlocked ? { borderColor: getTier(ach.key).color } : {}"
|
||||
></div>
|
||||
<div
|
||||
class="handle handle-right"
|
||||
:style="ach.unlocked ? { borderColor: getTier(ach.key).color } : {}"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Stem -->
|
||||
<div
|
||||
class="trophy-stem"
|
||||
:style="ach.unlocked ? {
|
||||
background: `linear-gradient(90deg, ${getTier(ach.key).shadow}, ${getTier(ach.key).color}, ${getTier(ach.key).shadow})`
|
||||
} : {}"
|
||||
></div>
|
||||
|
||||
<!-- Base -->
|
||||
<div
|
||||
class="trophy-base"
|
||||
:style="ach.unlocked ? {
|
||||
background: `linear-gradient(180deg, ${getTier(ach.key).color} 0%, ${getTier(ach.key).shadow} 100%)`,
|
||||
boxShadow: `0 4px 12px ${getTier(ach.key).glow}`
|
||||
} : {}"
|
||||
></div>
|
||||
|
||||
<!-- Locked overlay -->
|
||||
<div v-if="!ach.unlocked" class="locked-overlay">
|
||||
<div class="lock-icon">🔒</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Name plate below trophy -->
|
||||
<div class="nameplate" :style="ach.unlocked ? { borderColor: getTier(ach.key).color + '66', color: getTier(ach.key).light } : {}">
|
||||
{{ ach.title }}
|
||||
</div>
|
||||
<div v-if="ach.unlocked_at" class="unlock-date">
|
||||
{{ formatDate(ach.unlocked_at) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cabinet bottom frame -->
|
||||
<div class="cabinet-footer"></div>
|
||||
</div>
|
||||
|
||||
<!-- Tier legend -->
|
||||
<div class="tier-legend">
|
||||
<div class="legend-item">
|
||||
<span class="legend-dot" style="background: linear-gradient(135deg, #ffd700, #b8860b);"></span>
|
||||
Gold
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<span class="legend-dot" style="background: linear-gradient(135deg, #ddd, #777);"></span>
|
||||
Silver
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<span class="legend-dot" style="background: linear-gradient(135deg, #e8883a, #a0522d);"></span>
|
||||
Bronze
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<span class="legend-dot" style="background: linear-gradient(135deg, #c455ff, #4a0080);"></span>
|
||||
Special
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</UserLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.trophy-page {
|
||||
padding: 30px;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ── Page Header ─────────────────────────────── */
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 40px;
|
||||
gap: 20px;
|
||||
}
|
||||
.header-left h1 {
|
||||
font-size: 2.2rem;
|
||||
font-weight: 900;
|
||||
color: #fff;
|
||||
letter-spacing: -1px;
|
||||
line-height: 1;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.header-sub {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
font-weight: 600;
|
||||
}
|
||||
.viewing-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: rgba(223,0,106,0.08);
|
||||
border: 1px solid rgba(223,0,106,0.2);
|
||||
border-radius: 999px;
|
||||
padding: 4px 12px 4px 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--primary, #df006a);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.viewer-avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.progress-ring-wrap {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.progress-ring { width: 80px; height: 80px; }
|
||||
.ring-label {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 900;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ── Cabinet ─────────────────────────────────── */
|
||||
.cabinet {
|
||||
position: relative;
|
||||
background: linear-gradient(180deg, #0e0a06 0%, #0a0a0a 100%);
|
||||
border: 2px solid #2a1f0e;
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
0 0 0 1px #1a1208,
|
||||
0 20px 60px rgba(0,0,0,0.8),
|
||||
inset 0 1px 0 rgba(255,200,100,0.06);
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.cabinet-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 24px;
|
||||
background: linear-gradient(90deg, #1a120a, #120e06, #1a120a);
|
||||
border-bottom: 1px solid #2a1a08;
|
||||
}
|
||||
.cabinet-label {
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 3px;
|
||||
text-transform: uppercase;
|
||||
color: #7a5a2a;
|
||||
}
|
||||
.cabinet-bolts {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.bolt {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle at 35% 35%, #5a4020, #2a1a08);
|
||||
border: 1px solid #3a2510;
|
||||
box-shadow: inset 1px 1px 2px rgba(255,200,100,0.15);
|
||||
}
|
||||
.glass-shine {
|
||||
position: absolute;
|
||||
top: 44px;
|
||||
left: 0; right: 0;
|
||||
height: 80px;
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.03) 0%, transparent 100%);
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
.cabinet-body {
|
||||
padding: 24px 20px 0;
|
||||
}
|
||||
.cabinet-footer {
|
||||
height: 20px;
|
||||
background: linear-gradient(90deg, #1a120a, #120e06, #1a120a);
|
||||
border-top: 1px solid #2a1a08;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* ── Shelf ───────────────────────────────────── */
|
||||
.shelf-row {
|
||||
position: relative;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 28px;
|
||||
}
|
||||
.shelf-surface {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: -20px;
|
||||
right: -20px;
|
||||
height: 14px;
|
||||
background: linear-gradient(180deg, #3a2810 0%, #2a1e0c 50%, #1e1408 100%);
|
||||
border-top: 1px solid #5a3a18;
|
||||
box-shadow: 0 2px 0 #0a0806, inset 0 1px 0 rgba(255,200,100,0.1);
|
||||
z-index: 5;
|
||||
}
|
||||
.shelf-shadow {
|
||||
position: absolute;
|
||||
bottom: -10px;
|
||||
left: 0; right: 0;
|
||||
height: 10px;
|
||||
background: linear-gradient(180deg, rgba(0,0,0,0.5), transparent);
|
||||
z-index: 4;
|
||||
}
|
||||
.shelf-trophies {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-start;
|
||||
padding: 16px 8px 6px;
|
||||
position: relative;
|
||||
z-index: 6;
|
||||
}
|
||||
|
||||
/* ── Trophy Slot ─────────────────────────────── */
|
||||
.trophy-slot {
|
||||
flex: 1;
|
||||
max-width: 200px;
|
||||
min-width: 100px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
cursor: default;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
.trophy-slot:hover {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
.trophy-slot.locked .trophy-3d {
|
||||
filter: grayscale(0.9) brightness(0.4);
|
||||
}
|
||||
|
||||
/* ── 3D Trophy ───────────────────────────────── */
|
||||
.trophy-3d {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 90px;
|
||||
perspective: 400px;
|
||||
user-select: none;
|
||||
}
|
||||
.trophy-aura {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
border-radius: 50%;
|
||||
opacity: 0.25;
|
||||
filter: blur(14px);
|
||||
pointer-events: none;
|
||||
animation: aura-pulse 2.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes aura-pulse {
|
||||
0%, 100% { opacity: 0.2; transform: translate(-50%, -50%) scale(1); }
|
||||
50% { opacity: 0.4; transform: translate(-50%, -50%) scale(1.2); }
|
||||
}
|
||||
|
||||
.trophy-star {
|
||||
font-size: 14px;
|
||||
color: #ffd700;
|
||||
text-shadow: 0 0 8px #ffd70088;
|
||||
margin-bottom: 2px;
|
||||
animation: star-spin 6s linear infinite;
|
||||
display: inline-block;
|
||||
}
|
||||
@keyframes star-spin {
|
||||
0% { transform: rotate(0deg) scale(1); }
|
||||
50% { transform: rotate(180deg) scale(1.2); }
|
||||
100% { transform: rotate(360deg) scale(1); }
|
||||
}
|
||||
|
||||
/* Cup */
|
||||
.cup-wrap {
|
||||
position: relative;
|
||||
width: 60px;
|
||||
height: 52px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.cup-body {
|
||||
width: 46px;
|
||||
height: 52px;
|
||||
border-radius: 8px 8px 18px 18px;
|
||||
background: linear-gradient(135deg, #555 0%, #333 50%, #111 100%);
|
||||
position: relative;
|
||||
clip-path: polygon(8% 0%, 92% 0%, 100% 30%, 88% 100%, 12% 100%, 0% 30%);
|
||||
overflow: hidden;
|
||||
box-shadow: inset -4px -4px 8px rgba(0,0,0,0.4), inset 4px 4px 8px rgba(255,255,255,0.08);
|
||||
transition: background 0.3s, box-shadow 0.3s;
|
||||
}
|
||||
.cup-shine {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 6px;
|
||||
width: 12px;
|
||||
height: 24px;
|
||||
border-radius: 6px;
|
||||
background: rgba(255,255,255,0.18);
|
||||
transform: rotate(-15deg);
|
||||
pointer-events: none;
|
||||
}
|
||||
.cup-icon {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
filter: drop-shadow(0 1px 2px rgba(0,0,0,0.5));
|
||||
padding-top: 6px;
|
||||
}
|
||||
/* Handles */
|
||||
.handle {
|
||||
position: absolute;
|
||||
width: 14px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
border: 5px solid #333;
|
||||
top: 8px;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
.handle-left {
|
||||
left: -4px;
|
||||
border-right: none;
|
||||
border-radius: 50% 0 0 50%;
|
||||
}
|
||||
.handle-right {
|
||||
right: -4px;
|
||||
border-left: none;
|
||||
border-radius: 0 50% 50% 0;
|
||||
}
|
||||
|
||||
/* Stem */
|
||||
.trophy-stem {
|
||||
width: 10px;
|
||||
height: 22px;
|
||||
background: linear-gradient(90deg, #222, #444, #222);
|
||||
border-radius: 2px;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
/* Base */
|
||||
.trophy-base {
|
||||
width: 52px;
|
||||
height: 12px;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(180deg, #444 0%, #222 100%);
|
||||
box-shadow: 0 3px 8px rgba(0,0,0,0.5);
|
||||
transition: background 0.3s, box-shadow 0.3s;
|
||||
}
|
||||
|
||||
/* Locked overlay */
|
||||
.locked-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
}
|
||||
.lock-icon {
|
||||
font-size: 22px;
|
||||
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.8));
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* ── Nameplate ───────────────────────────────── */
|
||||
.nameplate {
|
||||
margin-top: 10px;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
background: #0d0d0d;
|
||||
border: 1px solid #222;
|
||||
border-radius: 4px;
|
||||
padding: 3px 8px;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
transition: border-color 0.3s, color 0.3s;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.unlock-date {
|
||||
font-size: 9px;
|
||||
color: #444;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
margin-top: 3px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
/* ── Tier Legend ─────────────────────────────── */
|
||||
.tier-legend {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #666;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.legend-dot {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Responsive ─────────────────────────────── */
|
||||
@media (max-width: 700px) {
|
||||
.trophy-page { padding: 16px; }
|
||||
.shelf-trophies { gap: 4px; }
|
||||
.trophy-3d { width: 72px; }
|
||||
.cup-body { width: 38px; height: 42px; }
|
||||
.trophy-base { width: 42px; }
|
||||
.nameplate { font-size: 9px; padding: 2px 5px; }
|
||||
.cup-icon { font-size: 16px; }
|
||||
.page-header { flex-direction: column; align-items: flex-start; }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.shelf-trophies { flex-wrap: wrap; justify-content: center; }
|
||||
.trophy-slot { min-width: 80px; }
|
||||
}
|
||||
</style>
|
||||
156
resources/js/pages/User.vue
Normal file
156
resources/js/pages/User.vue
Normal file
@@ -0,0 +1,156 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, nextTick } from 'vue';
|
||||
import UserLayout from '../layouts/user/userlayout.vue';
|
||||
|
||||
const liveFeed = ref([]);
|
||||
|
||||
const generateLiveFeed = () => {
|
||||
const games = ['Mental', 'San Quentin', 'Flight Mode', 'Sweet Bonanza'];
|
||||
const users = ['Andri_X', 'CryptoKing', 'Neon_Ripper'];
|
||||
const items = [];
|
||||
for(let i=0; i<8; i++) {
|
||||
const isWin = Math.random() > 0.3;
|
||||
items.push({
|
||||
id: i,
|
||||
user: users[Math.floor(Math.random()*users.length)],
|
||||
game: games[Math.floor(Math.random()*games.length)],
|
||||
amount: isWin ? '+' + (Math.random()).toFixed(3) + ' BTC' : '—',
|
||||
isWin
|
||||
});
|
||||
}
|
||||
liveFeed.value = items;
|
||||
nextTick(() => {
|
||||
if (window.lucide) window.lucide.createIcons();
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
generateLiveFeed();
|
||||
|
||||
// Slot hover effect
|
||||
const slots = document.querySelectorAll('.slot');
|
||||
slots.forEach(card => {
|
||||
card.addEventListener('mousemove', e => {
|
||||
const rect = card.getBoundingClientRect();
|
||||
card.style.setProperty('--mouse-x', `${((e.clientX - rect.left) / rect.width) * 100}%`);
|
||||
card.style.setProperty('--mouse-y', `${((e.clientY - rect.top) / rect.height) * 100}%`);
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UserLayout>
|
||||
<section class="content">
|
||||
<div class="wrap">
|
||||
<div class="panel">
|
||||
<div class="toolbar">
|
||||
<div class="search">
|
||||
<i data-lucide="search" style="width:14px; color:#333;"></i>
|
||||
<input type="text" placeholder="Suche nach Slots...">
|
||||
</div>
|
||||
<div style="display:flex;">
|
||||
<div class="chip active">Alle</div>
|
||||
<div class="chip">Top</div>
|
||||
<div class="chip">Neu</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-head">
|
||||
<h2>Top Slots</h2>
|
||||
<div class="view-all">Alle anzeigen →</div>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<article class="slot c1"><div class="slot-provider">Nolimit City</div><div class="thumb"><img src="https://netcontent.cc/bitkingz/i/s4/nolimit/FlightModeDX2.webp"></div><div class="slot-overlay"><button class="btn-s btn-play">Play</button><button class="btn-s btn-demo">Demo</button></div></article>
|
||||
<article class="slot c2"><div class="slot-provider">Nolimit City</div><div class="thumb"><img src="https://netcontent.cc/bitkingz/i/s6/nolimit/SanQuentin.webp"></div><div class="slot-overlay"><button class="btn-s btn-play">Play</button><button class="btn-s btn-demo">Demo</button></div></article>
|
||||
<article class="slot c3"><div class="slot-provider">Nolimit City</div><div class="thumb"><img src="https://netcontent.cc/bitkingz/i/s4/nolimit/MentalDX1.webp"></div><div class="slot-overlay"><button class="btn-s btn-play">Play</button><button class="btn-s btn-demo">Demo</button></div></article>
|
||||
<article class="slot c4"><div class="slot-provider">Pragmatic Play</div><div class="thumb"><img src="https://netcontent.cc/bitkingz/i/s4/pragmatic/SweetBonanza.webp"></div><div class="slot-overlay"><button class="btn-s btn-play">Play</button><button class="btn-s btn-demo">Demo</button></div></article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel live-card">
|
||||
<div class="live-head">
|
||||
<div class="live-title"><span class="dot"></span> Live Feed</div>
|
||||
<div class="live-meta" style="font-size: 10px; color: #444; font-weight: 800;">Realtime Protocol v2.0</div>
|
||||
</div>
|
||||
<div class="live-body" id="live-feed">
|
||||
<div v-for="item in liveFeed" :key="item.id" class="live-item">
|
||||
<div class="li-avatar"><i data-lucide="user" style="width:14px; color:#333;"></i></div>
|
||||
<div class="li-info"><span class="li-user">{{ item.user }}</span><span class="li-game">{{ item.game }}</span></div>
|
||||
<div class="li-val"><span :class="['li-amount', { loss: !item.isWin }]">{{ item.amount }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</UserLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.content { padding: 30px; padding-bottom: 30px; }
|
||||
.wrap { max-width: 1400px; margin: 0 auto; display: flex; flex-direction: column; gap: 30px; }
|
||||
|
||||
.panel { background: var(--bg-card); border: 1px solid var(--border); border-radius: 16px; overflow: hidden; animation: fade-in 0.8s cubic-bezier(0.2, 0, 0, 1); }
|
||||
@keyframes fade-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
||||
|
||||
.toolbar { padding: 15px 20px; display: flex; gap: 20px; align-items: center; border-bottom: 1px solid var(--border); }
|
||||
.search { flex: 1; background: #000; border: 1px solid var(--border); display: flex; align-items: center; padding: 0 15px; border-radius: 10px; transition: 0.3s; }
|
||||
.search:focus-within { border-color: var(--cyan); }
|
||||
.search input { background: transparent; border: none; color: #fff; padding: 10px; width: 100%; font-size: 12px; }
|
||||
|
||||
.chip { padding: 8px 16px; font-size: 10px; font-weight: 900; color: #444; cursor: pointer; text-transform: uppercase; border-radius: 8px; transition: 0.2s; }
|
||||
.chip:hover { color: #fff; }
|
||||
.chip.active { color: var(--magenta); background: rgba(255,0,122,0.05); }
|
||||
|
||||
.section-head { padding: 25px 20px 10px; display: flex; justify-content: space-between; align-items: flex-end; }
|
||||
.section-head h2 { margin: 0; font-size: 11px; font-weight: 900; text-transform: uppercase; letter-spacing: 2px; color: #444; }
|
||||
.view-all { font-size: 10px; color: var(--cyan); font-weight: 900; cursor: pointer; text-transform: uppercase; }
|
||||
|
||||
.grid { padding: 20px; display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 20px; }
|
||||
|
||||
.slot { background: #000; border: 1px solid var(--border); border-radius: 18px; position: relative; overflow: hidden; aspect-ratio: 16/11; transition: 0.4s cubic-bezier(0.2, 0, 0, 1); --mouse-x: 50%; --mouse-y: 50%; }
|
||||
.slot::after { content: ""; position: absolute; inset: -10px; z-index: 3; pointer-events: none; background: radial-gradient(240px circle at var(--mouse-x) var(--mouse-y), var(--glow), transparent 70%); opacity: 0; transition: opacity 0.22s cubic-bezier(0.2, 0, 0, 1); filter: blur(10px); }
|
||||
.slot:hover::after { opacity: 1; }
|
||||
.slot.c1 { --glow: var(--cyan); }
|
||||
.slot.c2 { --glow: var(--magenta); }
|
||||
.slot.c3 { --glow: var(--green); }
|
||||
.slot.c4 { --glow: var(--gold); }
|
||||
.slot:hover { transform: translateY(-8px) scale(1.02); box-shadow: 0 15px 40px rgba(0,0,0,0.9); }
|
||||
|
||||
.slot-provider { position: absolute; top: 12px; left: 12px; z-index: 4; font-size: 8px; font-weight: 900; background: rgba(0,0,0,0.85); padding: 5px 10px; border-radius: 6px; text-transform: uppercase; letter-spacing: 1px; color: #fff; border: 1px solid #222; opacity: 1; transition: 0.3s; }
|
||||
.slot:hover .slot-provider { opacity: 0; }
|
||||
|
||||
.thumb { width: 100%; height: 100%; }
|
||||
.thumb img { width: 100%; height: 100%; object-fit: cover; opacity: 0.4; transition: 0.6s cubic-bezier(0.2, 0, 0, 1); filter: grayscale(1) brightness(0.6); }
|
||||
.slot:hover img { opacity: 1; filter: grayscale(0) brightness(1); transform: scale(1.08); }
|
||||
.slot-overlay { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; background: linear-gradient(to top, rgba(0,0,0,0.8), transparent); opacity: 0; transition: 0.3s; z-index: 5; }
|
||||
.slot:hover .slot-overlay { opacity: 1; }
|
||||
.btn-s { width: 110px; padding: 10px; border-radius: 50px; font-size: 10px; font-weight: 900; text-transform: uppercase; cursor: pointer; transition: 0.2s; border: none; }
|
||||
.btn-play { background: var(--glow); color: #000; box-shadow: 0 0 15px var(--glow); }
|
||||
.btn-demo { background: transparent; border: 1px solid #fff; color: #fff; margin-top: 4px; }
|
||||
|
||||
.live-card { background: #000; border: 1px solid var(--border); border-radius: 16px; overflow: hidden; }
|
||||
.live-head { padding: 16px 20px; display:flex; align-items:center; justify-content:space-between; border-bottom: 1px solid var(--border); }
|
||||
.live-title { display:flex; align-items:center; gap:10px; font-size: 11px; font-weight: 900; letter-spacing: 2px; text-transform: uppercase; color: #fff; }
|
||||
.live-title .dot { width:8px; height:8px; border-radius:99px; background: var(--green); box-shadow: 0 0 18px rgba(0,255,157,.6); animation: pulse 1.2s infinite; }
|
||||
@keyframes pulse { 0%,100%{ transform:scale(1); opacity:1 } 50%{ transform:scale(1.25); opacity:.65 } }
|
||||
.live-body { height: 280px; overflow-y: auto; padding: 15px; display: flex; flex-direction: column; gap: 8px; }
|
||||
|
||||
.live-item { display:grid; grid-template-columns: auto 1fr auto; align-items:center; gap: 15px; padding: 10px 15px; border-radius: 12px; border: 1px solid #111; background: #050505; transition: .3s cubic-bezier(0.2, 0, 0, 1); }
|
||||
.li-avatar { width: 32px; height: 32px; border-radius: 8px; background: #111; display: flex; align-items: center; justify-content: center; border: 1px solid #222; }
|
||||
.li-info { display: flex; flex-direction: column; }
|
||||
.li-user { font-size: 12px; font-weight: 800; color: #fff; }
|
||||
.li-game { font-size: 10px; color: #555; font-weight: 700; text-transform: uppercase; }
|
||||
.li-val { text-align: right; }
|
||||
.li-amount { font-size: 12px; font-weight: 900; color: var(--green); display: block; }
|
||||
.li-amount.loss { color: #444; }
|
||||
|
||||
:global(:root) {
|
||||
--bg-card: #0a0a0a;
|
||||
--border: #151515;
|
||||
--magenta: #ff007a;
|
||||
--cyan: #00f2ff;
|
||||
--green: #00ff9d;
|
||||
--gold: #f7931a;
|
||||
}
|
||||
</style>
|
||||
520
resources/js/pages/VipLevels.vue
Normal file
520
resources/js/pages/VipLevels.vue
Normal file
@@ -0,0 +1,520 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, usePage, router } from '@inertiajs/vue3';
|
||||
import { computed, onMounted, nextTick, ref, watch } from 'vue';
|
||||
import { ChevronLeft, ChevronRight, Lock, Check, Star, Gift } from 'lucide-vue-next';
|
||||
import UserLayout from '../layouts/user/userlayout.vue';
|
||||
import { useNotifications } from '@/composables/useNotifications';
|
||||
|
||||
const page = usePage();
|
||||
const { notify } = useNotifications();
|
||||
const user = computed(() => page.props.auth.user);
|
||||
const stats = computed(() => user.value?.stats || { vip_points: 0, vip_level: 0 });
|
||||
|
||||
// Props from Controller
|
||||
const props = defineProps<{
|
||||
claimedLevels: number[];
|
||||
cashRewards: Record<number, number>;
|
||||
}>();
|
||||
|
||||
// Level Definitions
|
||||
const levels = [
|
||||
{
|
||||
level: 0,
|
||||
name: 'Newbie',
|
||||
xp: 0,
|
||||
color: '#888888',
|
||||
gradient: 'linear-gradient(135deg, #2a2a2a, #444)',
|
||||
shadow: 'rgba(100,100,100,0.5)',
|
||||
icon: 'egg',
|
||||
rewards: ['Access to Global Chat', 'Daily Free Spin']
|
||||
},
|
||||
{
|
||||
level: 1,
|
||||
name: 'Bronze',
|
||||
xp: 1000,
|
||||
color: '#cd7f32',
|
||||
gradient: 'linear-gradient(135deg, #5c3618, #cd7f32)',
|
||||
shadow: 'rgba(205,127,50,0.6)',
|
||||
icon: 'shield',
|
||||
rewards: ['10 Free Spins', '5% Rakeback', 'Bronze Badge', 'Weekly Reload']
|
||||
},
|
||||
{
|
||||
level: 2,
|
||||
name: 'Silver',
|
||||
xp: 5000,
|
||||
color: '#c0c0c0',
|
||||
gradient: 'linear-gradient(135deg, #555, #c0c0c0)',
|
||||
shadow: 'rgba(192,192,192,0.6)',
|
||||
icon: 'sword',
|
||||
rewards: ['25 Free Spins', '7% Rakeback', 'Silver Badge', 'Priority Support', 'Monthly Bonus']
|
||||
},
|
||||
{
|
||||
level: 3,
|
||||
name: 'Gold',
|
||||
xp: 20000,
|
||||
color: '#ffd700',
|
||||
gradient: 'linear-gradient(135deg, #8a6e06, #ffd700)',
|
||||
shadow: 'rgba(255,215,0,0.6)',
|
||||
icon: 'crown',
|
||||
rewards: ['50€ Cash Bonus', '10% Rakeback', 'Gold Badge', 'Instant Withdrawals', 'Birthday Gift']
|
||||
},
|
||||
{
|
||||
level: 4,
|
||||
name: 'Platinum',
|
||||
xp: 50000,
|
||||
color: '#00f2ff',
|
||||
gradient: 'linear-gradient(135deg, #005f6b, #00f2ff)',
|
||||
shadow: 'rgba(0,242,255,0.6)',
|
||||
icon: 'gem',
|
||||
rewards: ['200€ Cash Bonus', '12% Rakeback', 'Personal VIP Host', 'Exclusive Events', 'Higher Limits']
|
||||
},
|
||||
{
|
||||
level: 5,
|
||||
name: 'Diamond',
|
||||
xp: 150000,
|
||||
color: '#ff007a',
|
||||
gradient: 'linear-gradient(135deg, #75003a, #ff007a)',
|
||||
shadow: 'rgba(255,0,122,0.6)',
|
||||
icon: 'diamond',
|
||||
rewards: ['1000€ Cash Bonus', '15% Rakeback', 'Luxury Gifts', 'Concierge Service', 'Offline Events']
|
||||
},
|
||||
{
|
||||
level: 6,
|
||||
name: 'Obsidian',
|
||||
xp: 500000,
|
||||
color: '#ff3e3e',
|
||||
gradient: 'linear-gradient(135deg, #520000, #ff3e3e)',
|
||||
shadow: 'rgba(255,62,62,0.6)',
|
||||
icon: 'flame',
|
||||
rewards: ['Custom Supercar', '20% Rakeback', 'Share of Casino Profits', 'The "God" Badge', 'Private Jet Transfer']
|
||||
}
|
||||
];
|
||||
|
||||
// User Progress Logic
|
||||
const currentXP = computed(() => parseFloat(stats.value.vip_points || 0));
|
||||
|
||||
// Explicitly check user.vip_level first, then stats.vip_level
|
||||
const currentLevelIdx = computed(() => {
|
||||
// 1. Check direct user property (from users table)
|
||||
if (user.value?.vip_level !== undefined && user.value?.vip_level !== null) {
|
||||
const lvl = parseInt(user.value.vip_level);
|
||||
if (!isNaN(lvl) && lvl >= 0) {
|
||||
return Math.min(lvl, levels.length - 1);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check stats relation
|
||||
if (stats.value.vip_level !== undefined && stats.value.vip_level !== null) {
|
||||
const lvl = parseInt(stats.value.vip_level);
|
||||
if (!isNaN(lvl) && lvl >= 0) {
|
||||
return Math.min(lvl, levels.length - 1);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fallback: Calculate from XP
|
||||
let idx = 0;
|
||||
for (let i = 0; i < levels.length; i++) {
|
||||
if (currentXP.value >= levels[i].xp) idx = i;
|
||||
else break;
|
||||
}
|
||||
return idx;
|
||||
});
|
||||
|
||||
const nextLevel = computed(() => levels[currentLevelIdx.value + 1] || null);
|
||||
const progressPercent = computed(() => {
|
||||
if (!nextLevel.value) return 100;
|
||||
const currentLvlXP = levels[currentLevelIdx.value].xp;
|
||||
const nextLvlXP = nextLevel.value.xp;
|
||||
|
||||
// If user has level but not enough XP (manual set), show 0% or calculate relative
|
||||
if (currentXP.value < currentLvlXP) return 0;
|
||||
|
||||
const p = ((currentXP.value - currentLvlXP) / (nextLvlXP - currentLvlXP)) * 100;
|
||||
return Math.min(Math.max(p, 0), 100);
|
||||
});
|
||||
|
||||
// Slider Logic
|
||||
const activeIndex = ref(0);
|
||||
|
||||
// Watch for level changes to update slider initially
|
||||
watch(currentLevelIdx, (newVal) => {
|
||||
activeIndex.value = newVal;
|
||||
}, { immediate: true });
|
||||
|
||||
const nextSlide = () => {
|
||||
if (activeIndex.value < levels.length - 1) activeIndex.value++;
|
||||
};
|
||||
const prevSlide = () => {
|
||||
if (activeIndex.value > 0) activeIndex.value--;
|
||||
};
|
||||
const setSlide = (index: number) => {
|
||||
activeIndex.value = index;
|
||||
};
|
||||
|
||||
// Claim Logic
|
||||
const claiming = ref(false);
|
||||
async function claimReward(level: number) {
|
||||
if (claiming.value) return;
|
||||
claiming.value = true;
|
||||
try {
|
||||
await router.post('/vip-levels/claim', { level }, {
|
||||
onSuccess: () => {
|
||||
notify({ type: 'green', title: 'REWARD CLAIMED', desc: `You received your level ${level} bonus!`, icon: 'gift' });
|
||||
},
|
||||
onError: (err) => {
|
||||
notify({ type: 'magenta', title: 'ERROR', desc: err.message || 'Failed to claim.', icon: 'alert-triangle' });
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
claiming.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to check if reward is claimable
|
||||
const isClaimable = (levelIdx: number) => {
|
||||
const lvl = levels[levelIdx];
|
||||
if (levelIdx === 0) return false; // No cash for newbie
|
||||
if (levelIdx > currentLevelIdx.value) return false; // Not reached yet
|
||||
if (props.claimedLevels.includes(lvl.level)) return false; // Already claimed
|
||||
return true;
|
||||
};
|
||||
|
||||
const isClaimed = (levelIdx: number) => {
|
||||
return props.claimedLevels.includes(levels[levelIdx].level);
|
||||
};
|
||||
|
||||
// Calculate styles for 3D effect
|
||||
const getCardStyle = (index: number) => {
|
||||
const offset = index - activeIndex.value;
|
||||
const absOffset = Math.abs(offset);
|
||||
const isActive = offset === 0;
|
||||
|
||||
// Config
|
||||
const xDist = 60; // % overlap
|
||||
const scale = 1 - (absOffset * 0.15);
|
||||
const opacity = 1 - (absOffset * 0.3);
|
||||
const zIndex = 100 - absOffset;
|
||||
const rotateY = offset * -25; // Rotation angle
|
||||
|
||||
return {
|
||||
transform: `translateX(${offset * xDist}%) scale(${scale}) perspective(1000px) rotateY(${rotateY}deg)`,
|
||||
opacity: Math.max(opacity, 0),
|
||||
zIndex: zIndex,
|
||||
pointerEvents: isActive ? 'auto' : 'none', // Only active card is interactive
|
||||
filter: isActive ? 'none' : 'brightness(0.5) blur(2px)'
|
||||
};
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => { if ((window as any).lucide) (window as any).lucide.createIcons(); });
|
||||
});
|
||||
|
||||
// Re-init icons when slide changes (for the details section)
|
||||
watch(activeIndex, () => {
|
||||
nextTick(() => { if ((window as any).lucide) (window as any).lucide.createIcons(); });
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UserLayout>
|
||||
<Head title="VIP Club" />
|
||||
<div class="vip-container">
|
||||
|
||||
<!-- Header / Progress -->
|
||||
<div class="vip-header">
|
||||
<div class="header-content">
|
||||
<div class="user-rank">
|
||||
<span class="label">{{ $t('vip.your_rank') }}</span>
|
||||
<h1 class="rank-title" :style="{ color: levels[currentLevelIdx].color }">
|
||||
{{ levels[currentLevelIdx].name }}
|
||||
</h1>
|
||||
</div>
|
||||
<div class="xp-bar-container">
|
||||
<div class="xp-info">
|
||||
<span>{{ currentXP.toLocaleString() }} XP</span>
|
||||
<span v-if="nextLevel">{{ nextLevel.xp.toLocaleString() }} XP</span>
|
||||
<span v-else>{{ $t('vip.max') }}</span>
|
||||
</div>
|
||||
<div class="xp-track">
|
||||
<div class="xp-fill" :style="{ width: `${progressPercent}%`, background: levels[currentLevelIdx].color, boxShadow: `0 0 15px ${levels[currentLevelIdx].color}` }"></div>
|
||||
</div>
|
||||
<div class="xp-next" v-if="nextLevel">
|
||||
{{ (nextLevel.xp - currentXP).toLocaleString() }} XP to {{ nextLevel.name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3D Slider -->
|
||||
<div class="slider-section">
|
||||
<button class="nav-btn prev" @click="prevSlide" :disabled="activeIndex === 0">
|
||||
<ChevronLeft class="w-8 h-8" />
|
||||
</button>
|
||||
|
||||
<div class="cards-wrapper">
|
||||
<div
|
||||
v-for="(lvl, i) in levels"
|
||||
:key="lvl.level"
|
||||
class="vip-card"
|
||||
:class="{ 'is-active': i === activeIndex }"
|
||||
:style="getCardStyle(i)"
|
||||
@click="setSlide(i)"
|
||||
>
|
||||
<div class="card-inner" :style="{ background: lvl.gradient }">
|
||||
<div class="card-glow" :style="{ background: lvl.color }"></div>
|
||||
|
||||
<div class="card-top">
|
||||
<div class="level-badge">LVL {{ lvl.level }}</div>
|
||||
<i :data-lucide="lvl.icon" class="level-icon"></i>
|
||||
</div>
|
||||
|
||||
<div class="card-mid">
|
||||
<div class="card-name">{{ lvl.name }}</div>
|
||||
<div class="card-xp">{{ lvl.xp.toLocaleString() }} XP</div>
|
||||
</div>
|
||||
|
||||
<div class="card-status">
|
||||
<div v-if="i < currentLevelIdx" class="status passed">
|
||||
<Check class="w-4 h-4" /> {{ $t('vip.unlocked') }}
|
||||
</div>
|
||||
<div v-else-if="i === currentLevelIdx" class="status current">
|
||||
<div class="pulse"></div> {{ $t('vip.current') }}
|
||||
</div>
|
||||
<div v-else class="status locked">
|
||||
<Lock class="w-4 h-4" /> {{ $t('vip.locked') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="nav-btn next" @click="nextSlide" :disabled="activeIndex === levels.length - 1">
|
||||
<ChevronRight class="w-8 h-8" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Details Panel (Dynamic based on active slide) -->
|
||||
<transition name="fade-up" mode="out-in">
|
||||
<div :key="activeIndex" class="details-panel">
|
||||
<div class="details-head" :style="{ borderColor: levels[activeIndex].color }">
|
||||
<div class="head-left">
|
||||
<h2 :style="{ color: levels[activeIndex].color }">
|
||||
<i :data-lucide="levels[activeIndex].icon"></i>
|
||||
{{ levels[activeIndex].name }} {{ $t('vip.benefits') }}
|
||||
</h2>
|
||||
<div class="details-xp">{{ $t('vip.requires') }} {{ levels[activeIndex].xp.toLocaleString() }} XP</div>
|
||||
</div>
|
||||
|
||||
<!-- Claim Button -->
|
||||
<div class="head-right">
|
||||
<button
|
||||
v-if="isClaimable(activeIndex)"
|
||||
class="btn-claim"
|
||||
:style="{ background: levels[activeIndex].color, boxShadow: `0 0 20px ${levels[activeIndex].color}60` }"
|
||||
@click="claimReward(levels[activeIndex].level)"
|
||||
:disabled="claiming"
|
||||
>
|
||||
<Gift class="w-4 h-4 mr-2" />
|
||||
Claim {{ props.cashRewards[levels[activeIndex].level] }}€
|
||||
</button>
|
||||
<div v-else-if="isClaimed(activeIndex)" class="claimed-badge">
|
||||
<Check class="w-4 h-4 mr-1" /> {{ $t('vip.reward_claimed') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rewards-grid">
|
||||
<div v-for="(reward, r) in levels[activeIndex].rewards" :key="r" class="reward-item">
|
||||
<div class="reward-icon" :style="{ background: `${levels[activeIndex].color}20`, color: levels[activeIndex].color }">
|
||||
<Star class="w-5 h-5" />
|
||||
</div>
|
||||
<span class="reward-text">{{ reward }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
</div>
|
||||
</UserLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.vip-container {
|
||||
min-height: 100vh;
|
||||
padding: 40px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
overflow-x: hidden;
|
||||
background: radial-gradient(circle at top, #1a1a1a 0%, #020202 100%);
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.vip-header {
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
margin-bottom: 60px;
|
||||
text-align: center;
|
||||
}
|
||||
.label { font-size: 12px; font-weight: 900; color: #666; letter-spacing: 3px; }
|
||||
.rank-title { font-size: 48px; font-weight: 900; text-transform: uppercase; margin: 5px 0 20px; text-shadow: 0 0 30px currentColor; }
|
||||
|
||||
.xp-bar-container { background: #0a0a0a; border: 1px solid #222; padding: 20px; border-radius: 20px; position: relative; }
|
||||
.xp-info { display: flex; justify-content: space-between; font-size: 12px; font-weight: 700; color: #ccc; margin-bottom: 8px; }
|
||||
.xp-track { height: 8px; background: #1a1a1a; border-radius: 10px; overflow: hidden; }
|
||||
.xp-fill { height: 100%; transition: width 1s cubic-bezier(0.2, 0, 0, 1); }
|
||||
.xp-next { margin-top: 8px; font-size: 11px; color: #666; text-align: right; }
|
||||
|
||||
/* Slider Section */
|
||||
.slider-section {
|
||||
width: 100%;
|
||||
max-width: 1000px;
|
||||
height: 450px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.cards-wrapper {
|
||||
position: relative;
|
||||
width: 320px; /* Card Width */
|
||||
height: 420px; /* Card Height */
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
perspective: 1000px;
|
||||
}
|
||||
|
||||
.vip-card {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transition: all 0.5s cubic-bezier(0.2, 0, 0, 1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card-inner {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 24px;
|
||||
padding: 30px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
box-shadow: 0 20px 50px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.card-glow {
|
||||
position: absolute;
|
||||
top: -50%; left: -50%; width: 200%; height: 200%;
|
||||
opacity: 0.15;
|
||||
filter: blur(60px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.card-top { display: flex; justify-content: space-between; align-items: flex-start; position: relative; z-index: 2; }
|
||||
.level-badge { font-size: 12px; font-weight: 900; background: rgba(0,0,0,0.3); padding: 5px 12px; border-radius: 50px; border: 1px solid rgba(255,255,255,0.2); color: #fff; }
|
||||
.level-icon { width: 40px; height: 40px; color: #fff; filter: drop-shadow(0 0 10px rgba(255,255,255,0.5)); }
|
||||
|
||||
.card-mid { text-align: center; position: relative; z-index: 2; }
|
||||
.card-name { font-size: 36px; font-weight: 900; color: #fff; text-transform: uppercase; letter-spacing: 2px; text-shadow: 0 5px 15px rgba(0,0,0,0.3); }
|
||||
.card-xp { font-size: 14px; font-weight: 700; color: rgba(255,255,255,0.8); margin-top: 5px; }
|
||||
|
||||
.card-status { display: flex; justify-content: center; position: relative; z-index: 2; }
|
||||
.status { display: flex; align-items: center; gap: 8px; font-size: 12px; font-weight: 800; padding: 8px 16px; border-radius: 12px; text-transform: uppercase; }
|
||||
.status.passed { background: rgba(0,255,157,0.2); color: #00ff9d; border: 1px solid rgba(0,255,157,0.3); }
|
||||
.status.locked { background: rgba(0,0,0,0.3); color: #888; border: 1px solid rgba(255,255,255,0.1); }
|
||||
.status.current { background: rgba(255,255,255,0.2); color: #fff; border: 1px solid #fff; }
|
||||
|
||||
.pulse { width: 8px; height: 8px; background: #fff; border-radius: 50%; animation: pulse 1.5s infinite; }
|
||||
|
||||
/* Navigation Buttons */
|
||||
.nav-btn {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: rgba(255,255,255,0.05);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
color: #fff;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: 0.2s;
|
||||
z-index: 20;
|
||||
}
|
||||
.nav-btn:hover:not(:disabled) { background: #fff; color: #000; box-shadow: 0 0 20px rgba(255,255,255,0.3); }
|
||||
.nav-btn:disabled { opacity: 0.3; cursor: not-allowed; }
|
||||
.nav-btn.prev { left: 0; }
|
||||
.nav-btn.next { right: 0; }
|
||||
|
||||
/* Details Panel */
|
||||
.details-panel {
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
background: #0a0a0a;
|
||||
border: 1px solid #222;
|
||||
border-radius: 24px;
|
||||
padding: 30px;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
|
||||
}
|
||||
.details-head { display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #222; padding-bottom: 20px; margin-bottom: 20px; border-left: 4px solid transparent; padding-left: 20px; transition: 0.3s; }
|
||||
.details-head h2 { font-size: 24px; font-weight: 900; margin: 0; display: flex; align-items: center; gap: 12px; text-transform: uppercase; }
|
||||
.details-xp { font-size: 12px; font-weight: 700; color: #666; text-transform: uppercase; letter-spacing: 1px; }
|
||||
|
||||
.btn-claim {
|
||||
display: flex; align-items: center;
|
||||
padding: 10px 20px;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
color: #000;
|
||||
font-weight: 900;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
transition: 0.3s;
|
||||
font-size: 12px;
|
||||
}
|
||||
.btn-claim:hover:not(:disabled) { transform: translateY(-2px); filter: brightness(1.1); }
|
||||
.btn-claim:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
|
||||
.claimed-badge {
|
||||
display: flex; align-items: center;
|
||||
color: #00ff9d;
|
||||
font-weight: 800;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
background: rgba(0,255,157,0.1);
|
||||
padding: 8px 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0,255,157,0.2);
|
||||
}
|
||||
|
||||
.rewards-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 15px; }
|
||||
.reward-item { background: #111; padding: 15px; border-radius: 12px; display: flex; align-items: center; gap: 15px; border: 1px solid #1a1a1a; transition: 0.2s; }
|
||||
.reward-item:hover { border-color: #333; transform: translateY(-2px); }
|
||||
.reward-icon { width: 36px; height: 36px; border-radius: 10px; display: flex; align-items: center; justify-content: center; }
|
||||
.reward-text { font-size: 13px; font-weight: 600; color: #ddd; }
|
||||
|
||||
/* Animations */
|
||||
@keyframes pulse { 0% { opacity: 1; transform: scale(1); } 50% { opacity: 0.5; transform: scale(1.2); } 100% { opacity: 1; transform: scale(1); } }
|
||||
|
||||
.fade-up-enter-active, .fade-up-leave-active { transition: all 0.3s ease; }
|
||||
.fade-up-enter-from { opacity: 0; transform: translateY(20px); }
|
||||
.fade-up-leave-to { opacity: 0; transform: translateY(-20px); }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.slider-section { height: 400px; }
|
||||
.cards-wrapper { width: 260px; height: 360px; }
|
||||
.nav-btn { width: 40px; height: 40px; }
|
||||
.rank-title { font-size: 32px; }
|
||||
.details-head { flex-direction: column; align-items: flex-start; gap: 10px; }
|
||||
.head-right { width: 100%; display: flex; justify-content: flex-start; }
|
||||
}
|
||||
</style>
|
||||
2158
resources/js/pages/Wallet.vue
Normal file
2158
resources/js/pages/Wallet.vue
Normal file
File diff suppressed because it is too large
Load Diff
133
resources/js/pages/Welcome.vue
Normal file
133
resources/js/pages/Welcome.vue
Normal file
@@ -0,0 +1,133 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, Link } from '@inertiajs/vue3';
|
||||
import Button from '@/components/ui/button.vue';
|
||||
import SupportChat from '@/components/support/SupportChat.vue';
|
||||
|
||||
defineProps<{
|
||||
canLogin?: boolean;
|
||||
canRegister?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head title="Welcome" />
|
||||
|
||||
<div class="welcome-container">
|
||||
<!-- Background -->
|
||||
<div class="bg-grid"></div>
|
||||
<div class="glow-orb orb-1"></div>
|
||||
<div class="glow-orb orb-2"></div>
|
||||
|
||||
<div class="content">
|
||||
<h1 class="title">BETI<span class="highlight">X</span></h1>
|
||||
<p class="subtitle">THE ULTIMATE CRYPTO PROTOCOL</p>
|
||||
<p> pimmel</p>
|
||||
<div class="actions">
|
||||
<Link href="/login" v-if="canLogin">
|
||||
<Button class="w-40 h-12 text-base font-bold neon-button">LOG IN</Button>
|
||||
</Link>
|
||||
<Link href="/register" v-if="canRegister">
|
||||
<Button variant="secondary" class="w-40 h-12 text-base font-bold">SIGN UP</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SupportChat />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.welcome-container {
|
||||
min-height: 100vh;
|
||||
background: #020202;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.content {
|
||||
text-align: center;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 6rem;
|
||||
font-weight: 900;
|
||||
letter-spacing: 10px;
|
||||
margin: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
color: #ff007a;
|
||||
text-shadow: 0 0 40px rgba(255, 0, 122, 0.6);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1.2rem;
|
||||
color: #888;
|
||||
letter-spacing: 4px;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 50px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Background Effects */
|
||||
.bg-grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: linear-gradient(rgba(255, 255, 255, 0.03) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.03) 1px, transparent 1px);
|
||||
background-size: 50px 50px;
|
||||
mask-image: radial-gradient(circle at center, black 40%, transparent 80%);
|
||||
}
|
||||
|
||||
.glow-orb {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(100px);
|
||||
opacity: 0.4;
|
||||
animation: float 10s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.orb-1 {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
background: #ff007a;
|
||||
top: -100px;
|
||||
left: -100px;
|
||||
}
|
||||
|
||||
.orb-2 {
|
||||
width: 500px;
|
||||
height: 500px;
|
||||
background: #00f2ff;
|
||||
bottom: -100px;
|
||||
right: -100px;
|
||||
animation-delay: -5s;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translate(0, 0); }
|
||||
50% { transform: translate(30px, 50px); }
|
||||
}
|
||||
|
||||
/* Neon Button */
|
||||
.neon-button {
|
||||
background: linear-gradient(90deg, #ff007a, #be005b);
|
||||
border: none;
|
||||
box-shadow: 0 0 20px rgba(255, 0, 122, 0.4);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.neon-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 0 40px rgba(255, 0, 122, 0.7);
|
||||
}
|
||||
</style>
|
||||
108
resources/js/pages/auth/ConfirmPassword.vue
Normal file
108
resources/js/pages/auth/ConfirmPassword.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<script setup lang="ts">
|
||||
import { Form, Head } from '@inertiajs/vue3';
|
||||
import { onMounted, nextTick } from 'vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import AuthLayout from '@/layouts/AuthLayout.vue';
|
||||
import { store } from '@/routes/password/confirm';
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => { if ((window as any).lucide) (window as any).lucide.createIcons(); });
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthLayout>
|
||||
<Head title="Passwort bestätigen" />
|
||||
|
||||
<div class="wrap">
|
||||
<div class="main-panel">
|
||||
<header class="page-head">
|
||||
<div class="head-flex">
|
||||
<div class="title-group">
|
||||
<div class="title">Bestätigung erforderlich</div>
|
||||
<p class="subtitle">Bitte bestätige dein Passwort, um fortzufahren</p>
|
||||
</div>
|
||||
<div class="security-badge">
|
||||
<i data-lucide="lock"></i>
|
||||
<span>Secure</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Form v-bind="store.form()" reset-on-success v-slot="{ errors, processing }">
|
||||
<div class="form-body">
|
||||
<label class="field" for="password">
|
||||
<span class="lbl">Passwort</span>
|
||||
<div class="input-wrapper">
|
||||
<i data-lucide="key-round"></i>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
class="inp"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
autofocus
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
<InputError :message="errors.password" />
|
||||
</label>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn" type="submit" :disabled="processing" data-test="confirm-password-button">
|
||||
<span v-if="processing" class="spinner" />
|
||||
<span>{{ processing ? 'Wird bestätigt…' : 'Passwort bestätigen' }}</span>
|
||||
<i v-if="!processing" data-lucide="arrow-right" style="width:14px"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="muted hint">
|
||||
<i data-lucide="shield-check"></i>
|
||||
<span>Sicherer Bereich. Deine Eingabe ist verschlüsselt.</span>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:global(:root) { --bg-card:#0a0a0a; --border:#151515; --cyan:#00f2ff; --magenta:#ff007a; --green:#00ff9d; }
|
||||
.wrap { width: 100%; }
|
||||
.main-panel { background: var(--bg-card); border: 1px solid var(--border); border-radius: 20px; overflow: hidden; box-shadow: 0 20px 50px rgba(0,0,0,0.6); animation: fade-in 0.6s cubic-bezier(0.2,0,0,1); }
|
||||
|
||||
.page-head { padding: 22px 26px; border-bottom: 1px solid var(--border); background: linear-gradient(to right, rgba(0,242,255,0.03), transparent); }
|
||||
.head-flex { display: flex; justify-content: space-between; align-items: center; gap: 10px; }
|
||||
.title { font-size: 13px; font-weight: 900; color: #fff; letter-spacing: 2px; text-transform: uppercase; }
|
||||
.subtitle { color: #555; font-size: 11px; margin-top: 4px; font-weight: 600; }
|
||||
.security-badge { display:flex; align-items:center; gap:6px; color: var(--green); background: rgba(0,255,157,0.05); padding:5px 12px; border-radius:50px; border:1px solid rgba(0,255,157,0.1); font-size:9px; font-weight:900; text-transform:uppercase; letter-spacing:1px; }
|
||||
.security-badge i { width: 12px; height: 12px; }
|
||||
|
||||
.form-body { padding: 24px 26px; display: grid; gap: 20px; }
|
||||
.field { display: grid; gap: 8px; }
|
||||
.lbl { display:block; font-size:10px; font-weight:900; color:#555; text-transform:uppercase; letter-spacing:1px; }
|
||||
.input-wrapper { position: relative; display:flex; align-items:center; }
|
||||
.input-wrapper i { position: absolute; left: 14px; width: 16px; color: #444; pointer-events: none; transition: .3s; }
|
||||
.inp { width: 100%; background:#000; border:1px solid var(--border); color:#fff; padding:14px 14px 14px 44px; border-radius:12px; font-size:13px; transition:.3s cubic-bezier(0.2,0,0,1); }
|
||||
.inp:focus { border-color: var(--cyan); outline: none; box-shadow: 0 0 15px rgba(0,242,255,0.1); background:#050505; }
|
||||
.inp:focus + i { color: var(--cyan); }
|
||||
|
||||
.actions { display:flex; gap:10px; margin-top: 5px; }
|
||||
.btn { width: 100%; background: var(--cyan); color:#000; border:none; border-radius:12px; padding:14px 16px; font-size:12px; font-weight:900; text-transform:uppercase; letter-spacing:1px; cursor:pointer; transition:.3s; box-shadow: 0 0 20px rgba(0,242,255,0.2); display:flex; align-items:center; justify-content:center; gap:10px; }
|
||||
.btn:hover:not(:disabled) { transform: translateY(-1px); box-shadow: 0 6px 22px rgba(0,242,255,0.35); }
|
||||
.btn:disabled { opacity: .6; cursor: not-allowed; }
|
||||
.spinner { width: 16px; height: 16px; border: 2px solid rgba(0,0,0,0.1); border-top-color:#000; border-radius: 50%; animation: spin .8s linear infinite; }
|
||||
|
||||
.muted { color:#9aa0a6; font-size:11px; display:flex; align-items:center; gap:8px; justify-content: center; opacity: 0.7; }
|
||||
.hint i { width: 14px; color:#444; }
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@keyframes fade-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.page-head { padding: 18px 20px; }
|
||||
.form-body { padding: 18px 20px; }
|
||||
}
|
||||
</style>
|
||||
198
resources/js/pages/auth/ForgotPassword.vue
Normal file
198
resources/js/pages/auth/ForgotPassword.vue
Normal file
@@ -0,0 +1,198 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, useForm } from '@inertiajs/vue3';
|
||||
import { ChevronLeft } from 'lucide-vue-next';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import TextLink from '@/components/TextLink.vue';
|
||||
import Button from '@/components/ui/button.vue';
|
||||
import Input from '@/components/ui/input.vue';
|
||||
import Label from '@/components/ui/label.vue';
|
||||
import Spinner from '@/components/ui/spinner.vue';
|
||||
import UserLayout from '@/layouts/user/userlayout.vue';
|
||||
|
||||
defineProps<{
|
||||
status?: string;
|
||||
}>();
|
||||
|
||||
const form = useForm({
|
||||
email: '',
|
||||
});
|
||||
|
||||
const submit = () => {
|
||||
form.post('/forgot-password');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UserLayout>
|
||||
<Head title="Forgot Password" />
|
||||
|
||||
<div class="login-container">
|
||||
<!-- Animated Background -->
|
||||
<div class="glow-orb orb-1"></div>
|
||||
<div class="glow-orb orb-2"></div>
|
||||
|
||||
<div class="login-card">
|
||||
<div class="card-header">
|
||||
<h1 class="title">RESET <span class="highlight">PASSWORD</span></h1>
|
||||
<p class="subtitle">Enter your email to recover access</p>
|
||||
</div>
|
||||
|
||||
<div v-if="status" class="mb-4 text-center text-sm font-medium text-green-500 bg-green-500/10 p-2 rounded border border-green-500/20">
|
||||
{{ status }}
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="submit" class="form-content">
|
||||
|
||||
<div class="input-group">
|
||||
<Label for="email">Email Address</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
v-model="form.email"
|
||||
required
|
||||
autofocus
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
<InputError :message="form.errors.email" />
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<Button
|
||||
type="submit"
|
||||
class="w-full h-12 text-base font-bold tracking-widest uppercase neon-button relative overflow-hidden"
|
||||
:disabled="form.processing"
|
||||
>
|
||||
<div v-if="form.processing" class="absolute inset-0 bg-black/20 flex items-center justify-center">
|
||||
<Spinner class="w-5 h-5" />
|
||||
</div>
|
||||
<span :class="{ 'opacity-0': form.processing }">Send Reset Link</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-6">
|
||||
<TextLink href="/login" class="text-[#888] hover:text-white transition-colors flex items-center justify-center gap-2">
|
||||
<ChevronLeft class="w-4 h-4" /> Back to Login
|
||||
</TextLink>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</UserLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Container & Layout */
|
||||
.login-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: calc(100vh - 100px);
|
||||
padding: 20px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 450px;
|
||||
background: rgba(10, 10, 10, 0.8);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 24px;
|
||||
padding: 40px;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5);
|
||||
animation: slideUp 0.8s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.card-header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.title {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 900;
|
||||
color: white;
|
||||
letter-spacing: 2px;
|
||||
margin: 0;
|
||||
}
|
||||
.highlight {
|
||||
color: #ff007a;
|
||||
text-shadow: 0 0 20px rgba(255, 0, 122, 0.5);
|
||||
}
|
||||
.subtitle {
|
||||
color: #888;
|
||||
font-size: 0.9rem;
|
||||
margin-top: 5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
/* Form Content */
|
||||
.form-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
.input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Neon Button */
|
||||
.neon-button {
|
||||
background: linear-gradient(90deg, #ff007a, #be005b);
|
||||
border: none;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.neon-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 0 30px rgba(255, 0, 122, 0.6);
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
.neon-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
filter: grayscale(1);
|
||||
}
|
||||
|
||||
/* Background Orbs */
|
||||
.glow-orb {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(80px);
|
||||
opacity: 0.4;
|
||||
z-index: 0;
|
||||
animation: float 10s infinite ease-in-out;
|
||||
}
|
||||
.orb-1 {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
background: #ff007a;
|
||||
top: -50px;
|
||||
left: -100px;
|
||||
}
|
||||
.orb-2 {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
background: #00f2ff;
|
||||
bottom: -100px;
|
||||
right: -100px;
|
||||
animation-delay: -5s;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes slideUp {
|
||||
from { opacity: 0; transform: translateY(40px) scale(0.95); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translate(0, 0); }
|
||||
50% { transform: translate(20px, 30px); }
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user