fix(core): resolve local client url redirects and stabilize dev environment

- enforce localhost:5174 for wheel campaign client URL in local development
- lock vite dev server ports using strictPort on admin (5173) and client (5174)
- sanitize .env.example and remove hardcoded staging domain
- configure root dev orchestration and docker multi-stage build context
This commit is contained in:
s.esmaeili
2026-09-13 02:02:46 +03:30
parent efb0051ab9
commit afde44ab8a
67 changed files with 5229 additions and 558 deletions
@@ -0,0 +1,83 @@
<script setup lang="ts">
import { toFarsiDigits, WheelDesignProps } from '@lucky-wheel/shared-types';
import { computed } from 'vue';
import { useWheelFromProps } from '../../composables/useWheelBase';
const props = defineProps<WheelDesignProps>();
const emit = defineEmits<{ (e: 'spin-finished'): void }>();
const { wheelStyle, pointerStyle, getSlicePath, getTextRotation, makePegs } = useWheelFromProps(
props,
() => emit('spin-finished'),
{ radius: 138, pegRadius: 142 },
);
const candyColors = ['#fb7185', '#fbbf24', '#34d399', '#60a5fa', '#c084fc', '#f472b6'];
const sprinkles = computed(() =>
makePegs(24, 142).map((peg, i) => ({
...peg,
color: candyColors[i % candyColors.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">
<div
class="absolute inset-0 rounded-full p-3.5 border-4 border-pink-300 shadow-[0_10px_0_#f9a8d4,0_16px_28px_rgba(244,114,182,0.35)]"
style="background: linear-gradient(180deg, #ffe4e6 0%, #fbcfe8 100%)"
>
<div class="w-full h-full rounded-full border-4 border-white relative overflow-hidden bg-rose-50">
<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 || candyColors[idx % candyColors.length]"
stroke="#ffffff"
stroke-width="3"
/>
<g :transform="getTextRotation(idx)">
<text
x="150"
y="45"
text-anchor="middle"
fill="#ffffff"
font-size="11"
font-weight="900"
class="wheel-slice-text font-sans"
style="paint-order: stroke fill; stroke: rgba(0,0,0,0.2); 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>
<g v-for="(peg, pIdx) in sprinkles" :key="pIdx">
<circle :cx="peg.x" :cy="peg.y" r="4.2" fill="#fff" stroke="#f9a8d4" stroke-width="1.2" />
<circle :cx="peg.x" :cy="peg.y" r="2.6" :fill="peg.color" />
</g>
<circle cx="150" cy="150" r="32" fill="#fb7185" stroke="#fff" stroke-width="4" />
<circle cx="150" cy="150" r="20" fill="#fef08a" stroke="#f9a8d4" stroke-width="2" />
<text x="150" y="157" text-anchor="middle" font-size="16">🍬</text>
</svg>
</div>
</div>
<div class="absolute -top-4 left-1/2 -translate-x-1/2 z-20 pointer-events-none drop-shadow-[0_5px_0_#db2777]">
<div :style="pointerStyle" class="w-10 h-[48px] origin-[50%_14px] will-change-transform">
<svg width="40" height="48" viewBox="0 0 40 48" fill="none">
<path
d="M20 48L6 16C4 11 8 4 14 4H26C32 4 36 11 34 16L20 48Z"
fill="#fb7185"
stroke="#fff"
stroke-width="2.5"
/>
<circle cx="20" cy="14" r="5.5" fill="#fef08a" stroke="#fff" stroke-width="2" />
</svg>
</div>
</div>
</div>
</template>
@@ -1,86 +1,25 @@
<script setup lang="ts">
import { toFarsiDigits, WheelDesignProps } from '@lucky-wheel/shared-types';
import { computed, watch } from 'vue';
import { useWheelRotation } from '../../composables/useWheelRotation';
import { useWheelFromProps } from '../../composables/useWheelBase';
const props = defineProps<WheelDesignProps>();
const emit = defineEmits<{
(e: 'spin-finished'): void;
}>();
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 { wheelStyle, pointerStyle, getSlicePath, getTextRotation, pegs: bulbs } = useWheelFromProps(
props,
() => emit('spin-finished'),
{ radius: 140, pegRadius: 144 },
);
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 -->
<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)"
@@ -88,7 +27,6 @@ const bulbs = computed(() => {
stroke="#fbbf24"
stroke-width="1.5"
/>
<!-- Slice Content: Title & Image -->
<g :transform="getTextRotation(idx)">
<text
x="150"
@@ -101,48 +39,16 @@ const bulbs = computed(() => {
>
{{ toFarsiDigits(seg.title) }}
</text>
<image
v-if="seg.image"
:href="seg.image"
x="138"
y="54"
width="24"
height="24"
/>
<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"
/>
<circle :cx="bulb.x" :cy="bulb.y" r="4.4" fill="#1c1917" stroke="#543d03" stroke-width="0.8" />
<circle :cx="bulb.x" :cy="bulb.y" r="3.5" fill="url(#casinoStud3D)" stroke="#fde047" stroke-width="0.5" />
<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>
@@ -153,7 +59,6 @@ const bulbs = computed(() => {
<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" />
@@ -165,33 +70,22 @@ const bulbs = computed(() => {
</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 -->
<svg width="40" height="46" viewBox="0 0 40 46" fill="none" class="overflow-visible">
<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>
@@ -0,0 +1,89 @@
<script setup lang="ts">
import { toFarsiDigits, WheelDesignProps } from '@lucky-wheel/shared-types';
import { useWheelFromProps } from '../../composables/useWheelBase';
const props = defineProps<WheelDesignProps>();
const emit = defineEmits<{ (e: 'spin-finished'): void }>();
const { wheelStyle, pointerStyle, getSlicePath, getTextRotation, pegs } = useWheelFromProps(
props,
() => emit('spin-finished'),
{ radius: 138, pegRadius: 143 },
);
const iceFallback = ['#0ea5e9', '#38bdf8', '#7dd3fc', '#0284c7', '#e0f2fe', '#0369a1'];
</script>
<template>
<div class="relative w-full max-w-[340px] xs:max-w-[380px] sm:max-w-[420px] aspect-square mx-auto select-none">
<div
class="absolute inset-0 rounded-full p-2.5 shadow-[0_0_30px_rgba(56,189,248,0.45)]"
style="background: linear-gradient(145deg, #e0f2fe, #38bdf8, #0284c7)"
>
<div class="w-full h-full rounded-full border-2 border-sky-100 relative overflow-hidden bg-[#082f49]/40 backdrop-blur-sm">
<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 || iceFallback[idx % iceFallback.length]"
stroke="#e0f2fe"
stroke-width="1.2"
opacity="0.92"
/>
<g :transform="getTextRotation(idx)">
<text
x="150"
y="45"
text-anchor="middle"
fill="#f0f9ff"
font-size="11"
font-weight="700"
class="wheel-slice-text font-sans"
>
{{ toFarsiDigits(seg.title) }}
</text>
<image v-if="seg.image" :href="seg.image" x="138" y="54" width="24" height="24" />
</g>
</g>
<!-- Crystal shards on rim -->
<g v-for="(peg, pIdx) in pegs" :key="pIdx">
<polygon
:points="`${peg.x},${peg.y - 4.5} ${peg.x + 2.4},${peg.y + 2.2} ${peg.x - 2.4},${peg.y + 2.2}`"
fill="#e0f2fe"
stroke="#7dd3fc"
stroke-width="0.6"
opacity="0.95"
/>
</g>
<circle cx="150" cy="150" r="30" fill="url(#iceCore)" stroke="#e0f2fe" stroke-width="2" />
<circle cx="150" cy="150" r="16" fill="#0369a1" stroke="#bae6fd" stroke-width="1.5" />
<text x="150" y="156" text-anchor="middle" font-size="14">❄️</text>
<defs>
<radialGradient id="iceCore" cx="40%" cy="35%" r="65%">
<stop offset="0%" stop-color="#e0f2fe" />
<stop offset="55%" stop-color="#38bdf8" />
<stop offset="100%" stop-color="#0369a1" />
</radialGradient>
</defs>
</svg>
</div>
</div>
<div class="absolute -top-3 left-1/2 -translate-x-1/2 z-20 pointer-events-none drop-shadow-[0_0_10px_#7dd3fc]">
<div :style="pointerStyle" class="w-8 h-[42px] origin-[50%_10px] will-change-transform">
<svg width="32" height="42" viewBox="0 0 32 42" fill="none">
<path
d="M16 42L4 12C2 7 6 2 11 2H21C26 2 30 7 28 12L16 42Z"
fill="#38bdf8"
stroke="#e0f2fe"
stroke-width="1.5"
/>
<circle cx="16" cy="10" r="4" fill="#e0f2fe" stroke="#0284c7" stroke-width="1" />
</svg>
</div>
</div>
</div>
</template>
@@ -0,0 +1,93 @@
<script setup lang="ts">
import { toFarsiDigits, WheelDesignProps } from '@lucky-wheel/shared-types';
import { useWheelFromProps } from '../../composables/useWheelBase';
const props = defineProps<WheelDesignProps>();
const emit = defineEmits<{ (e: 'spin-finished'): void }>();
const { wheelStyle, pointerStyle, getSlicePath, getTextRotation, pegs } = useWheelFromProps(
props,
() => emit('spin-finished'),
{ radius: 138, pegRadius: 143 },
);
const fireFallback = ['#7f1d1d', '#b91c1c', '#ea580c', '#c2410c', '#9a3412', '#450a0a'];
</script>
<template>
<div class="relative w-full max-w-[340px] xs:max-w-[380px] sm:max-w-[420px] aspect-square mx-auto select-none">
<div
class="absolute inset-0 rounded-full p-2.5 shadow-[0_0_35px_rgba(249,115,22,0.55)]"
style="background: linear-gradient(145deg, #fbbf24, #f97316, #dc2626, #7f1d1d)"
>
<div class="w-full h-full rounded-full border-2 border-orange-300 relative overflow-hidden bg-[#1a0505]">
<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 || fireFallback[idx % fireFallback.length]"
stroke="#fdba74"
stroke-width="1.2"
/>
<g :transform="getTextRotation(idx)">
<text
x="150"
y="45"
text-anchor="middle"
fill="#fff7ed"
font-size="11"
font-weight="800"
class="wheel-slice-text font-sans"
>
{{ toFarsiDigits(seg.title) }}
</text>
<image v-if="seg.image" :href="seg.image" x="138" y="54" width="24" height="24" />
</g>
</g>
<!-- Flame tips on rim -->
<g v-for="(peg, pIdx) in pegs" :key="pIdx">
<ellipse
:cx="peg.x"
:cy="peg.y"
rx="2.2"
ry="4.2"
fill="url(#flameTip)"
:transform="`rotate(${pIdx * 15} ${peg.x} ${peg.y})`"
/>
</g>
<circle cx="150" cy="150" r="30" fill="url(#emberCore)" stroke="#fdba74" stroke-width="2" />
<circle cx="150" cy="150" r="16" fill="#7f1d1d" stroke="#f97316" stroke-width="1.5" />
<text x="150" y="156" text-anchor="middle" font-size="14">🔥</text>
<defs>
<radialGradient id="flameTip" cx="50%" cy="20%" r="80%">
<stop offset="0%" stop-color="#fef08a" />
<stop offset="55%" stop-color="#f97316" />
<stop offset="100%" stop-color="#b91c1c" />
</radialGradient>
<radialGradient id="emberCore" cx="40%" cy="35%" r="65%">
<stop offset="0%" stop-color="#fdba74" />
<stop offset="100%" stop-color="#7f1d1d" />
</radialGradient>
</defs>
</svg>
</div>
</div>
<div class="absolute -top-3 left-1/2 -translate-x-1/2 z-20 pointer-events-none drop-shadow-[0_0_12px_#f97316]">
<div :style="pointerStyle" class="w-9 h-[44px] origin-[50%_12px] will-change-transform">
<svg width="36" height="44" viewBox="0 0 36 44" fill="none">
<path
d="M18 44L5 14C3 9 7 3 12 3H24C29 3 33 9 31 14L18 44Z"
fill="#f97316"
stroke="#fef08a"
stroke-width="1.5"
/>
<circle cx="18" cy="12" r="4.5" fill="#dc2626" stroke="#fef08a" stroke-width="1.2" />
</svg>
</div>
</div>
</div>
</template>
@@ -0,0 +1,99 @@
<script setup lang="ts">
import { toFarsiDigits, WheelDesignProps } from '@lucky-wheel/shared-types';
import { computed } from 'vue';
import { useWheelFromProps } from '../../composables/useWheelBase';
const props = defineProps<WheelDesignProps>();
const emit = defineEmits<{ (e: 'spin-finished'): void }>();
const { wheelStyle, pointerStyle, getSlicePath, getTextRotation, pegs } = useWheelFromProps(
props,
() => emit('spin-finished'),
{ radius: 138, pegRadius: 143 },
);
const galaxyFallback = ['#1e1b4b', '#312e81', '#4c1d95', '#0f172a', '#1e3a8a', '#581c87'];
const stars = computed(() =>
Array.from({ length: 28 }, (_, i) => {
const a = (i * 47) % 360;
const r = 40 + ((i * 13) % 90);
const rad = ((a - 90) * Math.PI) / 180;
return {
x: 150 + r * Math.cos(rad),
y: 150 + r * Math.sin(rad),
s: 0.6 + (i % 3) * 0.4,
};
}),
);
</script>
<template>
<div class="relative w-full max-w-[340px] xs:max-w-[380px] sm:max-w-[420px] aspect-square mx-auto select-none">
<div
class="absolute inset-0 rounded-full p-2.5 shadow-[0_0_40px_rgba(99,102,241,0.45)]"
style="background: linear-gradient(135deg, #6366f1, #a855f7, #0ea5e9)"
>
<div class="w-full h-full rounded-full border border-indigo-300/40 relative overflow-hidden bg-[#050816]">
<svg class="w-full h-full" :style="wheelStyle" viewBox="0 0 300 300">
<circle
v-for="(st, i) in stars"
:key="'st' + i"
:cx="st.x"
:cy="st.y"
:r="st.s"
fill="#e0e7ff"
opacity="0.7"
/>
<g v-for="(seg, idx) in segments" :key="seg.id">
<path
:d="getSlicePath(idx)"
:fill="seg.color || galaxyFallback[idx % galaxyFallback.length]"
stroke="#a5b4fc"
stroke-width="1"
stroke-opacity="0.6"
/>
<g :transform="getTextRotation(idx)">
<text
x="150"
y="45"
text-anchor="middle"
fill="#eef2ff"
font-size="11"
font-weight="700"
class="wheel-slice-text font-sans"
>
{{ toFarsiDigits(seg.title) }}
</text>
<image v-if="seg.image" :href="seg.image" x="138" y="54" width="24" height="24" />
</g>
</g>
<g v-for="(peg, pIdx) in pegs" :key="pIdx">
<circle :cx="peg.x" :cy="peg.y" r="3" fill="#0f172a" stroke="#c4b5fd" stroke-width="1" />
<circle :cx="peg.x" :cy="peg.y" r="1.4" fill="#a78bfa" />
</g>
<circle cx="150" cy="150" r="30" fill="#0b1026" stroke="#818cf8" stroke-width="2" />
<circle cx="150" cy="150" r="18" fill="#312e81" stroke="#e879f9" stroke-width="1.5" />
<text x="150" y="156" text-anchor="middle" font-size="14">🌌</text>
</svg>
</div>
</div>
<div class="absolute -top-3 left-1/2 -translate-x-1/2 z-20 pointer-events-none drop-shadow-[0_0_10px_#a78bfa]">
<div :style="pointerStyle" class="w-8 h-[42px] origin-[50%_10px] will-change-transform">
<svg width="32" height="42" viewBox="0 0 32 42" fill="none">
<path
d="M16 42L4 12C2 7 6 2 11 2H21C26 2 30 7 28 12L16 42Z"
fill="#a78bfa"
stroke="#fff"
stroke-width="1.4"
/>
<circle cx="16" cy="10" r="4" fill="#e879f9" />
</svg>
</div>
</div>
</div>
</template>
@@ -1,82 +1,25 @@
<script setup lang="ts">
import { toFarsiDigits, WheelDesignProps } from '@lucky-wheel/shared-types';
import { computed, watch } from 'vue';
import { useWheelRotation } from '../../composables/useWheelRotation';
import { useWheelFromProps } from '../../composables/useWheelBase';
const props = defineProps<WheelDesignProps>();
const emit = defineEmits<{
(e: 'spin-finished'): void;
}>();
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 { wheelStyle, pointerStyle, getSlicePath, getTextRotation, pegs } = useWheelFromProps(
props,
() => emit('spin-finished'),
{ radius: 138, pegRadius: 143 },
);
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 -->
<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)"
@@ -96,25 +39,16 @@ const pegs = computed(() => {
>
{{ toFarsiDigits(seg.title) }}
</text>
<image
v-if="seg.image"
:href="seg.image"
x="138"
y="54"
width="24"
height="24"
/>
<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>
@@ -136,16 +70,9 @@ const pegs = computed(() => {
</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"
>
<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)"
@@ -1,80 +1,22 @@
<script setup lang="ts">
import { toFarsiDigits, WheelDesignProps } from '@lucky-wheel/shared-types';
import { computed, watch } from 'vue';
import { useWheelRotation } from '../../composables/useWheelRotation';
import { useWheelFromProps } from '../../composables/useWheelBase';
const props = defineProps<WheelDesignProps>();
const emit = defineEmits<{
(e: 'spin-finished'): void;
}>();
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 { wheelStyle, pointerStyle, getSlicePath, getTextRotation, pegs: minimalPegs } = useWheelFromProps(
props,
() => emit('spin-finished'),
{ radius: 142, pegRadius: 144 },
);
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="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"
>
<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)"
@@ -94,18 +36,10 @@ const minimalPegs = computed(() => {
>
{{ toFarsiDigits(seg.title) }}
</text>
<image
v-if="seg.image"
:href="seg.image"
x="138"
y="54"
width="24"
height="24"
/>
<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"
@@ -116,14 +50,12 @@ const minimalPegs = computed(() => {
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">
@@ -1,81 +1,24 @@
<script setup lang="ts">
import { toFarsiDigits, WheelDesignProps } from '@lucky-wheel/shared-types';
import { computed, watch } from 'vue';
import { useWheelRotation } from '../../composables/useWheelRotation';
import { useWheelFromProps } from '../../composables/useWheelBase';
const props = defineProps<WheelDesignProps>();
const emit = defineEmits<{
(e: 'spin-finished'): void;
}>();
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 { wheelStyle, pointerStyle, getSlicePath, getTextRotation, pegs: cyberPegs } = useWheelFromProps(
props,
() => emit('spin-finished'),
{ radius: 138, pegRadius: 143 },
);
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 -->
<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)"
@@ -96,24 +39,15 @@ const cyberPegs = computed(() => {
>
{{ toFarsiDigits(seg.title) }}
</text>
<image
v-if="seg.image"
:href="seg.image"
x="138"
y="54"
width="24"
height="24"
/>
<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" />
@@ -122,7 +56,6 @@ const cyberPegs = computed(() => {
</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">
@@ -1,83 +1,34 @@
<script setup lang="ts">
import { toFarsiDigits, WheelDesignProps } from '@lucky-wheel/shared-types';
import { computed, watch } from 'vue';
import { useWheelRotation } from '../../composables/useWheelRotation';
import { computed } from 'vue';
import { useWheelFromProps } from '../../composables/useWheelBase';
const props = defineProps<WheelDesignProps>();
const emit = defineEmits<{
(e: 'spin-finished'): void;
}>();
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 { wheelStyle, pointerStyle, getSlicePath, getTextRotation, makePegs } = useWheelFromProps(
props,
() => emit('spin-finished'),
{ radius: 138, pegRadius: 142 },
);
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],
};
});
});
const candyPegs = computed(() =>
makePegs(24, 142).map((peg, i) => ({
...peg,
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"
>
<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)"
@@ -98,25 +49,16 @@ const candyPegs = computed(() => {
>
{{ toFarsiDigits(seg.title) }}
</text>
<image
v-if="seg.image"
:href="seg.image"
x="138"
y="54"
width="24"
height="24"
/>
<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>
@@ -124,7 +66,6 @@ const candyPegs = computed(() => {
</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">
@@ -0,0 +1,103 @@
<script setup lang="ts">
import { toFarsiDigits, WheelDesignProps } from '@lucky-wheel/shared-types';
import { useWheelFromProps } from '../../composables/useWheelBase';
const props = defineProps<WheelDesignProps>();
const emit = defineEmits<{ (e: 'spin-finished'): void }>();
const { wheelStyle, pointerStyle, getSlicePath, getTextRotation, pegs } = useWheelFromProps(
props,
() => emit('spin-finished'),
{ radius: 138, pegRadius: 143 },
);
const woodFallback = ['#8B5A2B', '#A0522D', '#6B4226', '#C4A484', '#8B5A2B', '#5D4037'];
</script>
<template>
<div class="relative w-full max-w-[340px] xs:max-w-[380px] sm:max-w-[420px] aspect-square mx-auto select-none">
<!-- Rustic wooden bezel -->
<div
class="absolute inset-0 rounded-full p-3 shadow-[0_12px_30px_rgba(60,30,10,0.55)]"
style="background: linear-gradient(145deg, #c9a36a 0%, #6b3f1e 40%, #3e2412 100%)"
>
<div
class="w-full h-full rounded-full border-[3px] border-[#c9a36a] relative overflow-hidden"
style="background: radial-gradient(circle at 40% 35%, #5c3a1e 0%, #2a160c 70%)"
>
<svg class="w-full h-full" :style="wheelStyle" viewBox="0 0 300 300">
<!-- Wood grain rings -->
<circle cx="150" cy="150" r="136" fill="none" stroke="#4a2c14" stroke-width="8" opacity="0.35" />
<circle cx="150" cy="150" r="120" fill="none" stroke="#7a4a22" stroke-width="2" opacity="0.25" />
<g v-for="(seg, idx) in segments" :key="seg.id">
<path
:d="getSlicePath(idx)"
:fill="seg.color || woodFallback[idx % woodFallback.length]"
stroke="#3e2723"
stroke-width="1.5"
/>
<g :transform="getTextRotation(idx)">
<text
x="150"
y="45"
text-anchor="middle"
fill="#fff8e7"
font-size="11"
font-weight="700"
class="wheel-slice-text font-sans"
>
{{ toFarsiDigits(seg.title) }}
</text>
<image v-if="seg.image" :href="seg.image" x="138" y="54" width="24" height="24" />
</g>
</g>
<!-- Metal bolts -->
<g v-for="(peg, pIdx) in pegs" :key="pIdx">
<circle :cx="peg.x" :cy="peg.y" r="4" fill="#2c1810" stroke="#8d6e63" stroke-width="0.8" />
<circle :cx="peg.x" :cy="peg.y" r="2.6" fill="url(#woodBolt)" />
<line
:x1="peg.x - 1.6"
:y1="peg.y"
:x2="peg.x + 1.6"
:y2="peg.y"
stroke="#5d4037"
stroke-width="0.7"
/>
</g>
<circle cx="150" cy="150" r="30" fill="url(#woodHub)" stroke="#d7ccc8" stroke-width="2" />
<circle cx="150" cy="150" r="18" fill="#3e2723" stroke="#a1887f" stroke-width="1.5" />
<text x="150" y="156" text-anchor="middle" font-size="14" fill="#efebe9">🪵</text>
<defs>
<radialGradient id="woodBolt" cx="35%" cy="30%" r="70%">
<stop offset="0%" stop-color="#efebe9" />
<stop offset="50%" stop-color="#9e9e9e" />
<stop offset="100%" stop-color="#424242" />
</radialGradient>
<radialGradient id="woodHub" cx="40%" cy="35%" r="65%">
<stop offset="0%" stop-color="#bcaaa4" />
<stop offset="100%" stop-color="#5d4037" />
</radialGradient>
</defs>
</svg>
</div>
</div>
<div class="absolute -top-3 left-1/2 -translate-x-1/2 z-20 pointer-events-none drop-shadow-lg">
<div :style="pointerStyle" class="w-9 h-[44px] origin-[50%_12px] will-change-transform">
<svg width="36" height="44" viewBox="0 0 36 44" fill="none">
<path
d="M18 44L5 14C3 9 7 3 12 3H24C29 3 33 9 31 14L18 44Z"
fill="#8d6e63"
stroke="#efebe9"
stroke-width="1.5"
/>
<circle cx="18" cy="12" r="4.5" fill="#5d4037" stroke="#d7ccc8" stroke-width="1.2" />
</svg>
</div>
</div>
</div>
</template>
+112
View File
@@ -0,0 +1,112 @@
import type { WheelDesignProps, WheelSegment } from '@lucky-wheel/shared-types';
import { computed, watch, type Ref } from 'vue';
import { useWheelRotation } from './useWheelRotation';
export interface UseWheelBaseOptions {
segments: Ref<WheelSegment[]> | (() => WheelSegment[]);
spinning: Ref<boolean> | (() => boolean);
targetSegmentIndex: Ref<number | null> | (() => number | null);
onFinished: () => void;
/** Slice radius inside viewBox 0..300 (default 140) */
radius?: number;
/** Peg / bulb radius from center (default 144) */
pegRadius?: number;
pegCount?: number;
}
function unwrap<T>(source: Ref<T> | (() => T)): () => T {
return typeof source === 'function' ? (source as () => T) : () => (source as Ref<T>).value;
}
/**
* Shared geometry + spin wiring for all wheel skins.
*/
export function useWheelBase(options: UseWheelBaseOptions) {
const getSegments = unwrap(options.segments);
const getSpinning = unwrap(options.spinning);
const getTarget = unwrap(options.targetSegmentIndex);
const radius = options.radius ?? 140;
const pegRadius = options.pegRadius ?? 144;
const pegCount = options.pegCount ?? 24;
const { wheelStyle, pointerStyle, isSpinning, spinTo } = useWheelRotation({
segmentCount: () => getSegments().length,
pegCount: () => pegCount,
onFinished: options.onFinished,
});
watch(
[getSpinning, getTarget],
([newSpinning, newTargetIdx]) => {
if (newSpinning && newTargetIdx !== null && newTargetIdx !== undefined && !isSpinning.value) {
spinTo(newTargetIdx as number);
}
},
{ flush: 'post' },
);
const totalSegments = computed(() => getSegments().length);
const sliceAngle = computed(() => (totalSegments.value > 0 ? 360 / totalSegments.value : 0));
function getSlicePath(index: number): string {
if (totalSegments.value <= 1) return '';
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 + radius * Math.cos(startAngle);
const y1 = cy + radius * Math.sin(startAngle);
const x2 = cx + radius * Math.cos(endAngle);
const y2 = cy + radius * Math.sin(endAngle);
const largeArc = sliceAngle.value > 180 ? 1 : 0;
return `M ${cx} ${cy} L ${x1} ${y1} A ${radius} ${radius} 0 ${largeArc} 1 ${x2} ${y2} Z`;
}
function getTextRotation(index: number): string {
const midAngle = index * sliceAngle.value + sliceAngle.value / 2;
return `rotate(${midAngle} 150 150)`;
}
function makePegs(count = pegCount, r = pegRadius) {
return Array.from({ length: count }, (_, i) => {
const angle = (i * (360 / count) - 90) * (Math.PI / 180);
return {
x: 150 + r * Math.cos(angle),
y: 150 + r * Math.sin(angle),
index: i,
};
});
}
const pegs = computed(() => makePegs());
return {
wheelStyle,
pointerStyle,
isSpinning,
spinTo,
totalSegments,
sliceAngle,
getSlicePath,
getTextRotation,
pegs,
makePegs,
};
}
/** Convenience helper when using defineProps<WheelDesignProps>() */
export function useWheelFromProps(
props: WheelDesignProps,
emitFinished: () => void,
opts?: { radius?: number; pegRadius?: number; pegCount?: number },
) {
return useWheelBase({
segments: () => props.segments,
spinning: () => props.spinning,
targetSegmentIndex: () => props.targetSegmentIndex,
onFinished: emitFinished,
...opts,
});
}
+25 -8
View File
@@ -1,29 +1,46 @@
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router';
const defaultCampaignSlug = (import.meta.env.VITE_DEFAULT_CAMPAIGN_SLUG as string | undefined)?.trim();
const routes: RouteRecordRaw[] = [
{
path: '/my-prizes',
name: 'my-prizes',
component: () => import('../views/MyPrizesView.vue'),
},
// Legacy URLs: /c/slug → /slug
{
path: '/c/:slug',
name: 'campaign-wheel-slug',
component: () => import('../views/CampaignWheelView.vue'),
redirect: (to) => `/${String(to.params.slug)}`,
},
{
path: '/c',
redirect: '/',
},
...(defaultCampaignSlug
? [
{
path: '/',
name: 'home',
redirect: `/${defaultCampaignSlug}`,
} as RouteRecordRaw,
]
: [
{
path: '/',
name: 'home',
component: () => import('../views/HomeView.vue'),
} as RouteRecordRaw,
]),
{
path: '/:slug',
name: 'campaign-wheel',
component: () => import('../views/CampaignWheelView.vue'),
},
{
path: '/',
name: 'home',
redirect: '/c/default-campaign',
},
{
path: '/:pathMatch(.*)*',
redirect: '/',
name: 'not-found',
component: () => import('../views/NotFoundView.vue'),
},
];
+32 -12
View File
@@ -12,15 +12,10 @@ import {
Volume2,
VolumeX,
} from 'lucide-vue-next';
import { computed, onMounted, ref } from 'vue';
import { computed, defineAsyncComponent, 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';
@@ -40,16 +35,41 @@ 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,
[WheelDesign.CLASSIC_CASINO]: defineAsyncComponent(
() => import('../components/wheels/ClassicCasinoWheel.vue'),
),
[WheelDesign.NEON_CYBER]: defineAsyncComponent(
() => import('../components/wheels/NeonCyberWheel.vue'),
),
[WheelDesign.MINIMAL_FLAT]: defineAsyncComponent(
() => import('../components/wheels/MinimalFlatWheel.vue'),
),
[WheelDesign.LUXURY_GOLD]: defineAsyncComponent(
() => import('../components/wheels/LuxuryGoldWheel.vue'),
),
[WheelDesign.PLAYFUL_CARTOON]: defineAsyncComponent(
() => import('../components/wheels/PlayfulCartoonWheel.vue'),
),
[WheelDesign.RUSTIC_WOOD]: defineAsyncComponent(
() => import('../components/wheels/RusticWoodWheel.vue'),
),
[WheelDesign.GALAXY_SPACE]: defineAsyncComponent(
() => import('../components/wheels/GalaxySpaceWheel.vue'),
),
[WheelDesign.CANDY_LAND]: defineAsyncComponent(
() => import('../components/wheels/CandyLandWheel.vue'),
),
[WheelDesign.FIRE_FLAME]: defineAsyncComponent(
() => import('../components/wheels/FireFlameWheel.vue'),
),
[WheelDesign.CRYSTAL_ICE]: defineAsyncComponent(
() => import('../components/wheels/CrystalIceWheel.vue'),
),
};
const activeWheelComponent = computed(() => {
const design = campaign.value?.wheelDesign || WheelDesign.CLASSIC_CASINO;
return wheelComponents[design] || ClassicCasinoWheel;
return wheelComponents[design] || wheelComponents[WheelDesign.CLASSIC_CASINO];
});
async function loadCampaign() {
+24
View File
@@ -0,0 +1,24 @@
<script setup lang="ts">
import { Sparkles } from 'lucide-vue-next';
</script>
<template>
<div class="min-h-screen flex flex-col items-center justify-center px-6 py-12 text-center">
<div class="w-20 h-20 rounded-3xl bg-gradient-to-br from-amber-500 to-orange-600 flex items-center justify-center text-4xl shadow-lg shadow-amber-500/20 mb-6">
🎡
</div>
<h1 class="text-2xl font-extrabold text-white mb-3">گردونه شانس</h1>
<p class="text-slate-400 text-sm max-w-sm leading-7 mb-2">
برای شرکت در قرعه‌کشی، لینک اختصاصی کمپین خود را از برگزارکننده دریافت کنید.
</p>
<p class="text-slate-500 text-xs max-w-xs">
آدرس معمولاً به شکل
<code dir="ltr" class="text-amber-400/90 bg-slate-800 px-1.5 py-0.5 rounded">/نام-کمپین</code>
است.
</p>
<div class="mt-8 inline-flex items-center gap-2 text-amber-400/80 text-xs font-medium">
<Sparkles class="w-4 h-4" />
منتظر لینک کمپین باشید
</div>
</div>
</template>
+20
View File
@@ -0,0 +1,20 @@
<script setup lang="ts">
import { useRouter } from 'vue-router';
const router = useRouter();
</script>
<template>
<div class="min-h-screen flex flex-col items-center justify-center px-6 py-12 text-center">
<div class="text-6xl font-black text-slate-700 mb-4">۴۰۴</div>
<h1 class="text-xl font-bold text-white mb-2">صفحه پیدا نشد</h1>
<p class="text-slate-400 text-sm mb-8">آدرس وارد شده معتبر نیست.</p>
<button
type="button"
class="px-6 py-3 rounded-xl bg-slate-800 border border-slate-700 text-slate-200 text-sm font-semibold"
@click="router.push('/')"
>
بازگشت به صفحه اصلی
</button>
</div>
</template>
+10
View File
@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_DEFAULT_CAMPAIGN_SLUG?: string;
readonly VITE_CLIENT_PUBLIC_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}