416 lines
18 KiB
Vue
416 lines
18 KiB
Vue
<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>
|