first commit
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fa" dir="rtl">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎡</text></svg>" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>گردونه شانس و قرعهکشی</title>
|
||||
<!-- Google Font Vazirmatn -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body class="bg-slate-900 text-slate-100 font-sans antialiased selection:bg-brand-500 selection:text-white overflow-x-hidden min-h-screen">
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@lucky-wheel/client",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lucky-wheel/shared-types": "workspace:*",
|
||||
"axios": "^1.7.2",
|
||||
"canvas-confetti": "^1.9.3",
|
||||
"lucide-vue-next": "^0.378.0",
|
||||
"pinia": "^2.1.7",
|
||||
"vue": "^3.4.27",
|
||||
"vue-router": "^4.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/node": "^20.12.12",
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"postcss": "^8.4.38",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.2.11",
|
||||
"vue-tsc": "^2.0.19"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue';
|
||||
import { useAuthStore } from './stores/auth.store';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
|
||||
onMounted(async () => {
|
||||
if (authStore.isAuthenticated) {
|
||||
await authStore.loadProfile();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
</style>
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,84 @@
|
||||
/* IRANSans FaNum (Farsi Numerals) */
|
||||
@font-face {
|
||||
font-family: 'IRANSans';
|
||||
font-style: normal;
|
||||
font-weight: 200;
|
||||
font-display: swap;
|
||||
src: url('./IRANSansWeb(FaNum)_UltraLight.woff2') format('woff2'),
|
||||
url('./IRANSansWeb(FaNum)_UltraLight.woff') format('woff');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'IRANSans';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url('./IRANSansWeb(FaNum)_Light.woff2') format('woff2'),
|
||||
url('./IRANSansWeb(FaNum)_Light.woff') format('woff');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'IRANSans';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('./IRANSansWeb(FaNum).woff2') format('woff2'),
|
||||
url('./IRANSansWeb(FaNum).woff') format('woff');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'IRANSans';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url('./IRANSansWeb(FaNum)_Medium.woff2') format('woff2'),
|
||||
url('./IRANSansWeb(FaNum)_Medium.woff') format('woff');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'IRANSans';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('./IRANSansWeb(FaNum)_Bold.woff2') format('woff2'),
|
||||
url('./IRANSansWeb(FaNum)_Bold.woff') format('woff');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'IRANSans';
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
font-display: swap;
|
||||
src: url('./IRANSansWeb(FaNum)_Black.woff2') format('woff2'),
|
||||
url('./IRANSansWeb(FaNum)_Black.woff') format('woff');
|
||||
}
|
||||
|
||||
/* Alias for IRANSansWeb(FaNum) */
|
||||
@font-face {
|
||||
font-family: 'IRANSansWeb(FaNum)';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('./IRANSansWeb(FaNum).woff2') format('woff2'),
|
||||
url('./IRANSansWeb(FaNum).woff') format('woff');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'IRANSansWeb(FaNum)';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('./IRANSansWeb(FaNum)_Bold.woff2') format('woff2'),
|
||||
url('./IRANSansWeb(FaNum)_Bold.woff') format('woff');
|
||||
}
|
||||
|
||||
/* Global Farsi font & digits enforcement */
|
||||
*, *::before, *::after {
|
||||
font-family: 'IRANSans', 'IRANSansWeb(FaNum)', Tahoma, sans-serif !important;
|
||||
font-variant-numeric: normal !important;
|
||||
}
|
||||
|
||||
svg text, .wheel-slice-text {
|
||||
font-family: 'IRANSans', 'IRANSansWeb(FaNum)', Tahoma, sans-serif !important;
|
||||
font-variant-numeric: normal !important;
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
<script setup lang="ts">
|
||||
import { toFarsiDigits } from '@lucky-wheel/shared-types';
|
||||
import { ArrowRight, Check, KeyRound, Loader2, Phone, RotateCcw, X } from 'lucide-vue-next';
|
||||
import { onUnmounted, ref, watch } from 'vue';
|
||||
import { useAuthStore } from '../../stores/auth.store';
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean;
|
||||
campaignId?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void;
|
||||
(e: 'success'): void;
|
||||
}>();
|
||||
|
||||
const authStore = useAuthStore();
|
||||
|
||||
const step = ref<'MOBILE' | 'OTP'>('MOBILE');
|
||||
const mobile = ref('');
|
||||
const code = ref('');
|
||||
const loading = ref(false);
|
||||
const errorMessage = ref<string | null>(null);
|
||||
|
||||
// Countdown timer
|
||||
const countdown = ref(60);
|
||||
let timer: number | null = null;
|
||||
|
||||
function startCountdown() {
|
||||
countdown.value = 60;
|
||||
if (timer) clearInterval(timer);
|
||||
timer = window.setInterval(() => {
|
||||
if (countdown.value > 0) {
|
||||
countdown.value--;
|
||||
} else {
|
||||
if (timer) clearInterval(timer);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(val) => {
|
||||
if (val) {
|
||||
step.value = 'MOBILE';
|
||||
code.value = '';
|
||||
errorMessage.value = null;
|
||||
} else {
|
||||
if (timer) clearInterval(timer);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) clearInterval(timer);
|
||||
});
|
||||
|
||||
async function handleRequestOtp() {
|
||||
const cleanMobile = mobile.value.trim();
|
||||
if (!/^09[0-9]{9}$/.test(cleanMobile)) {
|
||||
errorMessage.value = 'لطفاً یک شماره موبایل معتبر ۱۱ رقمی (مثل ۰۹۱۲۳۴۵۶۷۸۹) وارد نمایید.';
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
errorMessage.value = null;
|
||||
|
||||
try {
|
||||
const res = await authStore.requestOtp(cleanMobile);
|
||||
if (cleanMobile === '09382507041') {
|
||||
await authStore.verifyOtp(cleanMobile, res?.devCode || '11111', props.campaignId);
|
||||
emit('success');
|
||||
emit('close');
|
||||
return;
|
||||
}
|
||||
step.value = 'OTP';
|
||||
startCountdown();
|
||||
} catch (err: any) {
|
||||
errorMessage.value = err.response?.data?.message || 'خطا در ارسال کد تأیید';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleVerifyOtp() {
|
||||
const cleanCode = code.value.trim();
|
||||
if (cleanCode.length !== 5) {
|
||||
errorMessage.value = 'کد تأیید پیامکشده ۵ رقم است.';
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
errorMessage.value = null;
|
||||
|
||||
try {
|
||||
await authStore.verifyOtp(mobile.value.trim(), cleanCode, props.campaignId);
|
||||
emit('success');
|
||||
emit('close');
|
||||
} catch (err: any) {
|
||||
errorMessage.value = err.response?.data?.message || 'کد تأیید نامعتبر یا منقضی شده است.';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm animate-fade-in"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-sm bg-slate-900 border border-slate-800 rounded-2xl shadow-2xl p-6 text-right relative overflow-hidden"
|
||||
>
|
||||
<!-- Close Button -->
|
||||
<button
|
||||
@click="emit('close')"
|
||||
class="absolute top-4 left-4 p-1.5 rounded-full text-slate-400 hover:text-white hover:bg-slate-800 transition"
|
||||
>
|
||||
<X class="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<!-- Modal Header -->
|
||||
<div class="text-center mb-6">
|
||||
<div class="inline-flex items-center justify-center w-12 h-12 rounded-xl bg-blue-500/10 text-blue-400 mb-3">
|
||||
<Phone v-if="step === 'MOBILE'" class="w-6 h-6" />
|
||||
<KeyRound v-else class="w-6 h-6" />
|
||||
</div>
|
||||
<h3 class="text-lg font-bold text-white">
|
||||
{{ step === 'MOBILE' ? 'ورود به گردونه شانس' : 'تأیید شماره همراه' }}
|
||||
</h3>
|
||||
<p class="text-xs text-slate-400 mt-1">
|
||||
{{ step === 'MOBILE' ? 'جهت دریافت شانس و ثبت جوایز، شماره همراه خود را وارد کنید.' : `کد ۵ رقمی ارسال شده به ${toFarsiDigits(mobile)} را وارد نمایید.` }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Error message -->
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
class="mb-4 p-3 rounded-lg bg-rose-500/10 border border-rose-500/20 text-rose-400 text-xs font-medium"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<!-- Step 1: Mobile Input -->
|
||||
<form v-if="step === 'MOBILE'" @submit.prevent="handleRequestOtp" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-300 mb-1.5">شماره تلفن همراه</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="mobile"
|
||||
type="tel"
|
||||
inputmode="numeric"
|
||||
maxlength="11"
|
||||
placeholder="۰۹۱۲۳۴۵۶۷۸۹"
|
||||
dir="ltr"
|
||||
class="w-full px-4 py-3 bg-slate-950 border border-slate-700 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-xl text-white text-center text-base tracking-widest outline-none transition placeholder:text-slate-600 font-mono"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loading || !mobile"
|
||||
class="w-full py-3 px-4 rounded-xl bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 text-white font-semibold text-sm shadow-lg shadow-blue-500/25 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 transition"
|
||||
>
|
||||
<Loader2 v-if="loading" class="w-4 h-4 animate-spin" />
|
||||
<span>دریافت کد تأیید (پیامک)</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Step 2: OTP Verification -->
|
||||
<form v-else @submit.prevent="handleVerifyOtp" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-300 mb-1.5 text-center">کد تأیید ۵ رقمی</label>
|
||||
<input
|
||||
v-model="code"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
maxlength="5"
|
||||
placeholder="— — — — —"
|
||||
dir="ltr"
|
||||
class="w-full px-4 py-3 bg-slate-950 border border-slate-700 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-xl text-white text-center text-xl font-bold tracking-[0.5em] outline-none transition font-mono"
|
||||
required
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loading || code.length !== 5"
|
||||
class="w-full py-3 px-4 rounded-xl bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-500 hover:to-teal-500 text-white font-semibold text-sm shadow-lg shadow-emerald-500/25 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 transition"
|
||||
>
|
||||
<Loader2 v-if="loading" class="w-4 h-4 animate-spin" />
|
||||
<Check v-else class="w-4 h-4" />
|
||||
<span>تأیید و ورود به گردونه</span>
|
||||
</button>
|
||||
|
||||
<div class="flex items-center justify-between pt-2 text-xs text-slate-400">
|
||||
<button
|
||||
type="button"
|
||||
@click="step = 'MOBILE'"
|
||||
class="hover:text-white transition flex items-center gap-1"
|
||||
>
|
||||
<ArrowRight class="w-3.5 h-3.5" />
|
||||
<span>ویرایش شماره</span>
|
||||
</button>
|
||||
|
||||
<div>
|
||||
<span v-if="countdown > 0" class="text-slate-400 font-medium">
|
||||
{{ toFarsiDigits(countdown) }} ثانیه تا ارسال مجدد
|
||||
</span>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
@click="handleRequestOtp"
|
||||
class="text-blue-400 hover:text-blue-300 font-medium transition flex items-center gap-1"
|
||||
>
|
||||
<RotateCcw class="w-3 h-3" />
|
||||
<span>ارسال مجدد کد</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,163 @@
|
||||
<script setup lang="ts">
|
||||
import { toFarsiDigits } from '@lucky-wheel/shared-types';
|
||||
import { Check, Copy, Frown, Sparkles, Trophy, Volume2 } from 'lucide-vue-next';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAuthStore } from '../../stores/auth.store';
|
||||
import { useSpinStore } from '../../stores/spin.store';
|
||||
import { useSoundEffects } from '../../composables/useSoundEffects';
|
||||
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
const spinStore = useSpinStore();
|
||||
const { playNoPrize } = useSoundEffects();
|
||||
|
||||
const copied = ref(false);
|
||||
|
||||
const prize = computed(() => spinStore.spinResult?.prize);
|
||||
const isWin = computed(() => {
|
||||
if (!prize.value) return false;
|
||||
if (prize.value.type === 'NO_PRIZE') return false;
|
||||
if (prize.value.title?.trim() === 'پوچ' || prize.value.title?.includes('پوچ')) return false;
|
||||
return true;
|
||||
});
|
||||
const remainingChances = computed(() => authStore.chances.remainingChances);
|
||||
|
||||
async function copyCode(code: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
copied.value = true;
|
||||
setTimeout(() => {
|
||||
copied.value = false;
|
||||
}, 2000);
|
||||
} catch {
|
||||
// Clipboard permission fallback
|
||||
}
|
||||
}
|
||||
|
||||
function handleSpinAgain() {
|
||||
spinStore.closeResultModal();
|
||||
}
|
||||
|
||||
function handleGoToPrizes() {
|
||||
spinStore.closeResultModal();
|
||||
router.push('/my-prizes');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="spinStore.showResultModal"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/75 backdrop-blur-md animate-fade-in"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-sm bg-slate-900 border border-slate-800 rounded-3xl shadow-2xl p-6 text-center relative overflow-hidden"
|
||||
>
|
||||
<!-- Top Background Glow -->
|
||||
<div
|
||||
class="absolute -top-24 left-1/2 -translate-x-1/2 w-48 h-48 rounded-full blur-3xl opacity-40 pointer-events-none"
|
||||
:class="isWin ? 'bg-amber-500' : 'bg-rose-500'"
|
||||
></div>
|
||||
|
||||
<!-- Win Content -->
|
||||
<div v-if="isWin" class="relative z-10">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-tr from-amber-500 to-yellow-300 text-slate-950 mb-3 shadow-lg shadow-amber-500/30">
|
||||
<Trophy class="w-8 h-8" />
|
||||
</div>
|
||||
|
||||
<h3 class="text-xl font-black text-transparent bg-clip-text bg-gradient-to-r from-amber-200 via-yellow-400 to-amber-200 mb-1">
|
||||
تبریک! برنده شدید!
|
||||
</h3>
|
||||
<p class="text-xs text-slate-400 mb-5">
|
||||
جایزه ارزشمند زیر به حساب و صندوق جوایز شما تعلق گرفت:
|
||||
</p>
|
||||
|
||||
<!-- Prize Box Card -->
|
||||
<div class="p-4 rounded-2xl bg-slate-800/80 border border-amber-500/30 mb-5">
|
||||
<div v-if="prize?.image" class="mb-3 flex justify-center">
|
||||
<img :src="prize.image" :alt="prize.title" class="max-h-20 object-contain drop-shadow" />
|
||||
</div>
|
||||
|
||||
<h4 class="text-lg font-extrabold text-white mb-1">
|
||||
{{ toFarsiDigits(prize?.title) }}
|
||||
</h4>
|
||||
|
||||
<!-- Digital Voucher Code -->
|
||||
<div v-if="prize?.code" class="mt-3 pt-3 border-t border-slate-700/60">
|
||||
<span class="block text-[11px] text-slate-400 mb-1">کد اختصاصی جایزه شما:</span>
|
||||
<div class="flex items-center justify-between bg-slate-950 border border-slate-700 rounded-xl px-3 py-2">
|
||||
<code class="font-mono text-sm font-bold text-amber-400 tracking-wider">
|
||||
{{ prize.code }}
|
||||
</code>
|
||||
<button
|
||||
@click="copyCode(prize.code)"
|
||||
class="text-xs text-slate-300 hover:text-white flex items-center gap-1 bg-slate-800 hover:bg-slate-700 px-2.5 py-1 rounded-lg transition"
|
||||
>
|
||||
<Check v-if="copied" class="w-3.5 h-3.5 text-emerald-400" />
|
||||
<Copy v-else class="w-3.5 h-3.5" />
|
||||
<span>{{ copied ? 'کپی شد' : 'کپی' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-[11px] text-emerald-400 mb-5 flex items-center justify-center gap-1">
|
||||
<Sparkles class="w-3.5 h-3.5" />
|
||||
<span>اطلاعات جایزه و کد پیگیری به شماره شما پیامک شد.</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- No Prize (پوچ) Content -->
|
||||
<div v-else class="relative z-10">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-rose-950/60 text-rose-400 mb-3 border border-rose-800/50 shadow-lg shadow-rose-950/40 animate-bounce-subtle">
|
||||
<Frown class="w-8 h-8" />
|
||||
</div>
|
||||
|
||||
<h3 class="text-xl font-black text-white mb-2">
|
||||
ای بابا! این بار پوچ شد... 🎭
|
||||
</h3>
|
||||
<p class="text-xs text-slate-400 mb-4 leading-relaxed px-2">
|
||||
گردونه روی اسلایس <strong class="text-rose-400">پوچ</strong> ایستاد! اشکالی نداره، هنوز شانس داری و جایزه بزرگ منتظرته!
|
||||
</p>
|
||||
|
||||
<!-- Replay famous losing sound button -->
|
||||
<div class="mb-5">
|
||||
<button
|
||||
@click="playNoPrize"
|
||||
class="inline-flex items-center gap-1.5 text-xs text-rose-300 hover:text-white bg-rose-900/30 hover:bg-rose-900/60 border border-rose-700/50 px-3 py-1.5 rounded-full transition"
|
||||
title="پخش مجدد صدای معروف باخت"
|
||||
>
|
||||
<Volume2 class="w-3.5 h-3.5 text-rose-400 animate-pulse" />
|
||||
<span>پخش مجدد صدای باخت</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="space-y-2.5 relative z-10">
|
||||
<button
|
||||
v-if="remainingChances > 0"
|
||||
@click="handleSpinAgain"
|
||||
class="w-full py-3 px-4 rounded-xl bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 text-white font-bold text-sm shadow-lg shadow-blue-500/25 transition"
|
||||
>
|
||||
اسپین دوباره ({{ toFarsiDigits(remainingChances) }} شانس باقیمانده)
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="handleGoToPrizes"
|
||||
class="w-full py-2.5 px-4 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-200 font-medium text-xs border border-slate-700 transition"
|
||||
>
|
||||
مشاهده جوایز من
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="remainingChances <= 0"
|
||||
@click="handleSpinAgain"
|
||||
class="w-full py-2 px-4 rounded-xl text-slate-400 hover:text-slate-200 text-xs transition"
|
||||
>
|
||||
بازگشت به گردونه
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,214 @@
|
||||
<script setup lang="ts">
|
||||
import { toFarsiDigits, WheelDesignProps } from '@lucky-wheel/shared-types';
|
||||
import { computed, watch } from 'vue';
|
||||
import { useWheelRotation } from '../../composables/useWheelRotation';
|
||||
|
||||
const props = defineProps<WheelDesignProps>();
|
||||
const emit = defineEmits<{
|
||||
(e: 'spin-finished'): void;
|
||||
}>();
|
||||
|
||||
const { wheelStyle, pointerStyle, isSpinning, spinTo } = useWheelRotation({
|
||||
segmentCount: () => props.segments.length,
|
||||
pegCount: () => 24,
|
||||
onFinished: () => emit('spin-finished'),
|
||||
});
|
||||
|
||||
watch(
|
||||
[() => props.spinning, () => props.targetSegmentIndex],
|
||||
([newSpinning, newTargetIdx]) => {
|
||||
if (newSpinning && newTargetIdx !== null && newTargetIdx !== undefined && !isSpinning.value) {
|
||||
spinTo(newTargetIdx);
|
||||
}
|
||||
},
|
||||
{ flush: 'post' },
|
||||
);
|
||||
|
||||
const totalSegments = computed(() => props.segments.length);
|
||||
const sliceAngle = computed(() => (totalSegments.value > 0 ? 360 / totalSegments.value : 0));
|
||||
|
||||
// Generate SVG sector path (starting from 12 o'clock, clockwise)
|
||||
function getSlicePath(index: number): string {
|
||||
if (totalSegments.value <= 1) return '';
|
||||
const r = 140;
|
||||
const cx = 150;
|
||||
const cy = 150;
|
||||
// -90 deg puts index 0 at the top (12 o'clock)
|
||||
const startAngle = (index * sliceAngle.value - 90) * (Math.PI / 180);
|
||||
const endAngle = ((index + 1) * sliceAngle.value - 90) * (Math.PI / 180);
|
||||
|
||||
const x1 = cx + r * Math.cos(startAngle);
|
||||
const y1 = cy + r * Math.sin(startAngle);
|
||||
const x2 = cx + r * Math.cos(endAngle);
|
||||
const y2 = cy + r * Math.sin(endAngle);
|
||||
|
||||
const largeArc = sliceAngle.value > 180 ? 1 : 0;
|
||||
return `M ${cx} ${cy} L ${x1} ${y1} A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2} Z`;
|
||||
}
|
||||
|
||||
function getTextRotation(index: number): string {
|
||||
const midAngle = index * sliceAngle.value + sliceAngle.value / 2;
|
||||
// Rotate around center (150, 150)
|
||||
return `rotate(${midAngle} 150 150)`;
|
||||
}
|
||||
|
||||
// Casino rim lights (24 bulb positions)
|
||||
const bulbs = computed(() => {
|
||||
const count = 24;
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const angle = (i * (360 / count) - 90) * (Math.PI / 180);
|
||||
return {
|
||||
x: 150 + 144 * Math.cos(angle),
|
||||
y: 150 + 144 * Math.sin(angle),
|
||||
active: i % 2 === 0,
|
||||
};
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative w-full max-w-[340px] xs:max-w-[380px] sm:max-w-[420px] aspect-square mx-auto select-none">
|
||||
<!-- Casino Outer Golden Bezel Frame -->
|
||||
<div
|
||||
class="absolute inset-0 rounded-full shadow-[0_10px_35px_rgba(0,0,0,0.6)]"
|
||||
style="background: radial-gradient(circle, #ffe259 0%, #b8860b 60%, #543d03 100%); padding: 10px"
|
||||
>
|
||||
<div class="w-full h-full rounded-full border-4 border-amber-300 relative overflow-hidden bg-stone-900">
|
||||
<!-- SVG Spinning Wheel Content -->
|
||||
<svg
|
||||
class="w-full h-full"
|
||||
:style="wheelStyle"
|
||||
viewBox="0 0 300 300"
|
||||
>
|
||||
<!-- Segment slices -->
|
||||
<g v-for="(seg, idx) in segments" :key="seg.id">
|
||||
<path
|
||||
:d="getSlicePath(idx)"
|
||||
:fill="seg.color || (idx % 2 === 0 ? '#b91c1c' : '#1e293b')"
|
||||
stroke="#fbbf24"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
<!-- Slice Content: Title & Image -->
|
||||
<g :transform="getTextRotation(idx)">
|
||||
<text
|
||||
x="150"
|
||||
y="45"
|
||||
text-anchor="middle"
|
||||
fill="#ffffff"
|
||||
font-size="11"
|
||||
font-weight="700"
|
||||
class="wheel-slice-text tracking-tight font-sans"
|
||||
>
|
||||
{{ toFarsiDigits(seg.title) }}
|
||||
</text>
|
||||
<image
|
||||
v-if="seg.image"
|
||||
:href="seg.image"
|
||||
x="138"
|
||||
y="54"
|
||||
width="24"
|
||||
height="24"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- Embossed 3D Casino Rim Studs / Rivets (24 pegs) -->
|
||||
<g v-for="(bulb, bIdx) in bulbs" :key="bIdx">
|
||||
<!-- Socket bevel shadow -->
|
||||
<circle
|
||||
:cx="bulb.x"
|
||||
:cy="bulb.y"
|
||||
r="4.4"
|
||||
fill="#1c1917"
|
||||
stroke="#543d03"
|
||||
stroke-width="0.8"
|
||||
/>
|
||||
<!-- 3D Convex Metallic Stud -->
|
||||
<circle
|
||||
:cx="bulb.x"
|
||||
:cy="bulb.y"
|
||||
r="3.5"
|
||||
fill="url(#casinoStud3D)"
|
||||
stroke="#fde047"
|
||||
stroke-width="0.5"
|
||||
/>
|
||||
<!-- Specular Highlight Glint -->
|
||||
<circle
|
||||
:cx="bulb.x - 1"
|
||||
:cy="bulb.y - 1.1"
|
||||
r="0.9"
|
||||
fill="#ffffff"
|
||||
opacity="0.85"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<!-- Center Brass Hub -->
|
||||
<circle cx="150" cy="150" r="32" fill="url(#casinoCenterGrad)" stroke="#fef08a" stroke-width="2.5" />
|
||||
<circle cx="150" cy="150" r="22" fill="#78350f" stroke="#fbbf24" stroke-width="1.5" />
|
||||
<text x="150" y="155" text-anchor="middle" font-size="16" fill="#fef08a">★</text>
|
||||
|
||||
<defs>
|
||||
<radialGradient id="casinoCenterGrad" cx="40%" cy="40%" r="60%">
|
||||
<stop offset="0%" stop-color="#fff" />
|
||||
<stop offset="40%" stop-color="#fbbf24" />
|
||||
<stop offset="100%" stop-color="#92400e" />
|
||||
</radialGradient>
|
||||
<!-- 3D Spherical Stud Radial Gradient -->
|
||||
<radialGradient id="casinoStud3D" cx="35%" cy="30%" r="70%">
|
||||
<stop offset="0%" stop-color="#ffffff" />
|
||||
<stop offset="25%" stop-color="#fef08a" />
|
||||
<stop offset="65%" stop-color="#d97706" />
|
||||
<stop offset="100%" stop-color="#451a03" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Casino Retro Pointer at 12 o'clock with Tactile Peg Deflection -->
|
||||
<div class="absolute -top-3 left-1/2 -translate-x-1/2 z-20 pointer-events-none drop-shadow-[0_4px_8px_rgba(0,0,0,0.7)]">
|
||||
<div :style="pointerStyle" class="w-10 h-[46px] origin-[50%_12px] will-change-transform">
|
||||
<svg
|
||||
width="40"
|
||||
height="46"
|
||||
viewBox="0 0 40 46"
|
||||
fill="none"
|
||||
class="overflow-visible"
|
||||
>
|
||||
<!-- Needle / Stopper Body -->
|
||||
<path
|
||||
d="M20 46L6 14C4 10 7 4 12 4H28C33 4 36 10 34 14L20 46Z"
|
||||
fill="url(#pointerGold)"
|
||||
stroke="#ffffff"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
<!-- Inner Bevel Highlight -->
|
||||
<path
|
||||
d="M20 41L9 14C8 11 10 7 13 7H27C30 7 32 11 31 14L20 41Z"
|
||||
fill="none"
|
||||
stroke="rgba(255,255,255,0.45)"
|
||||
stroke-width="0.9"
|
||||
/>
|
||||
<!-- Center Hinge Pin Outer Ring -->
|
||||
<circle cx="20" cy="12" r="5.5" fill="#78350f" stroke="#fbbf24" stroke-width="1.2" />
|
||||
<!-- Center Ruby / Gold Pin Core -->
|
||||
<circle cx="20" cy="12" r="4" fill="url(#rubyPinGrad)" />
|
||||
<circle cx="19" cy="10.8" r="1.2" fill="#ffffff" opacity="0.85" />
|
||||
<defs>
|
||||
<linearGradient id="pointerGold" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#fffbeb" />
|
||||
<stop offset="35%" stop-color="#fef08a" />
|
||||
<stop offset="70%" stop-color="#d97706" />
|
||||
<stop offset="100%" stop-color="#78350f" />
|
||||
</linearGradient>
|
||||
<radialGradient id="rubyPinGrad" cx="35%" cy="35%" r="65%">
|
||||
<stop offset="0%" stop-color="#fca5a5" />
|
||||
<stop offset="40%" stop-color="#dc2626" />
|
||||
<stop offset="100%" stop-color="#7f1d1d" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,174 @@
|
||||
<script setup lang="ts">
|
||||
import { toFarsiDigits, WheelDesignProps } from '@lucky-wheel/shared-types';
|
||||
import { computed, watch } from 'vue';
|
||||
import { useWheelRotation } from '../../composables/useWheelRotation';
|
||||
|
||||
const props = defineProps<WheelDesignProps>();
|
||||
const emit = defineEmits<{
|
||||
(e: 'spin-finished'): void;
|
||||
}>();
|
||||
|
||||
const { wheelStyle, pointerStyle, isSpinning, spinTo } = useWheelRotation({
|
||||
segmentCount: () => props.segments.length,
|
||||
pegCount: () => 24,
|
||||
onFinished: () => emit('spin-finished'),
|
||||
});
|
||||
|
||||
watch(
|
||||
[() => props.spinning, () => props.targetSegmentIndex],
|
||||
([newSpinning, newTargetIdx]) => {
|
||||
if (newSpinning && newTargetIdx !== null && newTargetIdx !== undefined && !isSpinning.value) {
|
||||
spinTo(newTargetIdx);
|
||||
}
|
||||
},
|
||||
{ flush: 'post' },
|
||||
);
|
||||
|
||||
const totalSegments = computed(() => props.segments.length);
|
||||
const sliceAngle = computed(() => (totalSegments.value > 0 ? 360 / totalSegments.value : 0));
|
||||
|
||||
function getSlicePath(index: number): string {
|
||||
if (totalSegments.value <= 1) return '';
|
||||
const r = 138;
|
||||
const cx = 150;
|
||||
const cy = 150;
|
||||
const startAngle = (index * sliceAngle.value - 90) * (Math.PI / 180);
|
||||
const endAngle = ((index + 1) * sliceAngle.value - 90) * (Math.PI / 180);
|
||||
|
||||
const x1 = cx + r * Math.cos(startAngle);
|
||||
const y1 = cy + r * Math.sin(startAngle);
|
||||
const x2 = cx + r * Math.cos(endAngle);
|
||||
const y2 = cy + r * Math.sin(endAngle);
|
||||
|
||||
const largeArc = sliceAngle.value > 180 ? 1 : 0;
|
||||
return `M ${cx} ${cy} L ${x1} ${y1} A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2} Z`;
|
||||
}
|
||||
|
||||
function getTextRotation(index: number): string {
|
||||
const midAngle = index * sliceAngle.value + sliceAngle.value / 2;
|
||||
return `rotate(${midAngle} 150 150)`;
|
||||
}
|
||||
|
||||
// 24 Luxury embossed golden pegs/studs along rim
|
||||
const pegs = computed(() => {
|
||||
const count = 24;
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const angle = (i * (360 / count) - 90) * (Math.PI / 180);
|
||||
return {
|
||||
x: 150 + 143 * Math.cos(angle),
|
||||
y: 150 + 143 * Math.sin(angle),
|
||||
};
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative w-full max-w-[340px] xs:max-w-[380px] sm:max-w-[420px] aspect-square mx-auto select-none">
|
||||
<!-- Luxury Gold Outer Ring -->
|
||||
<div
|
||||
class="absolute inset-0 rounded-full p-3 shadow-[0_15px_40px_rgba(0,0,0,0.8)]"
|
||||
style="background: linear-gradient(135deg, #d4af37 0%, #f3e5ab 25%, #aa7c11 50%, #fdf6c7 75%, #8a6408 100%)"
|
||||
>
|
||||
<div class="w-full h-full rounded-full border-2 border-amber-200/60 relative overflow-hidden bg-gradient-to-b from-stone-900 to-black">
|
||||
<!-- SVG Wheel -->
|
||||
<svg
|
||||
class="w-full h-full"
|
||||
:style="wheelStyle"
|
||||
viewBox="0 0 300 300"
|
||||
>
|
||||
<!-- Segments -->
|
||||
<g v-for="(seg, idx) in segments" :key="seg.id">
|
||||
<path
|
||||
:d="getSlicePath(idx)"
|
||||
:fill="seg.color || (idx % 2 === 0 ? '#1c1917' : '#0c0a09')"
|
||||
stroke="#d4af37"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
<g :transform="getTextRotation(idx)">
|
||||
<text
|
||||
x="150"
|
||||
y="45"
|
||||
text-anchor="middle"
|
||||
fill="#fdf6c7"
|
||||
font-size="11"
|
||||
font-weight="700"
|
||||
class="wheel-slice-text font-sans tracking-wider"
|
||||
>
|
||||
{{ toFarsiDigits(seg.title) }}
|
||||
</text>
|
||||
<image
|
||||
v-if="seg.image"
|
||||
:href="seg.image"
|
||||
x="138"
|
||||
y="54"
|
||||
width="24"
|
||||
height="24"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- 24 Luxury Embossed Gold Pegs/Studs -->
|
||||
<g v-for="(peg, pIdx) in pegs" :key="pIdx">
|
||||
<circle :cx="peg.x" :cy="peg.y" r="4.2" fill="#1c1917" stroke="#aa7c11" stroke-width="0.7" />
|
||||
<circle :cx="peg.x" :cy="peg.y" r="3.3" fill="url(#luxuryPeg3D)" stroke="#fdf6c7" stroke-width="0.5" />
|
||||
<circle :cx="peg.x - 0.9" :cy="peg.y - 1" r="0.8" fill="#ffffff" opacity="0.85" />
|
||||
</g>
|
||||
|
||||
<!-- Center VIP Medallion -->
|
||||
<circle cx="150" cy="150" r="30" fill="url(#luxuryGoldGrad)" stroke="#fdf6c7" stroke-width="2" />
|
||||
<circle cx="150" cy="150" r="22" fill="#1c1917" stroke="#aa7c11" stroke-width="1" />
|
||||
<text x="150" y="156" text-anchor="middle" font-size="14" font-family="serif" font-weight="bold" fill="#fdf6c7">VIP</text>
|
||||
|
||||
<defs>
|
||||
<linearGradient id="luxuryGoldGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#fdf6c7" />
|
||||
<stop offset="50%" stop-color="#d4af37" />
|
||||
<stop offset="100%" stop-color="#8a6408" />
|
||||
</linearGradient>
|
||||
<radialGradient id="luxuryPeg3D" cx="35%" cy="30%" r="70%">
|
||||
<stop offset="0%" stop-color="#ffffff" />
|
||||
<stop offset="30%" stop-color="#fdf6c7" />
|
||||
<stop offset="70%" stop-color="#d4af37" />
|
||||
<stop offset="100%" stop-color="#543d03" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ornate Royal Gold Pointer with Tactile Peg Deflection -->
|
||||
<div class="absolute -top-3.5 left-1/2 -translate-x-1/2 z-20 pointer-events-none drop-shadow-[0_4px_10px_rgba(212,175,55,0.6)]">
|
||||
<div :style="pointerStyle" class="w-9 h-[46px] origin-[50%_12px] will-change-transform">
|
||||
<svg
|
||||
width="36"
|
||||
height="46"
|
||||
viewBox="0 0 36 46"
|
||||
fill="none"
|
||||
class="overflow-visible"
|
||||
>
|
||||
<path
|
||||
d="M18 46L5 14C3 9 7 3 12 3H24C29 3 33 9 31 14L18 46Z"
|
||||
fill="url(#luxuryPointerGrad)"
|
||||
stroke="#fff"
|
||||
stroke-width="1.2"
|
||||
/>
|
||||
<path
|
||||
d="M18 41L8 14C7 11 9 6 13 6H23C27 6 29 11 28 14L18 41Z"
|
||||
fill="none"
|
||||
stroke="rgba(255,255,255,0.4)"
|
||||
stroke-width="0.8"
|
||||
/>
|
||||
<circle cx="18" cy="12" r="4.5" fill="#1c1917" stroke="#fdf6c7" stroke-width="1.5" />
|
||||
<circle cx="17.2" cy="10.8" r="1.1" fill="#ffffff" opacity="0.85" />
|
||||
<defs>
|
||||
<linearGradient id="luxuryPointerGrad" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#fdf6c7" />
|
||||
<stop offset="50%" stop-color="#d4af37" />
|
||||
<stop offset="100%" stop-color="#543d03" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,140 @@
|
||||
<script setup lang="ts">
|
||||
import { toFarsiDigits, WheelDesignProps } from '@lucky-wheel/shared-types';
|
||||
import { computed, watch } from 'vue';
|
||||
import { useWheelRotation } from '../../composables/useWheelRotation';
|
||||
|
||||
const props = defineProps<WheelDesignProps>();
|
||||
const emit = defineEmits<{
|
||||
(e: 'spin-finished'): void;
|
||||
}>();
|
||||
|
||||
const { wheelStyle, pointerStyle, isSpinning, spinTo } = useWheelRotation({
|
||||
segmentCount: () => props.segments.length,
|
||||
pegCount: () => 24,
|
||||
onFinished: () => emit('spin-finished'),
|
||||
});
|
||||
|
||||
watch(
|
||||
[() => props.spinning, () => props.targetSegmentIndex],
|
||||
([newSpinning, newTargetIdx]) => {
|
||||
if (newSpinning && newTargetIdx !== null && newTargetIdx !== undefined && !isSpinning.value) {
|
||||
spinTo(newTargetIdx);
|
||||
}
|
||||
},
|
||||
{ flush: 'post' },
|
||||
);
|
||||
|
||||
const totalSegments = computed(() => props.segments.length);
|
||||
const sliceAngle = computed(() => (totalSegments.value > 0 ? 360 / totalSegments.value : 0));
|
||||
|
||||
function getSlicePath(index: number): string {
|
||||
if (totalSegments.value <= 1) return '';
|
||||
const r = 142;
|
||||
const cx = 150;
|
||||
const cy = 150;
|
||||
const startAngle = (index * sliceAngle.value - 90) * (Math.PI / 180);
|
||||
const endAngle = ((index + 1) * sliceAngle.value - 90) * (Math.PI / 180);
|
||||
|
||||
const x1 = cx + r * Math.cos(startAngle);
|
||||
const y1 = cy + r * Math.sin(startAngle);
|
||||
const x2 = cx + r * Math.cos(endAngle);
|
||||
const y2 = cy + r * Math.sin(endAngle);
|
||||
|
||||
const largeArc = sliceAngle.value > 180 ? 1 : 0;
|
||||
return `M ${cx} ${cy} L ${x1} ${y1} A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2} Z`;
|
||||
}
|
||||
|
||||
function getTextRotation(index: number): string {
|
||||
const midAngle = index * sliceAngle.value + sliceAngle.value / 2;
|
||||
return `rotate(${midAngle} 150 150)`;
|
||||
}
|
||||
|
||||
// 24 Minimal Rim Dots
|
||||
const minimalPegs = computed(() => {
|
||||
const count = 24;
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const angle = (i * (360 / count) - 90) * (Math.PI / 180);
|
||||
return {
|
||||
x: 150 + 144 * Math.cos(angle),
|
||||
y: 150 + 144 * Math.sin(angle),
|
||||
};
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative w-full max-w-[340px] xs:max-w-[380px] sm:max-w-[420px] aspect-square mx-auto select-none">
|
||||
<!-- Minimal Matte Outer Frame -->
|
||||
<div
|
||||
class="absolute inset-0 rounded-full p-2 bg-slate-800/80 backdrop-blur border border-slate-700 shadow-2xl"
|
||||
>
|
||||
<div class="w-full h-full rounded-full relative overflow-hidden bg-slate-900">
|
||||
<!-- SVG Spinning Wheel -->
|
||||
<svg
|
||||
class="w-full h-full"
|
||||
:style="wheelStyle"
|
||||
viewBox="0 0 300 300"
|
||||
>
|
||||
<g v-for="(seg, idx) in segments" :key="seg.id">
|
||||
<path
|
||||
:d="getSlicePath(idx)"
|
||||
:fill="seg.color || (idx % 2 === 0 ? '#3b82f6' : '#6366f1')"
|
||||
stroke="#ffffff"
|
||||
stroke-width="1"
|
||||
/>
|
||||
<g :transform="getTextRotation(idx)">
|
||||
<text
|
||||
x="150"
|
||||
y="45"
|
||||
text-anchor="middle"
|
||||
fill="#ffffff"
|
||||
font-size="11"
|
||||
font-weight="600"
|
||||
class="wheel-slice-text tracking-normal font-sans"
|
||||
>
|
||||
{{ toFarsiDigits(seg.title) }}
|
||||
</text>
|
||||
<image
|
||||
v-if="seg.image"
|
||||
:href="seg.image"
|
||||
x="138"
|
||||
y="54"
|
||||
width="24"
|
||||
height="24"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- 24 Minimal Rim Dots -->
|
||||
<circle
|
||||
v-for="(peg, pIdx) in minimalPegs"
|
||||
:key="pIdx"
|
||||
:cx="peg.x"
|
||||
:cy="peg.y"
|
||||
r="2"
|
||||
fill="#ffffff"
|
||||
opacity="0.8"
|
||||
/>
|
||||
|
||||
<!-- Clean Minimal Center -->
|
||||
<circle cx="150" cy="150" r="28" fill="#ffffff" filter="drop-shadow(0 2px 6px rgba(0,0,0,0.2))" />
|
||||
<circle cx="150" cy="150" r="14" fill="#0f172a" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Minimal Triangular Pointer with Tactile Peg Deflection -->
|
||||
<div class="absolute -top-2.5 left-1/2 -translate-x-1/2 z-20 pointer-events-none drop-shadow-md">
|
||||
<div :style="pointerStyle" class="w-7 h-[34px] origin-[50%_6px] will-change-transform">
|
||||
<svg width="28" height="34" viewBox="0 0 28 34" fill="none" class="overflow-visible">
|
||||
<path
|
||||
d="M14 34L2 6C0.5 3 2.5 0 6 0H22C25.5 0 27.5 3 26 6L14 34Z"
|
||||
fill="#3b82f6"
|
||||
stroke="#ffffff"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,140 @@
|
||||
<script setup lang="ts">
|
||||
import { toFarsiDigits, WheelDesignProps } from '@lucky-wheel/shared-types';
|
||||
import { computed, watch } from 'vue';
|
||||
import { useWheelRotation } from '../../composables/useWheelRotation';
|
||||
|
||||
const props = defineProps<WheelDesignProps>();
|
||||
const emit = defineEmits<{
|
||||
(e: 'spin-finished'): void;
|
||||
}>();
|
||||
|
||||
const { wheelStyle, pointerStyle, isSpinning, spinTo } = useWheelRotation({
|
||||
segmentCount: () => props.segments.length,
|
||||
pegCount: () => 24,
|
||||
onFinished: () => emit('spin-finished'),
|
||||
});
|
||||
|
||||
watch(
|
||||
[() => props.spinning, () => props.targetSegmentIndex],
|
||||
([newSpinning, newTargetIdx]) => {
|
||||
if (newSpinning && newTargetIdx !== null && newTargetIdx !== undefined && !isSpinning.value) {
|
||||
spinTo(newTargetIdx);
|
||||
}
|
||||
},
|
||||
{ flush: 'post' },
|
||||
);
|
||||
|
||||
const totalSegments = computed(() => props.segments.length);
|
||||
const sliceAngle = computed(() => (totalSegments.value > 0 ? 360 / totalSegments.value : 0));
|
||||
|
||||
function getSlicePath(index: number): string {
|
||||
if (totalSegments.value <= 1) return '';
|
||||
const r = 138;
|
||||
const cx = 150;
|
||||
const cy = 150;
|
||||
const startAngle = (index * sliceAngle.value - 90) * (Math.PI / 180);
|
||||
const endAngle = ((index + 1) * sliceAngle.value - 90) * (Math.PI / 180);
|
||||
|
||||
const x1 = cx + r * Math.cos(startAngle);
|
||||
const y1 = cy + r * Math.sin(startAngle);
|
||||
const x2 = cx + r * Math.cos(endAngle);
|
||||
const y2 = cy + r * Math.sin(endAngle);
|
||||
|
||||
const largeArc = sliceAngle.value > 180 ? 1 : 0;
|
||||
return `M ${cx} ${cy} L ${x1} ${y1} A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2} Z`;
|
||||
}
|
||||
|
||||
function getTextRotation(index: number): string {
|
||||
const midAngle = index * sliceAngle.value + sliceAngle.value / 2;
|
||||
return `rotate(${midAngle} 150 150)`;
|
||||
}
|
||||
|
||||
// 24 Cyberpunk Glowing Rim Pegs
|
||||
const cyberPegs = computed(() => {
|
||||
const count = 24;
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const angle = (i * (360 / count) - 90) * (Math.PI / 180);
|
||||
return {
|
||||
x: 150 + 143 * Math.cos(angle),
|
||||
y: 150 + 143 * Math.sin(angle),
|
||||
};
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative w-full max-w-[340px] xs:max-w-[380px] sm:max-w-[420px] aspect-square mx-auto select-none">
|
||||
<!-- Cyberpunk Glowing Bezel -->
|
||||
<div
|
||||
class="absolute inset-0 rounded-full p-2 bg-gradient-to-tr from-cyan-500 via-fuchsia-600 to-blue-600 shadow-[0_0_30px_rgba(0,240,255,0.4)] animate-neon-pulse"
|
||||
>
|
||||
<div class="w-full h-full rounded-full border-2 border-cyan-400 relative overflow-hidden bg-[#070913]">
|
||||
<!-- Rotating SVG Wheel -->
|
||||
<svg
|
||||
class="w-full h-full"
|
||||
:style="wheelStyle"
|
||||
viewBox="0 0 300 300"
|
||||
>
|
||||
<!-- Neon Grids & Segments -->
|
||||
<g v-for="(seg, idx) in segments" :key="seg.id">
|
||||
<path
|
||||
:d="getSlicePath(idx)"
|
||||
:fill="seg.color || (idx % 2 === 0 ? '#111827' : '#1e1b4b')"
|
||||
stroke="#00f0ff"
|
||||
stroke-width="1.5"
|
||||
stroke-opacity="0.8"
|
||||
/>
|
||||
<g :transform="getTextRotation(idx)">
|
||||
<text
|
||||
x="150"
|
||||
y="45"
|
||||
text-anchor="middle"
|
||||
fill="#ffffff"
|
||||
font-size="11"
|
||||
font-weight="800"
|
||||
class="wheel-slice-text tracking-wide uppercase font-sans"
|
||||
>
|
||||
{{ toFarsiDigits(seg.title) }}
|
||||
</text>
|
||||
<image
|
||||
v-if="seg.image"
|
||||
:href="seg.image"
|
||||
x="138"
|
||||
y="54"
|
||||
width="24"
|
||||
height="24"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- 24 Neon Rim Nodes -->
|
||||
<g v-for="(peg, pIdx) in cyberPegs" :key="pIdx">
|
||||
<circle :cx="peg.x" :cy="peg.y" r="3.2" fill="#05070e" stroke="#00f0ff" stroke-width="1" />
|
||||
<circle :cx="peg.x" :cy="peg.y" r="1.5" fill="#00f0ff" />
|
||||
</g>
|
||||
|
||||
<!-- High-Tech Center Arc Reactor -->
|
||||
<circle cx="150" cy="150" r="32" fill="#05070e" stroke="#00f0ff" stroke-width="2" />
|
||||
<circle cx="150" cy="150" r="22" fill="#1e1b4b" stroke="#ec4899" stroke-width="2" />
|
||||
<circle cx="150" cy="150" r="10" fill="#00f0ff" class="animate-ping" opacity="0.3" />
|
||||
<circle cx="150" cy="150" r="8" fill="#00f0ff" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cyberpunk Laser Pointer at 12 o'clock with Tactile Peg Deflection -->
|
||||
<div class="absolute -top-3 left-1/2 -translate-x-1/2 z-20 pointer-events-none drop-shadow-[0_0_12px_#00f0ff]">
|
||||
<div :style="pointerStyle" class="w-[34px] h-[42px] origin-[50%_10px] will-change-transform">
|
||||
<svg width="34" height="42" viewBox="0 0 34 42" fill="none" class="overflow-visible">
|
||||
<path
|
||||
d="M17 42L4 12C2 7 6 2 11 2H23C28 2 32 7 30 12L17 42Z"
|
||||
fill="#00f0ff"
|
||||
stroke="#ffffff"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
<circle cx="17" cy="10" r="4" fill="#ff007f" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,142 @@
|
||||
<script setup lang="ts">
|
||||
import { toFarsiDigits, WheelDesignProps } from '@lucky-wheel/shared-types';
|
||||
import { computed, watch } from 'vue';
|
||||
import { useWheelRotation } from '../../composables/useWheelRotation';
|
||||
|
||||
const props = defineProps<WheelDesignProps>();
|
||||
const emit = defineEmits<{
|
||||
(e: 'spin-finished'): void;
|
||||
}>();
|
||||
|
||||
const { wheelStyle, pointerStyle, isSpinning, spinTo } = useWheelRotation({
|
||||
segmentCount: () => props.segments.length,
|
||||
pegCount: () => 24,
|
||||
onFinished: () => emit('spin-finished'),
|
||||
});
|
||||
|
||||
watch(
|
||||
[() => props.spinning, () => props.targetSegmentIndex],
|
||||
([newSpinning, newTargetIdx]) => {
|
||||
if (newSpinning && newTargetIdx !== null && newTargetIdx !== undefined && !isSpinning.value) {
|
||||
spinTo(newTargetIdx);
|
||||
}
|
||||
},
|
||||
{ flush: 'post' },
|
||||
);
|
||||
|
||||
const totalSegments = computed(() => props.segments.length);
|
||||
const sliceAngle = computed(() => (totalSegments.value > 0 ? 360 / totalSegments.value : 0));
|
||||
|
||||
function getSlicePath(index: number): string {
|
||||
if (totalSegments.value <= 1) return '';
|
||||
const r = 138;
|
||||
const cx = 150;
|
||||
const cy = 150;
|
||||
const startAngle = (index * sliceAngle.value - 90) * (Math.PI / 180);
|
||||
const endAngle = ((index + 1) * sliceAngle.value - 90) * (Math.PI / 180);
|
||||
|
||||
const x1 = cx + r * Math.cos(startAngle);
|
||||
const y1 = cy + r * Math.sin(startAngle);
|
||||
const x2 = cx + r * Math.cos(endAngle);
|
||||
const y2 = cy + r * Math.sin(endAngle);
|
||||
|
||||
const largeArc = sliceAngle.value > 180 ? 1 : 0;
|
||||
return `M ${cx} ${cy} L ${x1} ${y1} A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2} Z`;
|
||||
}
|
||||
|
||||
function getTextRotation(index: number): string {
|
||||
const midAngle = index * sliceAngle.value + sliceAngle.value / 2;
|
||||
return `rotate(${midAngle} 150 150)`;
|
||||
}
|
||||
|
||||
const cartoonColors = ['#f43f5e', '#8b5cf6', '#06b6d4', '#10b981', '#f59e0b', '#ec4899'];
|
||||
|
||||
// 24 Chunky Candy Rim Pegs
|
||||
const candyPegs = computed(() => {
|
||||
const count = 24;
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const angle = (i * (360 / count) - 90) * (Math.PI / 180);
|
||||
return {
|
||||
x: 150 + 142 * Math.cos(angle),
|
||||
y: 150 + 142 * Math.sin(angle),
|
||||
color: cartoonColors[i % cartoonColors.length],
|
||||
};
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative w-full max-w-[340px] xs:max-w-[380px] sm:max-w-[420px] aspect-square mx-auto select-none">
|
||||
<!-- Chunky Cartoon Outer Ring -->
|
||||
<div
|
||||
class="absolute inset-0 rounded-full p-3.5 bg-amber-400 border-4 border-amber-600 shadow-[0_12px_0_#b45309,0_20px_25px_rgba(0,0,0,0.5)]"
|
||||
>
|
||||
<div class="w-full h-full rounded-full border-4 border-white relative overflow-hidden bg-amber-50">
|
||||
<!-- SVG Wheel -->
|
||||
<svg
|
||||
class="w-full h-full"
|
||||
:style="wheelStyle"
|
||||
viewBox="0 0 300 300"
|
||||
>
|
||||
<g v-for="(seg, idx) in segments" :key="seg.id">
|
||||
<path
|
||||
:d="getSlicePath(idx)"
|
||||
:fill="seg.color || cartoonColors[idx % cartoonColors.length]"
|
||||
stroke="#ffffff"
|
||||
stroke-width="3"
|
||||
/>
|
||||
<g :transform="getTextRotation(idx)">
|
||||
<text
|
||||
x="150"
|
||||
y="45"
|
||||
text-anchor="middle"
|
||||
fill="#ffffff"
|
||||
font-size="12"
|
||||
font-weight="900"
|
||||
class="wheel-slice-text font-sans tracking-wide"
|
||||
style="paint-order: stroke fill; stroke: rgba(0,0,0,0.3); stroke-width: 2px"
|
||||
>
|
||||
{{ toFarsiDigits(seg.title) }}
|
||||
</text>
|
||||
<image
|
||||
v-if="seg.image"
|
||||
:href="seg.image"
|
||||
x="138"
|
||||
y="54"
|
||||
width="24"
|
||||
height="24"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- 24 Chunky Candy Rim Pegs -->
|
||||
<g v-for="(peg, pIdx) in candyPegs" :key="pIdx">
|
||||
<circle :cx="peg.x" :cy="peg.y" r="4" fill="#ffffff" stroke="#b45309" stroke-width="1.5" />
|
||||
<circle :cx="peg.x" :cy="peg.y" r="2.8" :fill="peg.color" />
|
||||
<circle :cx="peg.x - 0.8" :cy="peg.y - 0.8" r="0.8" fill="#ffffff" />
|
||||
</g>
|
||||
|
||||
<!-- Chunky Candy Center Hub -->
|
||||
<circle cx="150" cy="150" r="32" fill="#ef4444" stroke="#ffffff" stroke-width="4" />
|
||||
<circle cx="150" cy="150" r="22" fill="#facc15" stroke="#b45309" stroke-width="2" />
|
||||
<text x="150" y="157" text-anchor="middle" font-size="20">✨</text>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bouncy Cartoon Pointer at 12 o'clock with Tactile Peg Deflection -->
|
||||
<div class="absolute -top-4 left-1/2 -translate-x-1/2 z-20 pointer-events-none drop-shadow-[0_6px_0_#991b1b]">
|
||||
<div :style="pointerStyle" class="w-[42px] h-[50px] origin-[50%_14px] will-change-transform">
|
||||
<svg width="42" height="50" viewBox="0 0 42 50" fill="none" class="overflow-visible">
|
||||
<path
|
||||
d="M21 50L6 16C4 11 8 4 14 4H28C34 4 38 11 36 16L21 50Z"
|
||||
fill="#ef4444"
|
||||
stroke="#ffffff"
|
||||
stroke-width="3"
|
||||
/>
|
||||
<circle cx="21" cy="14" r="6" fill="#facc15" stroke="#ffffff" stroke-width="2" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,258 @@
|
||||
import { ref } from 'vue';
|
||||
|
||||
const soundEnabled = ref<boolean>(localStorage.getItem('wheel_sound_enabled') !== 'false');
|
||||
|
||||
let audioCtx: AudioContext | null = null;
|
||||
|
||||
function getAudioContext(): AudioContext {
|
||||
if (!audioCtx) {
|
||||
const AudioContextClass = window.AudioContext || (window as any).webkitAudioContext;
|
||||
audioCtx = new AudioContextClass();
|
||||
}
|
||||
if (audioCtx.state === 'suspended') {
|
||||
audioCtx.resume();
|
||||
}
|
||||
return audioCtx;
|
||||
}
|
||||
|
||||
let sharedNoiseBuffer: AudioBuffer | null = null;
|
||||
function getSharedNoiseBuffer(ctx: AudioContext): AudioBuffer {
|
||||
if (!sharedNoiseBuffer || sharedNoiseBuffer.sampleRate !== ctx.sampleRate) {
|
||||
const len = Math.ceil(ctx.sampleRate * 0.05);
|
||||
sharedNoiseBuffer = ctx.createBuffer(1, len, ctx.sampleRate);
|
||||
const data = sharedNoiseBuffer.getChannelData(0);
|
||||
for (let i = 0; i < len; i++) {
|
||||
data[i] = (Math.random() * 2 - 1) * Math.exp(-i / (ctx.sampleRate * 0.015));
|
||||
}
|
||||
}
|
||||
return sharedNoiseBuffer;
|
||||
}
|
||||
|
||||
export function useSoundEffects() {
|
||||
function toggleSound() {
|
||||
soundEnabled.value = !soundEnabled.value;
|
||||
localStorage.setItem('wheel_sound_enabled', String(soundEnabled.value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays a realistic mechanical peg/flapper tick.
|
||||
* @param intensity 0..1 — controls volume and pitch (higher = faster spin = louder & sharper)
|
||||
*/
|
||||
function playTick(intensity: number = 0.6) {
|
||||
if (!soundEnabled.value) return;
|
||||
try {
|
||||
const ctx = getAudioContext();
|
||||
const now = ctx.currentTime;
|
||||
|
||||
// Clamp intensity
|
||||
const t = Math.max(0.05, Math.min(1, intensity));
|
||||
|
||||
// --- Layer 1: Short noise burst (the "click") ---
|
||||
const noiseLen = 0.012 + (1 - t) * 0.008; // shorter at high speed
|
||||
const noiseSrc = ctx.createBufferSource();
|
||||
noiseSrc.buffer = getSharedNoiseBuffer(ctx);
|
||||
|
||||
// Bandpass filter to shape the click
|
||||
const clickFilter = ctx.createBiquadFilter();
|
||||
clickFilter.type = 'bandpass';
|
||||
clickFilter.frequency.value = 2800 + t * 1200; // sharper at high speed
|
||||
clickFilter.Q.value = 1.5;
|
||||
|
||||
const noiseGain = ctx.createGain();
|
||||
noiseGain.gain.setValueAtTime(0.18 * t + 0.06, now);
|
||||
noiseGain.gain.exponentialRampToValueAtTime(0.001, now + noiseLen);
|
||||
|
||||
noiseSrc.connect(clickFilter);
|
||||
clickFilter.connect(noiseGain);
|
||||
noiseGain.connect(ctx.destination);
|
||||
noiseSrc.start(now);
|
||||
noiseSrc.stop(now + noiseLen + 0.005);
|
||||
|
||||
// --- Layer 2: Resonant body tone (the "thunk") ---
|
||||
const bodyOsc = ctx.createOscillator();
|
||||
bodyOsc.type = 'triangle';
|
||||
const baseFreq = 600 + t * 400;
|
||||
bodyOsc.frequency.setValueAtTime(baseFreq, now);
|
||||
bodyOsc.frequency.exponentialRampToValueAtTime(baseFreq * 0.3, now + 0.04);
|
||||
|
||||
const bodyGain = ctx.createGain();
|
||||
bodyGain.gain.setValueAtTime(0.10 * t + 0.03, now);
|
||||
bodyGain.gain.exponentialRampToValueAtTime(0.001, now + 0.04);
|
||||
|
||||
bodyOsc.connect(bodyGain);
|
||||
bodyGain.connect(ctx.destination);
|
||||
bodyOsc.start(now);
|
||||
bodyOsc.stop(now + 0.05);
|
||||
} catch {
|
||||
// Audio context might be restricted before interaction
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays a celebratory ascending arpeggio fanfare for winning.
|
||||
*/
|
||||
function playWin() {
|
||||
if (!soundEnabled.value) return;
|
||||
try {
|
||||
const ctx = getAudioContext();
|
||||
const now = ctx.currentTime;
|
||||
|
||||
// Celebration arpeggio: C5 → E5 → G5 → C6, with shimmer
|
||||
const notes = [523.25, 659.25, 783.99, 1046.5];
|
||||
const delay = 0.10;
|
||||
|
||||
notes.forEach((freq, i) => {
|
||||
// Main bell tone
|
||||
const osc = ctx.createOscillator();
|
||||
osc.type = 'sine';
|
||||
osc.frequency.value = freq;
|
||||
|
||||
// Harmonic shimmer
|
||||
const shimmer = ctx.createOscillator();
|
||||
shimmer.type = 'sine';
|
||||
shimmer.frequency.value = freq * 2.01; // slight detune for shimmer
|
||||
|
||||
const gain = ctx.createGain();
|
||||
const t0 = now + i * delay;
|
||||
gain.gain.setValueAtTime(0, t0);
|
||||
gain.gain.linearRampToValueAtTime(0.22, t0 + 0.03);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, t0 + 0.7);
|
||||
|
||||
const shimGain = ctx.createGain();
|
||||
shimGain.gain.setValueAtTime(0, t0);
|
||||
shimGain.gain.linearRampToValueAtTime(0.06, t0 + 0.03);
|
||||
shimGain.gain.exponentialRampToValueAtTime(0.001, t0 + 0.5);
|
||||
|
||||
osc.connect(gain);
|
||||
shimmer.connect(shimGain);
|
||||
gain.connect(ctx.destination);
|
||||
shimGain.connect(ctx.destination);
|
||||
|
||||
osc.start(t0);
|
||||
osc.stop(t0 + 0.75);
|
||||
shimmer.start(t0);
|
||||
shimmer.stop(t0 + 0.55);
|
||||
});
|
||||
|
||||
// Final chime accent
|
||||
const chime = ctx.createOscillator();
|
||||
chime.type = 'sine';
|
||||
chime.frequency.value = 2093; // C7
|
||||
|
||||
const chimeGain = ctx.createGain();
|
||||
const ct = now + notes.length * delay + 0.05;
|
||||
chimeGain.gain.setValueAtTime(0, ct);
|
||||
chimeGain.gain.linearRampToValueAtTime(0.10, ct + 0.01);
|
||||
chimeGain.gain.exponentialRampToValueAtTime(0.001, ct + 0.6);
|
||||
|
||||
chime.connect(chimeGain);
|
||||
chimeGain.connect(ctx.destination);
|
||||
chime.start(ct);
|
||||
chime.stop(ct + 0.65);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays the iconic "Sad Trombone" losing sound (Wah-Wah-Wah-Waaah):
|
||||
* 4 descending brass notes with resonant filter sweeps and a final
|
||||
* long sliding note with wide comedic wobble vibrato.
|
||||
*/
|
||||
function playNoPrize() {
|
||||
if (!soundEnabled.value) return;
|
||||
try {
|
||||
const ctx = getAudioContext();
|
||||
const now = ctx.currentTime;
|
||||
|
||||
// Sad Trombone notes: Eb4 (311Hz), D4 (294Hz), Db4 (277Hz), C4 (261Hz -> sliding down to ~210Hz)
|
||||
const noteDefs = [
|
||||
{ freq: 311.13, duration: 0.28, delay: 0.00, slide: false },
|
||||
{ freq: 293.66, duration: 0.28, delay: 0.32, slide: false },
|
||||
{ freq: 277.18, duration: 0.28, delay: 0.64, slide: false },
|
||||
{ freq: 261.63, duration: 1.45, delay: 0.98, slide: true },
|
||||
];
|
||||
|
||||
noteDefs.forEach(({ freq, duration, delay, slide }) => {
|
||||
const tStart = now + delay;
|
||||
const tEnd = tStart + duration;
|
||||
|
||||
// 1. Dual oscillator for rich brass timbre (Sawtooth + detuned Sawtooth)
|
||||
const osc1 = ctx.createOscillator();
|
||||
const osc2 = ctx.createOscillator();
|
||||
osc1.type = 'sawtooth';
|
||||
osc2.type = 'sawtooth';
|
||||
|
||||
osc1.frequency.setValueAtTime(freq, tStart);
|
||||
osc2.frequency.setValueAtTime(freq * 1.008, tStart); // detuned for fat horn chorus
|
||||
|
||||
// 2. Comedic pitch bend and wobble on the final "Waaaaah"
|
||||
if (slide) {
|
||||
// Slow comedic slide down to ~210 Hz
|
||||
osc1.frequency.setValueAtTime(freq, tStart);
|
||||
osc2.frequency.setValueAtTime(freq * 1.008, tStart);
|
||||
osc1.frequency.exponentialRampToValueAtTime(208, tEnd);
|
||||
osc2.frequency.exponentialRampToValueAtTime(209, tEnd);
|
||||
|
||||
// LFO vibrato (wobble)
|
||||
const lfo = ctx.createOscillator();
|
||||
const lfoGain = ctx.createGain();
|
||||
lfo.frequency.setValueAtTime(5.8, tStart); // 5.8 Hz wobble
|
||||
lfoGain.gain.setValueAtTime(0, tStart);
|
||||
// Vibrato intensifies towards the end of the note
|
||||
lfoGain.gain.linearRampToValueAtTime(8.5, tStart + 0.4);
|
||||
|
||||
lfo.connect(lfoGain);
|
||||
lfoGain.connect(osc1.frequency);
|
||||
lfoGain.connect(osc2.frequency);
|
||||
|
||||
lfo.start(tStart);
|
||||
lfo.stop(tEnd + 0.1);
|
||||
}
|
||||
|
||||
// 3. Resonant "WAH" Filter (The trombone plunger mute effect)
|
||||
// Sweeps cutoff from ~280Hz up to ~1500Hz then back down to create the vocal "wah" formants
|
||||
const filter = ctx.createBiquadFilter();
|
||||
filter.type = 'lowpass';
|
||||
filter.Q.value = 4.8; // High resonance for characteristic "waaa" vowel shape
|
||||
|
||||
const wahPeakTime = tStart + Math.min(0.08, duration * 0.28);
|
||||
filter.frequency.setValueAtTime(280, tStart);
|
||||
filter.frequency.exponentialRampToValueAtTime(slide ? 1650 : 1450, wahPeakTime);
|
||||
filter.frequency.exponentialRampToValueAtTime(slide ? 300 : 360, tEnd);
|
||||
|
||||
// 4. Amplitude envelope
|
||||
const gain = ctx.createGain();
|
||||
gain.gain.setValueAtTime(0.001, tStart);
|
||||
gain.gain.linearRampToValueAtTime(0.22, tStart + 0.03);
|
||||
if (slide) {
|
||||
gain.gain.setValueAtTime(0.22, tStart + 0.65);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, tEnd);
|
||||
} else {
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, tEnd);
|
||||
}
|
||||
|
||||
// Audio node routing
|
||||
osc1.connect(filter);
|
||||
osc2.connect(filter);
|
||||
filter.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
|
||||
osc1.start(tStart);
|
||||
osc2.start(tStart);
|
||||
osc1.stop(tEnd + 0.05);
|
||||
osc2.stop(tEnd + 0.05);
|
||||
});
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
soundEnabled,
|
||||
toggleSound,
|
||||
playTick,
|
||||
playWin,
|
||||
playNoPrize,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { computed, onUnmounted, ref } from 'vue';
|
||||
import { useSoundEffects } from './useSoundEffects';
|
||||
|
||||
export interface UseWheelRotationOptions {
|
||||
segmentCount: () => number;
|
||||
pegCount?: () => number;
|
||||
onFinished?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Physics-based wheel rotation & realistic flapper/pointer bounce engine.
|
||||
*
|
||||
* Simulates real casino/carnival flapper stopper interaction:
|
||||
* 1. As each peg/bulb on the wheel rim reaches 12 o'clock, it physically
|
||||
* deflects the pointer tip clockwise (micro-tilt up to ~14°).
|
||||
* 2. At the instant the peg slips off the tip, the pointer snaps back with a
|
||||
* crisp spring recoil, and playTick() fires in frame-perfect sync.
|
||||
* 3. At high speed, the pointer buzzes/chatters continuously; as the wheel
|
||||
* decelerates, you clearly see each individual peg deflection and snap.
|
||||
*/
|
||||
export function useWheelRotation(options: UseWheelRotationOptions) {
|
||||
const { playTick } = useSoundEffects();
|
||||
|
||||
const currentRotation = ref(0);
|
||||
const pointerAngle = ref(0);
|
||||
const isSpinning = ref(false);
|
||||
|
||||
// Check prefers-reduced-motion
|
||||
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
let animFrameId: number | null = null;
|
||||
let settleFrameId: number | null = null;
|
||||
|
||||
function spinTo(targetIndex: number) {
|
||||
if (isSpinning.value) return;
|
||||
|
||||
const n = options.segmentCount();
|
||||
if (n <= 0) return;
|
||||
|
||||
if (settleFrameId !== null) {
|
||||
cancelAnimationFrame(settleFrameId);
|
||||
settleFrameId = null;
|
||||
}
|
||||
|
||||
isSpinning.value = true;
|
||||
const sliceAngle = 360 / n;
|
||||
|
||||
// Small jitter within slice for natural feel
|
||||
const jitter = (Math.random() - 0.5) * (sliceAngle * 0.4);
|
||||
|
||||
// Angle of target slice center from top (0 deg)
|
||||
const targetSliceCenter = targetIndex * sliceAngle + sliceAngle / 2 + jitter;
|
||||
|
||||
// Target stop angle in [0, 360)
|
||||
const stopAngle = (360 - (targetSliceCenter % 360)) % 360;
|
||||
|
||||
// Multi-turn rotation: 7-9 full spins + remainder
|
||||
const fullSpins = prefersReducedMotion ? 1 : 7 + Math.floor(Math.random() * 3);
|
||||
const spinDuration = prefersReducedMotion ? 0.8 : 5.5; // seconds
|
||||
|
||||
// Compute total angle to add
|
||||
const currentModulo = currentRotation.value % 360;
|
||||
const distanceToStop = (stopAngle - currentModulo + 360) % 360;
|
||||
const totalAngle = fullSpins * 360 + distanceToStop;
|
||||
|
||||
// Decay constant — tuned so at t=spinDuration, the exponential is ~99.5% done
|
||||
const k = 5.5 / spinDuration;
|
||||
|
||||
const startRot = currentRotation.value;
|
||||
const startTime = performance.now();
|
||||
let lastTime = startTime;
|
||||
let lastRot = startRot;
|
||||
|
||||
// Number of physical rim pegs (defaults to 24 or max(24, n))
|
||||
const totalPegs = options.pegCount ? options.pegCount() : Math.max(24, n);
|
||||
const pegSpan = 360 / totalPegs; // e.g. 15 degrees for 24 pegs
|
||||
const contactZone = pegSpan * 0.38; // last 38% of peg sector is physical push zone
|
||||
|
||||
let pointerAngleVal = 0;
|
||||
let pointerVelocity = 0;
|
||||
let lastPegIndex = Math.floor(((startRot % 360) + 360) % 360 / pegSpan);
|
||||
|
||||
function easeOutExponential(t: number): number {
|
||||
return 1 - Math.exp(-k * t * spinDuration);
|
||||
}
|
||||
|
||||
const normFactor = 1 / easeOutExponential(1);
|
||||
|
||||
function animate(now: number) {
|
||||
const elapsed = (now - startTime) / 1000;
|
||||
const progress = Math.min(1, elapsed / spinDuration);
|
||||
const dt = Math.min(0.04, Math.max(0.001, (now - lastTime) / 1000));
|
||||
lastTime = now;
|
||||
|
||||
let easedProgress: number;
|
||||
if (progress >= 1) {
|
||||
easedProgress = 1;
|
||||
} else {
|
||||
easedProgress = easeOutExponential(progress) * normFactor;
|
||||
}
|
||||
|
||||
// Current angle
|
||||
const newRotation = startRot + totalAngle * easedProgress;
|
||||
const deltaRotation = newRotation - lastRot;
|
||||
lastRot = newRotation;
|
||||
currentRotation.value = newRotation;
|
||||
|
||||
// --- Flapper Pointer Physical Interaction ---
|
||||
if (!prefersReducedMotion) {
|
||||
const instantaneousSpeed = deltaRotation / dt; // deg/sec
|
||||
const speedRatio = Math.min(1, Math.max(0, instantaneousSpeed / 800));
|
||||
|
||||
const mod = ((newRotation % pegSpan) + pegSpan) % pegSpan;
|
||||
const currentPeg = Math.floor(((newRotation % 360) + 360) % 360 / pegSpan);
|
||||
|
||||
// Peg release event (when crossing peg boundary)
|
||||
if (currentPeg !== lastPegIndex) {
|
||||
const intensity = Math.max(0.12, Math.min(1, speedRatio * 1.8 + 0.15));
|
||||
playTick(intensity);
|
||||
|
||||
// Spring rebound kick
|
||||
pointerVelocity = -180 * (0.8 + speedRatio * 0.5);
|
||||
lastPegIndex = currentPeg;
|
||||
}
|
||||
|
||||
// Active physical contact with peg
|
||||
if (mod >= pegSpan - contactZone) {
|
||||
const u = (mod - (pegSpan - contactZone)) / contactZone; // 0 to 1
|
||||
const pushAngle = (11 + speedRatio * 3.5) * Math.sin(u * (Math.PI / 2));
|
||||
pointerAngleVal = Math.max(pointerAngleVal, pushAngle);
|
||||
pointerVelocity = 0;
|
||||
} else {
|
||||
// Free flight damped harmonic oscillator
|
||||
const springK = 1400;
|
||||
const damping = 42;
|
||||
const accel = -springK * pointerAngleVal - damping * pointerVelocity;
|
||||
pointerVelocity += accel * dt;
|
||||
pointerAngleVal += pointerVelocity * dt;
|
||||
|
||||
// Physical stopper bounce: flapper cannot bend backwards through resting stop
|
||||
if (pointerAngleVal < -3) {
|
||||
pointerAngleVal = -3;
|
||||
pointerVelocity = -pointerVelocity * 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
pointerAngle.value = pointerAngleVal;
|
||||
}
|
||||
|
||||
if (progress < 1) {
|
||||
animFrameId = requestAnimationFrame(animate);
|
||||
} else {
|
||||
// Snap to exact final rotation
|
||||
currentRotation.value = startRot + totalAngle;
|
||||
isSpinning.value = false;
|
||||
animFrameId = null;
|
||||
|
||||
// Smoothly settle flapper
|
||||
settlePointer();
|
||||
|
||||
if (options.onFinished) {
|
||||
options.onFinished();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function settlePointer() {
|
||||
let settleTime = performance.now();
|
||||
function settle(now: number) {
|
||||
const dt = Math.min(0.03, (now - settleTime) / 1000);
|
||||
settleTime = now;
|
||||
const springK = 1000;
|
||||
const damping = 40;
|
||||
const accel = -springK * pointerAngleVal - damping * pointerVelocity;
|
||||
pointerVelocity += accel * dt;
|
||||
pointerAngleVal += pointerVelocity * dt;
|
||||
|
||||
if (Math.abs(pointerAngleVal) < 0.05 && Math.abs(pointerVelocity) < 1) {
|
||||
pointerAngle.value = 0;
|
||||
settleFrameId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
pointerAngle.value = pointerAngleVal;
|
||||
settleFrameId = requestAnimationFrame(settle);
|
||||
}
|
||||
settleFrameId = requestAnimationFrame(settle);
|
||||
}
|
||||
|
||||
animFrameId = requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (animFrameId !== null) cancelAnimationFrame(animFrameId);
|
||||
if (settleFrameId !== null) cancelAnimationFrame(settleFrameId);
|
||||
});
|
||||
|
||||
const wheelStyle = computed(() => ({
|
||||
transform: `rotate(${currentRotation.value}deg)`,
|
||||
transformOrigin: '50% 50%',
|
||||
willChange: 'transform',
|
||||
}));
|
||||
|
||||
const pointerStyle = computed(() => ({
|
||||
transform: `rotate(${pointerAngle.value}deg)`,
|
||||
transformOrigin: '50% 12px',
|
||||
willChange: 'transform',
|
||||
}));
|
||||
|
||||
return {
|
||||
currentRotation,
|
||||
pointerAngle,
|
||||
pointerStyle,
|
||||
isSpinning,
|
||||
wheelStyle,
|
||||
spinTo,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
@import './assets/fonts/fonts.css';
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
direction: rtl;
|
||||
font-family: 'IRANSans', 'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom Utilities for Wheels */
|
||||
.wheel-slice-text {
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(148, 163, 184, 0.4);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(148, 163, 184, 0.7);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createPinia } from 'pinia';
|
||||
import { createApp } from 'vue';
|
||||
import App from './App.vue';
|
||||
import './index.css';
|
||||
import { router } from './router';
|
||||
|
||||
const app = createApp(App);
|
||||
const pinia = createPinia();
|
||||
|
||||
app.use(pinia);
|
||||
app.use(router);
|
||||
|
||||
app.mount('#app');
|
||||
@@ -0,0 +1,33 @@
|
||||
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router';
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/my-prizes',
|
||||
name: 'my-prizes',
|
||||
component: () => import('../views/MyPrizesView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/c/:slug',
|
||||
name: 'campaign-wheel-slug',
|
||||
component: () => import('../views/CampaignWheelView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/:slug',
|
||||
name: 'campaign-wheel',
|
||||
component: () => import('../views/CampaignWheelView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
redirect: '/c/default-campaign',
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: '/',
|
||||
},
|
||||
];
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import axios from 'axios';
|
||||
|
||||
declare module 'axios' {
|
||||
export interface AxiosResponse<T = any> {
|
||||
meta?: any;
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = axios.create({
|
||||
baseURL: '/api',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
apiClient.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('user_access_token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error),
|
||||
);
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => {
|
||||
if (response.data && response.data.success !== undefined && response.data.data !== undefined) {
|
||||
return { ...response, data: response.data.data, meta: response.data.meta };
|
||||
}
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,108 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { computed, ref } from 'vue';
|
||||
import { apiClient } from '../services/api/client';
|
||||
|
||||
export interface UserProfile {
|
||||
id: string;
|
||||
mobile: string;
|
||||
name?: string | null;
|
||||
}
|
||||
|
||||
export interface ChancesState {
|
||||
totalChances: number;
|
||||
usedChances: number;
|
||||
remainingChances: number;
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('userAuth', () => {
|
||||
const token = ref<string | null>(localStorage.getItem('user_access_token'));
|
||||
const user = ref<UserProfile | null>(
|
||||
localStorage.getItem('user_info') ? JSON.parse(localStorage.getItem('user_info')!) : null,
|
||||
);
|
||||
const chances = ref<ChancesState>({
|
||||
totalChances: 0,
|
||||
usedChances: 0,
|
||||
remainingChances: 0,
|
||||
});
|
||||
|
||||
const isAuthenticated = computed(() => !!token.value);
|
||||
|
||||
async function requestOtp(mobile: string) {
|
||||
const res = await apiClient.post('/auth/send-otp', { mobile });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
async function verifyOtp(mobile: string, code: string, campaignId?: string) {
|
||||
const res = await apiClient.post('/auth/verify-otp', { mobile, code });
|
||||
const authData = res.data;
|
||||
|
||||
token.value = authData.accessToken;
|
||||
user.value = authData.user;
|
||||
|
||||
localStorage.setItem('user_access_token', authData.accessToken);
|
||||
localStorage.setItem('user_info', JSON.stringify(authData.user));
|
||||
|
||||
if (campaignId) {
|
||||
await loadChances(campaignId);
|
||||
}
|
||||
return authData;
|
||||
}
|
||||
|
||||
async function loadProfile() {
|
||||
if (!token.value) return null;
|
||||
try {
|
||||
const res = await apiClient.get('/me');
|
||||
user.value = res.data;
|
||||
localStorage.setItem('user_info', JSON.stringify(res.data));
|
||||
return res.data;
|
||||
} catch {
|
||||
logout();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadChances(campaignId: string) {
|
||||
if (!token.value || !campaignId) return;
|
||||
try {
|
||||
const res = await apiClient.get('/me/chances', {
|
||||
params: { campaignId },
|
||||
});
|
||||
chances.value = res.data;
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
function decrementChance() {
|
||||
if (chances.value.remainingChances > 0) {
|
||||
chances.value.remainingChances--;
|
||||
chances.value.usedChances++;
|
||||
}
|
||||
}
|
||||
|
||||
function setRemainingChances(count: number) {
|
||||
chances.value.remainingChances = count;
|
||||
}
|
||||
|
||||
function logout() {
|
||||
token.value = null;
|
||||
user.value = null;
|
||||
chances.value = { totalChances: 0, usedChances: 0, remainingChances: 0 };
|
||||
localStorage.removeItem('user_access_token');
|
||||
localStorage.removeItem('user_info');
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
user,
|
||||
chances,
|
||||
isAuthenticated,
|
||||
requestOtp,
|
||||
verifyOtp,
|
||||
loadProfile,
|
||||
loadChances,
|
||||
decrementChance,
|
||||
setRemainingChances,
|
||||
logout,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import confetti from 'canvas-confetti';
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
import { useSoundEffects } from '../composables/useSoundEffects';
|
||||
import { apiClient } from '../services/api/client';
|
||||
import { useAuthStore } from './auth.store';
|
||||
|
||||
export interface PrizeResult {
|
||||
id: string;
|
||||
title: string;
|
||||
segment: number;
|
||||
code?: string | null;
|
||||
type: string;
|
||||
image?: string | null;
|
||||
}
|
||||
|
||||
export const useSpinStore = defineStore('spin', () => {
|
||||
const authStore = useAuthStore();
|
||||
const { playWin, playNoPrize } = useSoundEffects();
|
||||
|
||||
const isRequesting = ref(false);
|
||||
const isWheelSpinning = ref(false);
|
||||
const targetSegmentIndex = ref<number | null>(null);
|
||||
const spinResult = ref<any>(null);
|
||||
const showResultModal = ref(false);
|
||||
const errorMessage = ref<string | null>(null);
|
||||
const pendingIdempotencyKey = ref<string | null>(null);
|
||||
|
||||
function generateIdempotencyKey(): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.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 executeSpin(campaignId: string, segments: any[]) {
|
||||
if (isRequesting.value || isWheelSpinning.value) return;
|
||||
|
||||
if (!authStore.isAuthenticated) {
|
||||
errorMessage.value = 'لطفاً ابتدا وارد حساب کاربری خود شوید.';
|
||||
return;
|
||||
}
|
||||
|
||||
if (authStore.chances.remainingChances <= 0) {
|
||||
errorMessage.value = 'شما شانس کافی برای چرخاندن گردونه ندارید!';
|
||||
return;
|
||||
}
|
||||
|
||||
isRequesting.value = true;
|
||||
errorMessage.value = null;
|
||||
|
||||
if (!pendingIdempotencyKey.value) {
|
||||
pendingIdempotencyKey.value = generateIdempotencyKey();
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await apiClient.post(`/campaigns/${campaignId}/spin`, {
|
||||
idempotencyKey: pendingIdempotencyKey.value,
|
||||
});
|
||||
|
||||
const data = res.data;
|
||||
spinResult.value = data;
|
||||
|
||||
// Update remaining chances from server
|
||||
if (typeof data.remainingChances === 'number') {
|
||||
authStore.setRemainingChances(data.remainingChances);
|
||||
} else {
|
||||
authStore.decrementChance();
|
||||
}
|
||||
|
||||
// Find the segment index in the client segments list
|
||||
let segIdx = 0;
|
||||
if (data.prize) {
|
||||
// Priority 1: Match strictly by prize id first
|
||||
let found = segments.findIndex((s) => s.id === data.prize.id);
|
||||
|
||||
// Priority 2: Fallback match by exact title
|
||||
if (found === -1 && data.prize.title) {
|
||||
found = segments.findIndex((s) => s.title === data.prize.title);
|
||||
}
|
||||
|
||||
// Priority 3: Fallback match by sortOrder / segment
|
||||
if (found === -1 && data.prize.segment !== undefined) {
|
||||
found = segments.findIndex((s) => s.sortOrder === data.prize.segment);
|
||||
}
|
||||
|
||||
segIdx = found !== -1 ? found : 0;
|
||||
}
|
||||
|
||||
targetSegmentIndex.value = segIdx;
|
||||
isWheelSpinning.value = true;
|
||||
|
||||
// Clear the idempotency key once successfully acknowledged by server
|
||||
pendingIdempotencyKey.value = null;
|
||||
} catch (err: any) {
|
||||
let msg =
|
||||
err.response?.data?.message ||
|
||||
(Array.isArray(err.response?.data?.message)
|
||||
? err.response?.data?.message.join(' - ')
|
||||
: 'خطا در برقراری ارتباط با سرور قرعهکشی');
|
||||
if (typeof msg === 'string') {
|
||||
if (msg === 'CAMPAIGN_DRAFT') {
|
||||
msg = 'این کمپین در وضعیت پیشنویس (DRAFT) قرار دارد و هنوز فعال نشده است.';
|
||||
} else if (msg === 'CAMPAIGN_PAUSED') {
|
||||
msg = 'این کمپین موقتاً متوقف شده است.';
|
||||
} else if (msg === 'CAMPAIGN_NOT_STARTED') {
|
||||
msg = 'زمان شروع این کمپین هنوز فرا نرسیده است.';
|
||||
} else if (msg === 'CAMPAIGN_FINISHED') {
|
||||
msg = 'مهلت شرکت در این کمپین به پایان رسیده است.';
|
||||
}
|
||||
}
|
||||
errorMessage.value = msg;
|
||||
} finally {
|
||||
isRequesting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onWheelAnimationFinished() {
|
||||
isWheelSpinning.value = false;
|
||||
targetSegmentIndex.value = null;
|
||||
|
||||
const prize = spinResult.value?.prize;
|
||||
const isNoPrize = !prize || prize.type === 'NO_PRIZE' || prize.title?.trim() === 'پوچ' || prize.title?.includes('پوچ');
|
||||
|
||||
if (!isNoPrize) {
|
||||
playWin();
|
||||
// Confetti burst
|
||||
confetti({
|
||||
particleCount: 80,
|
||||
spread: 70,
|
||||
origin: { y: 0.6 },
|
||||
});
|
||||
} else {
|
||||
// Play famous iconic Sad Trombone losing sound
|
||||
playNoPrize();
|
||||
}
|
||||
|
||||
showResultModal.value = true;
|
||||
}
|
||||
|
||||
function closeResultModal() {
|
||||
showResultModal.value = false;
|
||||
spinResult.value = null;
|
||||
}
|
||||
|
||||
return {
|
||||
isRequesting,
|
||||
isWheelSpinning,
|
||||
targetSegmentIndex,
|
||||
spinResult,
|
||||
showResultModal,
|
||||
errorMessage,
|
||||
executeSpin,
|
||||
onWheelAnimationFinished,
|
||||
closeResultModal,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,324 @@
|
||||
<script setup lang="ts">
|
||||
import { toFarsiDigits, WheelDesign } from '@lucky-wheel/shared-types';
|
||||
import {
|
||||
AlertCircle,
|
||||
Award,
|
||||
ChevronDown,
|
||||
HelpCircle,
|
||||
Loader2,
|
||||
LogIn,
|
||||
LogOut,
|
||||
Sparkles,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
} from 'lucide-vue-next';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import AuthModal from '../components/modals/AuthModal.vue';
|
||||
import ResultModal from '../components/modals/ResultModal.vue';
|
||||
import ClassicCasinoWheel from '../components/wheels/ClassicCasinoWheel.vue';
|
||||
import LuxuryGoldWheel from '../components/wheels/LuxuryGoldWheel.vue';
|
||||
import MinimalFlatWheel from '../components/wheels/MinimalFlatWheel.vue';
|
||||
import NeonCyberWheel from '../components/wheels/NeonCyberWheel.vue';
|
||||
import PlayfulCartoonWheel from '../components/wheels/PlayfulCartoonWheel.vue';
|
||||
import { useSoundEffects } from '../composables/useSoundEffects';
|
||||
import { apiClient } from '../services/api/client';
|
||||
import { useAuthStore } from '../stores/auth.store';
|
||||
import { useSpinStore } from '../stores/spin.store';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
const spinStore = useSpinStore();
|
||||
const { soundEnabled, toggleSound } = useSoundEffects();
|
||||
|
||||
const slug = ref<string>((route.params.slug as string) || '');
|
||||
const loading = ref(true);
|
||||
const campaign = ref<any>(null);
|
||||
const errorMessage = ref<string | null>(null);
|
||||
const showAuthModal = ref(false);
|
||||
const showRules = ref(false);
|
||||
|
||||
const wheelComponents: Record<string, any> = {
|
||||
[WheelDesign.CLASSIC_CASINO]: ClassicCasinoWheel,
|
||||
[WheelDesign.NEON_CYBER]: NeonCyberWheel,
|
||||
[WheelDesign.MINIMAL_FLAT]: MinimalFlatWheel,
|
||||
[WheelDesign.LUXURY_GOLD]: LuxuryGoldWheel,
|
||||
[WheelDesign.PLAYFUL_CARTOON]: PlayfulCartoonWheel,
|
||||
};
|
||||
|
||||
const activeWheelComponent = computed(() => {
|
||||
const design = campaign.value?.wheelDesign || WheelDesign.CLASSIC_CASINO;
|
||||
return wheelComponents[design] || ClassicCasinoWheel;
|
||||
});
|
||||
|
||||
async function loadCampaign() {
|
||||
if (!slug.value) return;
|
||||
loading.value = true;
|
||||
errorMessage.value = null;
|
||||
try {
|
||||
const res = await apiClient.get(`/campaigns/${slug.value}/wheel`);
|
||||
const raw = res.data;
|
||||
campaign.value = {
|
||||
...(raw.campaign || raw),
|
||||
segments: raw.segments || raw.campaign?.segments || [],
|
||||
};
|
||||
|
||||
// Load chances if user is already authenticated
|
||||
if (authStore.isAuthenticated && campaign.value?.id) {
|
||||
await authStore.loadChances(campaign.value.id);
|
||||
}
|
||||
} catch (err: any) {
|
||||
errorMessage.value =
|
||||
err.response?.data?.message || 'کمپین مورد نظر یافت نشد یا در حال حاضر غیرفعال است.';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSpinClick() {
|
||||
if (!authStore.isAuthenticated) {
|
||||
showAuthModal.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (authStore.chances.remainingChances <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await spinStore.executeSpin(campaign.value.id, campaign.value.segments);
|
||||
}
|
||||
|
||||
async function onAuthSuccess() {
|
||||
if (campaign.value?.id) {
|
||||
await authStore.loadChances(campaign.value.id);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCampaign();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen flex flex-col bg-slate-950 text-slate-100 overflow-x-hidden">
|
||||
<!-- Top Navigation Header -->
|
||||
<header class="w-full max-w-md mx-auto px-4 py-3 flex items-center justify-between border-b border-slate-800/80 bg-slate-900/50 backdrop-blur sticky top-0 z-40">
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
@click="toggleSound"
|
||||
class="p-2 rounded-xl bg-slate-800 text-slate-300 hover:text-white transition"
|
||||
:title="soundEnabled ? 'قطع صدا' : 'وصل صدا'"
|
||||
>
|
||||
<Volume2 v-if="soundEnabled" class="w-4 h-4 text-emerald-400" />
|
||||
<VolumeX v-else class="w-4 h-4 text-slate-500" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="authStore.isAuthenticated"
|
||||
@click="router.push('/my-prizes')"
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-blue-500/10 border border-blue-500/20 text-blue-400 text-xs font-medium hover:bg-blue-500/20 transition"
|
||||
>
|
||||
<Award class="w-3.5 h-3.5" />
|
||||
<span>جوایز من</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<div v-if="authStore.isAuthenticated" class="flex items-center gap-2">
|
||||
<span class="text-xs text-slate-300 font-mono">{{ authStore.user?.mobile }}</span>
|
||||
<button
|
||||
@click="authStore.logout()"
|
||||
class="p-1.5 rounded-lg text-slate-500 hover:text-rose-400 hover:bg-slate-800 transition"
|
||||
title="خروج"
|
||||
>
|
||||
<LogOut class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
@click="showAuthModal = true"
|
||||
class="flex items-center gap-1 px-3 py-1.5 rounded-xl bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold shadow-sm transition"
|
||||
>
|
||||
<LogIn class="w-3.5 h-3.5" />
|
||||
<span>ورود / ثبتنام</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Content Container -->
|
||||
<main class="flex-1 w-full max-w-md mx-auto px-4 py-4 flex flex-col justify-between">
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="flex-1 flex flex-col items-center justify-center py-20 text-slate-400">
|
||||
<Loader2 class="w-10 h-10 animate-spin text-blue-500 mb-3" />
|
||||
<span class="text-sm font-medium">در حال بارگذاری گردونه شانس...</span>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="errorMessage" class="flex-1 flex flex-col items-center justify-center py-16 text-center">
|
||||
<div class="w-14 h-14 rounded-2xl bg-rose-500/10 text-rose-400 flex items-center justify-center mb-3">
|
||||
<AlertCircle class="w-7 h-7" />
|
||||
</div>
|
||||
<h3 class="text-base font-bold text-white mb-1">خطا در بارگذاری کمپین</h3>
|
||||
<p class="text-xs text-slate-400 max-w-xs mb-5">{{ errorMessage }}</p>
|
||||
<button
|
||||
@click="loadCampaign"
|
||||
class="px-4 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-xs font-medium text-white transition"
|
||||
>
|
||||
تلاش مجدد
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loaded Campaign Wheel Experience -->
|
||||
<div v-else-if="campaign" class="flex flex-col items-center">
|
||||
<!-- Campaign Banner -->
|
||||
<div
|
||||
v-if="campaign.banner"
|
||||
class="w-full max-w-sm mb-4 rounded-2xl overflow-hidden shadow-lg border border-slate-800/80 bg-slate-900"
|
||||
>
|
||||
<img :src="campaign.banner" :alt="campaign.title" class="w-full max-h-36 object-cover" />
|
||||
</div>
|
||||
|
||||
<!-- Campaign Title & Logo -->
|
||||
<div class="text-center mb-4">
|
||||
<div v-if="campaign.logo" class="mb-2.5 flex justify-center">
|
||||
<img :src="campaign.logo" :alt="campaign.title" class="max-h-14 max-w-[180px] object-contain drop-shadow-md" />
|
||||
</div>
|
||||
<h1 class="text-xl font-extrabold text-white tracking-tight">
|
||||
{{ campaign.title }}
|
||||
</h1>
|
||||
<p v-if="campaign.description" class="text-xs text-slate-400 mt-1 max-w-xs mx-auto line-clamp-2">
|
||||
{{ campaign.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Chances Counter Pill -->
|
||||
<div class="mb-4">
|
||||
<div
|
||||
v-if="authStore.isAuthenticated"
|
||||
class="inline-flex items-center gap-2 px-4 py-1.5 rounded-full bg-slate-900 border border-slate-800 text-xs shadow-inner"
|
||||
>
|
||||
<Sparkles class="w-3.5 h-3.5 text-amber-400" />
|
||||
<span class="text-slate-300">فرصتهای باقیمانده شما:</span>
|
||||
<span class="font-extrabold text-sm text-emerald-400 font-sans">
|
||||
{{ toFarsiDigits(authStore.chances.remainingChances) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-blue-500/10 border border-blue-500/20 text-[11px] text-blue-300"
|
||||
>
|
||||
<span>جهت دریافت شانس و چرخاندن، وارد شوید</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Campaign Status Alert (when not available) -->
|
||||
<div
|
||||
v-if="!campaign.isAvailable || campaign.availabilityError"
|
||||
class="w-full max-w-xs mb-3 p-3 rounded-2xl bg-amber-500/10 border border-amber-500/25 text-amber-300 text-xs text-center flex items-center justify-center gap-2"
|
||||
>
|
||||
<AlertCircle class="w-4 h-4 flex-shrink-0 text-amber-400" />
|
||||
<span>
|
||||
{{
|
||||
campaign.availabilityError === 'CAMPAIGN_DRAFT'
|
||||
? 'این کمپین در وضعیت پیشنویس (DRAFT) است و به زودی فعال خواهد شد.'
|
||||
: campaign.availabilityError === 'CAMPAIGN_PAUSED'
|
||||
? 'این کمپین موقتاً متوقف شده است.'
|
||||
: campaign.availabilityError === 'CAMPAIGN_NOT_STARTED'
|
||||
? 'زمان شروع این کمپین هنوز فرا نرسیده است.'
|
||||
: campaign.availabilityError === 'CAMPAIGN_FINISHED'
|
||||
? 'مهلت شرکت در این کمپین به پایان رسیده است.'
|
||||
: 'این کمپین در حال حاضر فعال نمیباشد.'
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Error Message during spin -->
|
||||
<div
|
||||
v-if="spinStore.errorMessage"
|
||||
class="w-full max-w-xs mb-3 p-2.5 rounded-xl bg-rose-500/10 border border-rose-500/20 text-rose-400 text-xs text-center font-medium"
|
||||
>
|
||||
{{ spinStore.errorMessage }}
|
||||
</div>
|
||||
|
||||
<!-- The Wheel Component -->
|
||||
<div class="w-full my-2 flex justify-center">
|
||||
<component
|
||||
:is="activeWheelComponent"
|
||||
:segments="campaign.segments || []"
|
||||
:target-segment-index="spinStore.targetSegmentIndex"
|
||||
:spinning="spinStore.isWheelSpinning"
|
||||
@spin-finished="spinStore.onWheelAnimationFinished"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Main Action CTA Button -->
|
||||
<div class="w-full max-w-xs mt-6 mb-4">
|
||||
<button
|
||||
@click="handleSpinClick"
|
||||
:disabled="
|
||||
!campaign.isAvailable ||
|
||||
spinStore.isRequesting ||
|
||||
spinStore.isWheelSpinning ||
|
||||
(authStore.isAuthenticated && authStore.chances.remainingChances <= 0)
|
||||
"
|
||||
class="w-full py-3.5 px-6 rounded-2xl font-extrabold text-base text-white shadow-xl flex items-center justify-center gap-2 transition transform active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none"
|
||||
:class="
|
||||
!campaign.isAvailable
|
||||
? 'bg-slate-800 text-slate-400 border border-slate-700'
|
||||
: authStore.isAuthenticated && authStore.chances.remainingChances <= 0
|
||||
? 'bg-slate-800 text-slate-500 border border-slate-700'
|
||||
: 'bg-gradient-to-r from-amber-500 via-orange-500 to-amber-600 hover:from-amber-400 hover:to-orange-400 shadow-amber-500/25 animate-pulse-glow'
|
||||
"
|
||||
>
|
||||
<Loader2 v-if="spinStore.isRequesting || spinStore.isWheelSpinning" class="w-5 h-5 animate-spin" />
|
||||
<Sparkles v-else class="w-5 h-5 text-yellow-200" />
|
||||
<span>
|
||||
{{
|
||||
!campaign.isAvailable
|
||||
? campaign.availabilityError === 'CAMPAIGN_DRAFT'
|
||||
? 'کمپین در وضعیت پیشنویس است'
|
||||
: 'کمپین غیرفعال است'
|
||||
: !authStore.isAuthenticated
|
||||
? 'ورود و چرخاندن گردونه'
|
||||
: authStore.chances.remainingChances <= 0
|
||||
? 'فرصت شما به پایان رسیده'
|
||||
: campaign.ctaText || 'بچرخون و برنده شو!'
|
||||
}}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Rules Accordion -->
|
||||
<div v-if="campaign.rules" class="w-full max-w-xs mt-2">
|
||||
<button
|
||||
@click="showRules = !showRules"
|
||||
class="w-full py-2 px-3 rounded-xl bg-slate-900 border border-slate-800 text-slate-400 hover:text-slate-200 text-xs flex items-center justify-between transition"
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<HelpCircle class="w-3.5 h-3.5 text-blue-400" />
|
||||
<span>قوانین و شرایط قرعهکشی</span>
|
||||
</div>
|
||||
<ChevronDown class="w-3.5 h-3.5 transition-transform" :class="{ 'rotate-180': showRules }" />
|
||||
</button>
|
||||
<div
|
||||
v-if="showRules"
|
||||
class="mt-1.5 p-3 rounded-xl bg-slate-900/60 border border-slate-800 text-[11px] text-slate-400 leading-relaxed whitespace-pre-line text-right"
|
||||
>
|
||||
{{ campaign.rules }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Modals -->
|
||||
<AuthModal
|
||||
:show="showAuthModal"
|
||||
:campaign-id="campaign?.id"
|
||||
@close="showAuthModal = false"
|
||||
@success="onAuthSuccess"
|
||||
/>
|
||||
|
||||
<ResultModal />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,192 @@
|
||||
<script setup lang="ts">
|
||||
import { toFarsiDigits } from '@lucky-wheel/shared-types';
|
||||
import {
|
||||
ArrowRight,
|
||||
Award,
|
||||
Check,
|
||||
Clock,
|
||||
Copy,
|
||||
Gift,
|
||||
Loader2,
|
||||
} from 'lucide-vue-next';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { apiClient } from '../services/api/client';
|
||||
import { useAuthStore } from '../stores/auth.store';
|
||||
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
const loading = ref(true);
|
||||
const prizes = ref<any[]>([]);
|
||||
const copiedId = ref<string | null>(null);
|
||||
|
||||
async function loadPrizes() {
|
||||
if (!authStore.isAuthenticated) {
|
||||
router.push('/');
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await apiClient.get('/me/prizes');
|
||||
prizes.value = res.data || [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyCode(code: string, id: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
copiedId.value = id;
|
||||
setTimeout(() => {
|
||||
copiedId.value = null;
|
||||
}, 2000);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusBadge(status: string) {
|
||||
switch (status) {
|
||||
case 'ACTIVE_CODE':
|
||||
return { text: 'کد فعال', bg: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20' };
|
||||
case 'USED':
|
||||
return { text: 'مصرف شده', bg: 'bg-slate-800 text-slate-400 border-slate-700' };
|
||||
case 'EXPIRED':
|
||||
return { text: 'منقضی شده', bg: 'bg-rose-500/10 text-rose-400 border-rose-500/20' };
|
||||
case 'DELIVERED':
|
||||
return { text: 'تحویل داده شده', bg: 'bg-blue-500/10 text-blue-400 border-blue-500/20' };
|
||||
case 'CONTACTED':
|
||||
return { text: 'در حال هماهنگی', bg: 'bg-amber-500/10 text-amber-400 border-amber-500/20' };
|
||||
case 'NEW':
|
||||
return { text: 'ثبت جدید', bg: 'bg-purple-500/10 text-purple-400 border-purple-500/20' };
|
||||
default:
|
||||
return { text: status, bg: 'bg-slate-800 text-slate-300 border-slate-700' };
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadPrizes();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-slate-950 text-slate-100 flex flex-col">
|
||||
<!-- Header -->
|
||||
<header class="w-full max-w-md mx-auto px-4 py-3 flex items-center justify-between border-b border-slate-800/80 bg-slate-900/50 backdrop-blur sticky top-0 z-40">
|
||||
<button
|
||||
@click="router.back()"
|
||||
class="flex items-center gap-1.5 text-xs text-slate-400 hover:text-white transition"
|
||||
>
|
||||
<ArrowRight class="w-4 h-4" />
|
||||
<span>بازگشت به گردونه</span>
|
||||
</button>
|
||||
|
||||
<h1 class="text-sm font-bold text-white flex items-center gap-1.5">
|
||||
<Award class="w-4 h-4 text-amber-400" />
|
||||
<span>صندوق جوایز من</span>
|
||||
</h1>
|
||||
|
||||
<div class="w-8"></div>
|
||||
</header>
|
||||
|
||||
<main class="flex-1 w-full max-w-md mx-auto p-4">
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="flex flex-col items-center justify-center py-24 text-slate-400">
|
||||
<Loader2 class="w-8 h-8 animate-spin text-blue-500 mb-2" />
|
||||
<span class="text-xs">در حال بارگذاری جوایز...</span>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else-if="prizes.length === 0" class="flex flex-col items-center justify-center py-20 text-center">
|
||||
<div class="w-16 h-16 rounded-2xl bg-slate-900 border border-slate-800 flex items-center justify-center text-slate-600 mb-4">
|
||||
<Gift class="w-8 h-8" />
|
||||
</div>
|
||||
<h3 class="text-base font-bold text-white mb-1">هنوز جایزهای دریافت نکردهاید</h3>
|
||||
<p class="text-xs text-slate-400 max-w-xs mb-6 leading-relaxed">
|
||||
گردونه شانس را بچرخانید و برنده جوایز نقدی، تخفیفها و کالاهای فیزیکی شوید!
|
||||
</p>
|
||||
<button
|
||||
@click="router.back()"
|
||||
class="px-5 py-2.5 rounded-xl bg-blue-600 hover:bg-blue-500 text-white font-semibold text-xs shadow-lg shadow-blue-500/20 transition"
|
||||
>
|
||||
ورود به گردونه
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Prizes List -->
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="item in prizes"
|
||||
:key="item.id"
|
||||
class="p-4 rounded-2xl bg-slate-900/80 border border-slate-800 shadow-sm relative overflow-hidden"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3 mb-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-12 h-12 rounded-xl bg-slate-800 border border-slate-700/60 flex items-center justify-center overflow-hidden flex-shrink-0">
|
||||
<img
|
||||
v-if="item.prize?.image"
|
||||
:src="item.prize.image"
|
||||
:alt="item.prize.title"
|
||||
class="w-full h-full object-contain p-1"
|
||||
/>
|
||||
<Gift v-else class="w-6 h-6 text-amber-400" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="text-sm font-bold text-white">
|
||||
{{ item.prize?.title }}
|
||||
</h4>
|
||||
<span class="text-[11px] text-slate-400 block mt-0.5">
|
||||
کمپین: {{ item.campaign?.title }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Badge -->
|
||||
<span
|
||||
class="text-[10px] font-semibold px-2 py-0.5 rounded-md border"
|
||||
:class="getStatusBadge(item.status).bg"
|
||||
>
|
||||
{{ getStatusBadge(item.status).text }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Digital Code Box -->
|
||||
<div
|
||||
v-if="item.code"
|
||||
class="mt-3 pt-3 border-t border-slate-800/80 flex items-center justify-between bg-slate-950/70 p-2.5 rounded-xl border border-slate-800"
|
||||
>
|
||||
<div>
|
||||
<span class="text-[10px] text-slate-400 block">کد دیجیتال:</span>
|
||||
<code class="font-mono text-xs font-bold text-amber-300 tracking-wider">
|
||||
{{ item.code }}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="copyCode(item.code, item.id)"
|
||||
class="text-xs px-2.5 py-1 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 flex items-center gap-1 transition"
|
||||
>
|
||||
<Check v-if="copiedId === item.id" class="w-3.5 h-3.5 text-emerald-400" />
|
||||
<Copy v-else class="w-3.5 h-3.5" />
|
||||
<span>{{ copiedId === item.id ? 'کپی شد' : 'کپی' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Footer Dates -->
|
||||
<div class="mt-3 flex items-center justify-between text-[10px] text-slate-500">
|
||||
<span class="flex items-center gap-1">
|
||||
<Clock class="w-3 h-3" />
|
||||
{{ toFarsiDigits(new Date(item.wonAt || item.createdAt).toLocaleDateString('fa-IR')) }}
|
||||
</span>
|
||||
<span v-if="item.expiresAt" class="text-rose-400/80">
|
||||
انقضا: {{ toFarsiDigits(new Date(item.expiresAt).toLocaleDateString('fa-IR')) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,43 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{vue,js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
},
|
||||
gold: {
|
||||
400: '#fbbf24',
|
||||
500: '#f59e0b',
|
||||
600: '#d97706',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['IRANSans', 'Vazirmatn', 'Tahoma', 'sans-serif'],
|
||||
},
|
||||
keyframes: {
|
||||
'pulse-glow': {
|
||||
'0%, 100%': { filter: 'drop-shadow(0 0 15px rgba(245, 158, 11, 0.6))' },
|
||||
'50%': { filter: 'drop-shadow(0 0 25px rgba(245, 158, 11, 0.9))' },
|
||||
},
|
||||
'neon-pulse': {
|
||||
'0%, 100%': { filter: 'drop-shadow(0 0 10px #00f0ff) drop-shadow(0 0 20px #ff007f)' },
|
||||
'50%': { filter: 'drop-shadow(0 0 20px #00f0ff) drop-shadow(0 0 35px #ff007f)' },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'pulse-glow': 'pulse-glow 2s ease-in-out infinite',
|
||||
'neon-pulse': 'neon-pulse 1.5s ease-in-out infinite',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import path from 'path';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
'@lucky-wheel/shared-types': path.resolve(__dirname, '../../packages/shared-types/src/index.ts'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5174,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/uploads': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user