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:
@@ -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>
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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'),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Vendored
+10
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user