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

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

View File

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