first commit

This commit is contained in:
s.esmaeili
2026-09-10 01:58:29 +03:30
commit 077da7eacd
201 changed files with 25072 additions and 0 deletions
+33
View File
@@ -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.
+84
View File
@@ -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,
};
}
+43
View File
@@ -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);
}
+13
View File
@@ -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');
+33
View File
@@ -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,
});
+37
View File
@@ -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);
},
);
+108
View File
@@ -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,
};
});
+161
View File
@@ -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,
};
});
+324
View File
@@ -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>
+192
View File
@@ -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>