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
+4
View File
@@ -0,0 +1,4 @@
# Docker Compose build arg for admin "view wheel" links.
# On production server, override with your real client domain before building admin:
# VITE_CLIENT_PUBLIC_URL=https://wheel.example.com
VITE_CLIENT_PUBLIC_URL=http://localhost:5174
+2
View File
@@ -8,3 +8,5 @@ uploads/
coverage/
.DS_Store
Thumbs.db
package-lock.json
*.ps1
+1
View File
@@ -0,0 +1 @@
require-build-scripts-approval=false
+2
View File
@@ -0,0 +1,2 @@
# Public client app URL for "view wheel" links in admin panel
VITE_CLIENT_PUBLIC_URL=http://localhost:5174
+6 -11
View File
@@ -8,23 +8,18 @@ WORKDIR /app
COPY . .
ARG VITE_CLIENT_PUBLIC_URL=
ENV VITE_CLIENT_PUBLIC_URL=$VITE_CLIENT_PUBLIC_URL
RUN pnpm install --frozen-lockfile
RUN pnpm --filter @lucky-wheel/api exec prisma generate
RUN pnpm run build:types
RUN pnpm --filter @lucky-wheel/admin build
FROM nginx:alpine
COPY --from=builder /app/apps/admin/dist /usr/share/nginx/html
# Configure Nginx for Single Page Application
RUN echo 'server { \
listen 80; \
server_name localhost; \
location / { \
root /usr/share/nginx/html; \
index index.html index.htm; \
try_files $uri $uri/ /index.html; \
} \
}' > /etc/nginx/conf.d/default.conf
COPY apps/admin/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
+14 -2
View File
@@ -3,7 +3,12 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=1.0, user-scalable=no" />
<meta name="theme-color" content="#4f46e5" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="گردونه شانس" />
<meta name="mobile-web-app-capable" content="yes" />
<title>پنل مدیریت گردونه شانس و قرعه‌کشی</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
@@ -11,11 +16,18 @@
<style>
* {
font-family: 'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
-webkit-tap-highlight-color: transparent;
}
body {
html, body {
margin: 0;
padding: 0;
background-color: #f0f2f5;
direction: rtl;
overscroll-behavior: none;
}
#app {
min-height: 100%;
min-height: 100dvh;
}
</style>
</head>
+30
View File
@@ -0,0 +1,30 @@
server {
listen 80;
server_name localhost;
location /api/ {
proxy_pass http://api:3000/api/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /uploads/ {
proxy_pass http://api:3000/uploads/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
}
@@ -0,0 +1,341 @@
<script setup lang="ts">
import {
AuditOutlined,
DashboardOutlined,
LineChartOutlined,
LogoutOutlined,
MessageOutlined,
MoreOutlined,
PieChartOutlined,
ReloadOutlined,
TrophyOutlined,
UserOutlined,
} from '@ant-design/icons-vue';
import { computed, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useAuthStore } from '../../stores/auth.store';
const route = useRoute();
const router = useRouter();
const authStore = useAuthStore();
const moreOpen = ref(false);
const tabs = [
{ key: 'm-dashboard', label: 'داشبورد', icon: DashboardOutlined, path: '/m/dashboard' },
{ key: 'm-campaigns', label: 'کمپین‌ها', icon: PieChartOutlined, path: '/m/campaigns' },
{ key: 'm-users', label: 'کاربران', icon: UserOutlined, path: '/m/users' },
{ key: 'm-winners', label: 'برندگان', icon: TrophyOutlined, path: '/m/winners' },
];
const moreItems = [
{ key: 'm-spins', label: 'تاریخچه اسپین', icon: ReloadOutlined, path: '/m/spins' },
{ key: 'm-sms', label: 'لاگ پیامک', icon: MessageOutlined, path: '/m/sms' },
{ key: 'm-reports', label: 'گزارش‌ها', icon: LineChartOutlined, path: '/m/reports' },
{ key: 'm-audit', label: 'لاگ بازرسی', icon: AuditOutlined, path: '/m/audit' },
];
const activeKey = computed(() => {
const name = String(route.name || '');
if (tabs.some((t) => t.key === name || name.startsWith(t.key))) {
if (name.startsWith('m-campaign')) return 'm-campaigns';
if (name.startsWith('m-user')) return 'm-users';
return name;
}
if (moreItems.some((t) => t.key === name)) return 'more';
return name;
});
const moreActive = computed(() => moreItems.some((i) => i.key === String(route.name)));
function go(path: string) {
moreOpen.value = false;
if (route.path !== path) router.push(path);
}
function openMore() {
moreOpen.value = true;
}
async function handleLogout() {
moreOpen.value = false;
await authStore.logout();
router.push('/m/login');
}
</script>
<template>
<nav class="m-bottom-nav" aria-label="ناوبری اصلی">
<div class="m-bottom-nav__inner">
<button
v-for="tab in tabs"
:key="tab.key"
type="button"
class="m-bottom-nav__item"
:class="{ 'is-active': activeKey === tab.key }"
@click="go(tab.path)"
>
<span class="m-bottom-nav__icon-wrap">
<component :is="tab.icon" class="m-bottom-nav__icon" />
</span>
<span class="m-bottom-nav__label">{{ tab.label }}</span>
</button>
<button
type="button"
class="m-bottom-nav__item"
:class="{ 'is-active': moreActive || moreOpen }"
@click="openMore"
>
<span class="m-bottom-nav__icon-wrap">
<MoreOutlined class="m-bottom-nav__icon" />
</span>
<span class="m-bottom-nav__label">بیشتر</span>
</button>
</div>
<div class="m-bottom-nav__safe" />
</nav>
<a-drawer
v-model:open="moreOpen"
placement="bottom"
:height="'auto'"
:closable="false"
class="m-more-drawer"
:body-style="{ padding: '12px 16px 24px' }"
>
<div class="m-more-handle" />
<div class="m-more-title">منوی بیشتر</div>
<div class="m-more-grid">
<button
v-for="item in moreItems"
:key="item.key"
type="button"
class="m-more-item"
:class="{ 'is-active': String(route.name) === item.key }"
@click="go(item.path)"
>
<span class="m-more-item__icon">
<component :is="item.icon" />
</span>
<span class="m-more-item__label">{{ item.label }}</span>
</button>
</div>
<div class="m-more-profile" v-if="authStore.admin">
<div class="m-more-profile__info">
<div class="m-more-profile__avatar">
{{ (authStore.admin.name || 'ا').charAt(0) }}
</div>
<div>
<div class="m-more-profile__name">{{ authStore.admin.name }}</div>
<div class="m-more-profile__email">{{ authStore.admin.email }}</div>
</div>
</div>
<button type="button" class="m-more-logout" @click="handleLogout">
<LogoutOutlined />
خروج
</button>
</div>
</a-drawer>
</template>
<style scoped>
.m-bottom-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 110;
background: rgba(255, 255, 255, 0.96);
backdrop-filter: blur(16px);
border-top: 1px solid #e2e8f0;
box-shadow: 0 -8px 24px rgba(15, 23, 42, 0.06);
}
.m-bottom-nav__inner {
height: 64px;
display: flex;
align-items: stretch;
justify-content: space-around;
padding: 0 4px;
}
.m-bottom-nav__safe {
height: env(safe-area-inset-bottom, 0px);
}
.m-bottom-nav__item {
flex: 1;
border: none;
background: transparent;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
color: #94a3b8;
cursor: pointer;
min-width: 0;
padding: 6px 2px;
transition: color 0.2s ease;
}
.m-bottom-nav__icon-wrap {
width: 36px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 14px;
transition: all 0.22s ease;
}
.m-bottom-nav__icon {
font-size: 20px;
}
.m-bottom-nav__label {
font-size: 11px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.m-bottom-nav__item.is-active {
color: #4f46e5;
}
.m-bottom-nav__item.is-active .m-bottom-nav__icon-wrap {
background: #eef2ff;
transform: translateY(-2px) scale(1.05);
}
.m-bottom-nav__item:active .m-bottom-nav__icon-wrap {
transform: scale(0.92);
}
.m-more-handle {
width: 40px;
height: 4px;
border-radius: 4px;
background: #cbd5e1;
margin: 0 auto 16px;
}
.m-more-title {
font-size: 15px;
font-weight: 700;
color: #0f172a;
margin-bottom: 14px;
text-align: center;
}
.m-more-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.m-more-item {
border: 1px solid #e2e8f0;
background: #f8fafc;
border-radius: 14px;
padding: 14px 12px;
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
text-align: right;
}
.m-more-item.is-active {
border-color: #c7d2fe;
background: #eef2ff;
color: #4f46e5;
}
.m-more-item__icon {
width: 40px;
height: 40px;
border-radius: 12px;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
color: #4f46e5;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06);
}
.m-more-item__label {
font-size: 13px;
font-weight: 600;
color: #1e293b;
}
.m-more-profile {
margin-top: 18px;
padding-top: 16px;
border-top: 1px solid #e2e8f0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.m-more-profile__info {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.m-more-profile__avatar {
width: 40px;
height: 40px;
border-radius: 12px;
background: linear-gradient(135deg, #4f46e5, #7c3aed);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
flex-shrink: 0;
}
.m-more-profile__name {
font-size: 13px;
font-weight: 700;
color: #0f172a;
}
.m-more-profile__email {
font-size: 11px;
color: #64748b;
direction: ltr;
text-align: right;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 160px;
}
.m-more-logout {
border: none;
background: #fef2f2;
color: #ef4444;
border-radius: 12px;
padding: 10px 12px;
font-size: 12px;
font-weight: 700;
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
flex-shrink: 0;
}
</style>
@@ -0,0 +1,154 @@
<script setup lang="ts">
import { ArrowRightOutlined } from '@ant-design/icons-vue';
import { computed } from 'vue';
import { useRouter } from 'vue-router';
const props = withDefaults(
defineProps<{
title?: string;
showBack?: boolean;
transparent?: boolean;
}>(),
{
title: '',
showBack: false,
transparent: false,
},
);
const emit = defineEmits<{
back: [];
}>();
const router = useRouter();
const canGoBack = computed(() => props.showBack);
function handleBack() {
emit('back');
if (window.history.length > 1) {
router.back();
} else {
router.push('/m/dashboard');
}
}
</script>
<template>
<header class="m-topbar" :class="{ 'm-topbar--transparent': transparent }">
<div class="m-topbar__safe" />
<div class="m-topbar__inner">
<div class="m-topbar__side m-topbar__side--start">
<button
v-if="canGoBack"
type="button"
class="m-topbar__btn"
aria-label="بازگشت"
@click="handleBack"
>
<ArrowRightOutlined />
</button>
<slot name="start" />
</div>
<h1 class="m-topbar__title">{{ title }}</h1>
<div class="m-topbar__side m-topbar__side--end">
<slot name="actions" />
</div>
</div>
</header>
</template>
<style scoped>
.m-topbar {
position: sticky;
top: 0;
z-index: 100;
background: linear-gradient(135deg, #4f46e5 0%, #6366f1 55%, #7c3aed 100%);
color: #fff;
box-shadow: 0 4px 20px rgba(79, 70, 229, 0.28);
}
.m-topbar--transparent {
background: transparent;
box-shadow: none;
}
.m-topbar__safe {
height: env(safe-area-inset-top, 0px);
}
.m-topbar__inner {
height: 56px;
display: grid;
grid-template-columns: 72px 1fr 72px;
align-items: center;
padding: 0 8px;
}
.m-topbar__title {
margin: 0;
text-align: center;
font-size: 16px;
font-weight: 700;
letter-spacing: -0.01em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: #fff;
}
.m-topbar__side {
display: flex;
align-items: center;
gap: 4px;
min-height: 48px;
}
.m-topbar__side--start {
justify-content: flex-start;
}
.m-topbar__side--end {
justify-content: flex-end;
}
.m-topbar__btn {
width: 44px;
height: 44px;
border: none;
border-radius: 12px;
background: rgba(255, 255, 255, 0.14);
color: #fff;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 16px;
cursor: pointer;
}
.m-topbar__btn:active {
background: rgba(255, 255, 255, 0.28);
transform: scale(0.96);
}
:deep(.m-topbar-action) {
width: 44px;
height: 44px;
border: none;
border-radius: 12px;
background: rgba(255, 255, 255, 0.14);
color: #fff;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 16px;
cursor: pointer;
}
:deep(.m-topbar-action:active) {
background: rgba(255, 255, 255, 0.28);
transform: scale(0.96);
}
</style>
+126
View File
@@ -0,0 +1,126 @@
import { onMounted, ref } from 'vue';
import type { Router } from 'vue-router';
const MOBILE_UA =
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile|Tablet|tablet/i;
/** Include tablets (iPad / 768–1024) in mobile app experience */
const MOBILE_BREAKPOINT = 1024;
function detectMobile(): boolean {
if (typeof window === 'undefined') return false;
const byUa = MOBILE_UA.test(navigator.userAgent);
const byWidth = window.innerWidth <= MOBILE_BREAKPOINT;
// Prefer width so Chrome device toolbar always switches even if UA is desktop
return byWidth || byUa;
}
const isMobile = ref(detectMobile());
let listenersAttached = false;
let boundRouter: Router | null = null;
const desktopToMobileRoute: Record<string, string> = {
login: 'm-login',
dashboard: 'm-dashboard',
campaigns: 'm-campaigns',
'campaign-create': 'm-campaign-create',
'campaign-edit': 'm-campaign-edit',
'campaign-prizes': 'm-campaign-prizes',
users: 'm-users',
'user-detail': 'm-user-detail',
spins: 'm-spins',
winners: 'm-winners',
sms: 'm-sms',
reports: 'm-reports',
audit: 'm-audit',
};
const mobileToDesktopRoute: Record<string, string> = Object.fromEntries(
Object.entries(desktopToMobileRoute).map(([d, m]) => [m, d]),
);
export { desktopToMobileRoute, mobileToDesktopRoute };
function syncRouteToDevice() {
if (!boundRouter) return;
const mobile = detectMobile();
isMobile.value = mobile;
const route = boundRouter.currentRoute.value;
const isMobileRoute = route.path.startsWith('/m') || !!route.meta.mobile;
const name = String(route.name || '');
if (mobile && !isMobileRoute) {
if (name === 'login') {
boundRouter.replace({ name: 'm-login', query: route.query });
return;
}
const mapped = desktopToMobileRoute[name];
if (mapped) {
boundRouter.replace({ name: mapped, params: route.params, query: route.query });
} else {
boundRouter.replace('/m/dashboard');
}
return;
}
if (!mobile && isMobileRoute) {
if (name === 'm-login') {
boundRouter.replace({ name: 'login', query: route.query });
return;
}
const mapped = mobileToDesktopRoute[name];
if (mapped) {
boundRouter.replace({ name: mapped, params: route.params, query: route.query });
} else {
boundRouter.replace('/dashboard');
}
}
}
function onResize() {
const next = detectMobile();
if (next === isMobile.value) return;
isMobile.value = next;
syncRouteToDevice();
}
function ensureListeners() {
if (listenersAttached || typeof window === 'undefined') return;
window.addEventListener('resize', onResize);
// Chrome device-mode sometimes fires orientationchange without a reliable resize
window.addEventListener('orientationchange', onResize);
listenersAttached = true;
}
/**
* Call once from main.ts so resize/device-toolbar switches layouts live.
*/
export function bindDeviceRouter(router: Router) {
boundRouter = router;
isMobile.value = detectMobile();
ensureListeners();
// Initial sync after first navigation settles
router.isReady().then(() => syncRouteToDevice());
}
export function useDevice() {
onMounted(() => {
isMobile.value = detectMobile();
ensureListeners();
});
if (typeof window !== 'undefined') {
ensureListeners();
}
return {
isMobile,
};
}
export function checkIsMobile(): boolean {
return detectMobile();
}
+134
View File
@@ -0,0 +1,134 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import MobileBottomNav from '../components/mobile/MobileBottomNav.vue';
import MobileTopBar from '../components/mobile/MobileTopBar.vue';
const route = useRoute();
const titles: Record<string, string> = {
'm-dashboard': 'داشبورد',
'm-campaigns': 'کمپین‌ها',
'm-campaign-create': 'ایجاد کمپین',
'm-campaign-edit': 'ویرایش کمپین',
'm-campaign-prizes': 'مدیریت جوایز',
'm-users': 'کاربران',
'm-user-detail': 'جزئیات کاربر',
'm-spins': 'اسپین‌ها',
'm-winners': 'برندگان',
'm-sms': 'لاگ پیامک',
'm-reports': 'گزارش‌ها',
'm-audit': 'لاگ بازرسی',
};
const hideBottomNavRoutes = new Set(['m-campaign-create', 'm-campaign-edit', 'm-user-detail']);
const showBackRoutes = new Set([
'm-campaign-create',
'm-campaign-edit',
'm-campaign-prizes',
'm-user-detail',
]);
const pageTitle = computed(() => {
const custom = route.meta.title as string | undefined;
if (custom) return custom;
return titles[String(route.name)] || 'پنل مدیریت';
});
const showBottomNav = computed(() => !hideBottomNavRoutes.has(String(route.name)));
const showBack = computed(() => showBackRoutes.has(String(route.name)) || !!route.meta.showBack);
const hideTopBar = computed(() => !!route.meta.hideTopBar);
const transitionName = ref('m-slide-left');
watch(
() => route.fullPath,
(to, from) => {
const toDepth = to.split('/').filter(Boolean).length;
const fromDepth = (from || '').split('/').filter(Boolean).length;
transitionName.value = toDepth >= fromDepth ? 'm-slide-left' : 'm-slide-right';
},
);
</script>
<template>
<div class="mobile-shell" :class="{ 'mobile-shell--no-nav': !showBottomNav }">
<MobileTopBar
v-if="!hideTopBar"
:title="pageTitle"
:show-back="showBack"
>
<template #actions>
<slot name="top-actions" />
</template>
</MobileTopBar>
<main class="mobile-shell__content">
<router-view v-slot="{ Component, route: r }">
<transition :name="transitionName" mode="out-in">
<div :key="r.fullPath" class="mobile-shell__page">
<component :is="Component" />
</div>
</transition>
</router-view>
</main>
<MobileBottomNav v-if="showBottomNav" />
</div>
</template>
<style scoped>
.mobile-shell {
min-height: 100vh;
min-height: 100dvh;
background: #f4f6fa;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
.mobile-shell__content {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
padding-bottom: calc(64px + env(safe-area-inset-bottom, 0px) + 12px);
}
.mobile-shell--no-nav .mobile-shell__content {
padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
}
.mobile-shell__page {
min-height: 100%;
}
/* Slide animations (RTL-aware: left = forward into stack) */
.m-slide-left-enter-active,
.m-slide-left-leave-active,
.m-slide-right-enter-active,
.m-slide-right-leave-active {
transition: all 0.28s cubic-bezier(0.22, 1, 0.36, 1);
}
.m-slide-left-enter-from {
opacity: 0;
transform: translateX(-24px);
}
.m-slide-left-leave-to {
opacity: 0;
transform: translateX(16px);
}
.m-slide-right-enter-from {
opacity: 0;
transform: translateX(24px);
}
.m-slide-right-leave-to {
opacity: 0;
transform: translateX(-16px);
}
</style>
+3
View File
@@ -4,6 +4,7 @@ import './assets/fonts/fonts.css';
import { createPinia } from 'pinia';
import { createApp } from 'vue';
import App from './App.vue';
import { bindDeviceRouter } from './composables/useDevice';
import { router } from './router';
const app = createApp(App);
@@ -13,4 +14,6 @@ app.use(pinia);
app.use(router);
app.use(Antd);
bindDeviceRouter(router);
app.mount('#app');
+139 -13
View File
@@ -1,6 +1,11 @@
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router';
import AdminLayout from '../layouts/AdminLayout.vue';
import { useAuthStore } from '../stores/auth.store';
import MobileLayout from '../layouts/MobileLayout.vue';
import {
checkIsMobile,
desktopToMobileRoute,
mobileToDesktopRoute,
} from '../composables/useDevice';
const routes: RouteRecordRaw[] = [
{
@@ -9,15 +14,18 @@ const routes: RouteRecordRaw[] = [
component: () => import('../views/auth/LoginView.vue'),
meta: { guestOnly: true },
},
{
path: '/m/login',
name: 'm-login',
component: () => import('../views/mobile/MLoginView.vue'),
meta: { guestOnly: true, mobile: true },
},
{
path: '/',
component: AdminLayout,
meta: { requiresAuth: true },
children: [
{
path: '',
redirect: '/dashboard',
},
{ path: '', redirect: '/dashboard' },
{
path: 'dashboard',
name: 'dashboard',
@@ -80,9 +88,77 @@ const routes: RouteRecordRaw[] = [
},
],
},
{
path: '/m',
component: MobileLayout,
meta: { requiresAuth: true, mobile: true },
children: [
{ path: '', redirect: '/m/dashboard' },
{
path: 'dashboard',
name: 'm-dashboard',
component: () => import('../views/mobile/MDashboardView.vue'),
},
{
path: 'campaigns',
name: 'm-campaigns',
component: () => import('../views/mobile/MCampaignListView.vue'),
},
{
path: 'campaigns/create',
name: 'm-campaign-create',
component: () => import('../views/mobile/MCampaignFormView.vue'),
},
{
path: 'campaigns/:id/edit',
name: 'm-campaign-edit',
component: () => import('../views/mobile/MCampaignFormView.vue'),
},
{
path: 'campaigns/:campaignId/prizes',
name: 'm-campaign-prizes',
component: () => import('../views/mobile/MPrizeListView.vue'),
},
{
path: 'users',
name: 'm-users',
component: () => import('../views/mobile/MUserListView.vue'),
},
{
path: 'users/:id',
name: 'm-user-detail',
component: () => import('../views/mobile/MUserDetailView.vue'),
},
{
path: 'spins',
name: 'm-spins',
component: () => import('../views/mobile/MSpinListView.vue'),
},
{
path: 'winners',
name: 'm-winners',
component: () => import('../views/mobile/MWinnerListView.vue'),
},
{
path: 'sms',
name: 'm-sms',
component: () => import('../views/mobile/MSmsLogView.vue'),
},
{
path: 'reports',
name: 'm-reports',
component: () => import('../views/mobile/MReportView.vue'),
},
{
path: 'audit',
name: 'm-audit',
component: () => import('../views/mobile/MAuditLogView.vue'),
},
],
},
{
path: '/:pathMatch(.*)*',
redirect: '/dashboard',
redirect: () => (checkIsMobile() ? '/m/dashboard' : '/dashboard'),
},
];
@@ -91,15 +167,65 @@ export const router = createRouter({
routes,
});
router.beforeEach(async (to, from, next) => {
const authStore = useAuthStore();
function toMobilePath(to: { name?: string | symbol | null; params: Record<string, any>; query: Record<string, any> }) {
const name = String(to.name || '');
const mobileName = desktopToMobileRoute[name];
if (!mobileName) return null;
return { name: mobileName, params: to.params, query: to.query };
}
function toDesktopPath(to: { name?: string | symbol | null; params: Record<string, any>; query: Record<string, any> }) {
const name = String(to.name || '');
const desktopName = mobileToDesktopRoute[name];
if (!desktopName) return null;
return { name: desktopName, params: to.params, query: to.query };
}
router.beforeEach(async (to, _from, next) => {
const token = localStorage.getItem('admin_access_token');
const mobile = checkIsMobile();
const isMobileRoute = to.path.startsWith('/m') || !!to.meta.mobile;
if (to.meta.requiresAuth && !token) {
next('/login');
} else if (to.meta.guestOnly && token) {
next('/dashboard');
} else {
next();
next(mobile ? '/m/login' : '/login');
return;
}
if (to.meta.guestOnly && token) {
next(mobile ? '/m/dashboard' : '/dashboard');
return;
}
// Device-aware redirects between desktop and mobile route trees
if (token || to.meta.guestOnly) {
if (mobile && !isMobileRoute) {
if (to.name === 'login') {
next({ name: 'm-login', query: to.query });
return;
}
const mapped = toMobilePath(to as any);
if (mapped) {
next(mapped);
return;
}
next('/m/dashboard');
return;
}
if (!mobile && isMobileRoute) {
if (to.name === 'm-login') {
next({ name: 'login', query: to.query });
return;
}
const mapped = toDesktopPath(to as any);
if (mapped) {
next(mapped);
return;
}
next('/dashboard');
return;
}
}
next();
});
+87
View File
@@ -0,0 +1,87 @@
import { toFarsiDigits } from '@lucky-wheel/shared-types';
/** Jalali date+time display for admin tables */
export function formatFarsiDateTime(val?: string | Date | null): string {
if (!val) return '—';
try {
const d = new Date(val);
if (isNaN(d.getTime())) return '—';
const dateStr = d.toLocaleDateString('fa-IR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
const timeStr = d.toLocaleTimeString('fa-IR', {
hour: '2-digit',
minute: '2-digit',
});
return toFarsiDigits(`${dateStr} - ${timeStr}`);
} catch {
return '—';
}
}
/** Jalali date only */
export function formatFarsiDate(val?: string | Date | null): string {
if (!val) return '—';
try {
const d = new Date(val);
if (isNaN(d.getTime())) return '—';
return toFarsiDigits(
d.toLocaleDateString('fa-IR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}),
);
} catch {
return '—';
}
}
export const prizeTypeMap: Record<string, { text: string; color: string }> = {
PHYSICAL: { text: 'فیزیکی', color: 'blue' },
DISCOUNT_CODE: { text: 'کد تخفیف', color: 'green' },
CREDIT: { text: 'اعتبار', color: 'purple' },
WALLET_CREDIT: { text: 'شارژ کیف پول', color: 'purple' },
CASH: { text: 'نقدی', color: 'gold' },
NO_PRIZE: { text: 'پوچ', color: 'default' },
OTHER: { text: 'سایر', color: 'cyan' },
};
export function getPrizeTypeTag(type: string) {
return prizeTypeMap[type] || { text: type, color: 'blue' };
}
export const winnerStatusMap: Record<string, { text: string; color: string }> = {
NEW: { text: 'جدید', color: 'blue' },
PENDING: { text: 'در انتظار', color: 'warning' },
CONTACTED: { text: 'تماس گرفته‌شده', color: 'cyan' },
DELIVERED: { text: 'تحویل داده شده', color: 'success' },
ACTIVE_CODE: { text: 'کد فعال', color: 'purple' },
USED: { text: 'استفاده شده', color: 'green' },
CANCELLED: { text: 'لغو شده', color: 'error' },
EXPIRED: { text: 'منقضی شده', color: 'default' },
};
export function getCampaignStatusInfo(status: string) {
switch (status) {
case 'ACTIVE':
return { color: 'green', dotColor: '#10b981', text: 'فعال' };
case 'PAUSED':
return { color: 'orange', dotColor: '#f59e0b', text: 'متوقف' };
case 'FINISHED':
return { color: 'red', dotColor: '#ef4444', text: 'پایان‌یافته' };
case 'DRAFT':
return { color: 'gold', dotColor: '#d97706', text: 'پیش‌نویس' };
default:
return { color: 'default', dotColor: '#94a3b8', text: status };
}
}
/** Normalize list API responses that may be array or { data, meta } */
export function unwrapListResponse<T = any>(res: any): { items: T[]; total: number } {
const items = Array.isArray(res.data) ? res.data : res.data?.data || [];
const total = res.meta?.total || res.data?.meta?.total || items.length;
return { items, total };
}
+77
View File
@@ -0,0 +1,77 @@
/**
* Public URL of the client app (wheel page).
* Local browsing must never open production domains like wheel.montest.ir.
*/
const LOCAL_CLIENT_ORIGIN = 'http://localhost:5174';
function isLocalBrowsingContext(): boolean {
if (typeof window === 'undefined') return false;
const href = String(window.location.href || '');
const host = String(window.location.hostname || '');
const port = String(window.location.port || '');
if (href.includes('localhost') || href.includes('127.0.0.1') || href.includes('[::1]')) {
return true;
}
if (host === 'localhost' || host === '127.0.0.1' || host === '[::1]') {
return true;
}
// Common local admin ports (Vite / Docker compose mappings)
if (port === '5173' || port === '5174' || port === '8080') {
return true;
}
return Boolean(import.meta.env.DEV);
}
function normalizeSlug(slug: string): string {
const trimmed = String(slug || '').trim().replace(/^\/+/, '');
if (!trimmed) return '';
// If a full URL was stored as slug, keep only the last path segment
if (/^https?:\/\//i.test(trimmed) || trimmed.includes('wheel.montest.ir')) {
try {
const asUrl = /^https?:\/\//i.test(trimmed)
? new URL(trimmed)
: new URL(`https://${trimmed}`);
const parts = asUrl.pathname.split('/').filter(Boolean);
return parts[parts.length - 1] || '';
} catch {
const parts = trimmed.split('/').filter(Boolean);
return parts[parts.length - 1] || trimmed;
}
}
return trimmed;
}
export function getClientCampaignUrl(slug: string): string {
const cleanSlug = normalizeSlug(slug);
// Absolute local rule: never return production domains while browsing locally
if (isLocalBrowsingContext()) {
return cleanSlug ? `${LOCAL_CLIENT_ORIGIN}/${cleanSlug}` : `${LOCAL_CLIENT_ORIGIN}/`;
}
const configured = (import.meta.env.VITE_CLIENT_PUBLIC_URL as string | undefined)?.replace(/\/$/, '');
if (configured) {
// Safety net: if a local build somehow got a production URL, still refuse montest on local hosts
if (configured.includes('wheel.montest.ir') && isLocalBrowsingContext()) {
return cleanSlug ? `${LOCAL_CLIENT_ORIGIN}/${cleanSlug}` : `${LOCAL_CLIENT_ORIGIN}/`;
}
return cleanSlug ? `${configured}/${cleanSlug}` : `${configured}/`;
}
return cleanSlug ? `/${cleanSlug}` : '/';
}
export function openClientCampaign(slug: string): void {
if (typeof window === 'undefined') return;
const targetUrl = getClientCampaignUrl(slug);
console.log('CLICKED WHEEL LINK:', targetUrl);
window.open(targetUrl, '_blank', 'noopener,noreferrer');
}
+2 -1
View File
@@ -2,6 +2,7 @@
import { LockOutlined, MailOutlined } from '@ant-design/icons-vue';
import { reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { checkIsMobile } from '../../composables/useDevice';
import { useAuthStore } from '../../stores/auth.store';
const router = useRouter();
@@ -21,7 +22,7 @@ async function onFinish() {
email: formState.email,
password: formState.password,
});
router.push('/dashboard');
router.push(checkIsMobile() ? '/m/dashboard' : '/dashboard');
} catch (err) {
// Handled by axios interceptor
} finally {
@@ -15,9 +15,22 @@ import { formatFarsiNumber, toFarsiDigits } from '@lucky-wheel/shared-types';
import { onMounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { apiClient } from '../../services/api/client';
import {
formatFarsiDateTime,
getCampaignStatusInfo,
getPrizeTypeTag,
unwrapListResponse,
} from '../../utils/adminListUtils';
import { getClientCampaignUrl, openClientCampaign } from '../../utils/clientUrl';
const router = useRouter();
function onViewWheelClick(slug: string) {
const targetUrl = getClientCampaignUrl(slug);
console.log('CLICKED WHEEL LINK:', targetUrl);
openClientCampaign(slug);
}
const loading = ref(false);
const campaigns = ref<any[]>([]);
const total = ref(0);
@@ -40,9 +53,9 @@ async function loadCampaigns() {
search: filters.search || undefined,
},
});
const items = Array.isArray(res.data) ? res.data : (res.data?.data || []);
const { items, total: t } = unwrapListResponse(res);
campaigns.value = items;
total.value = res.meta?.total || res.data?.meta?.total || items.length;
total.value = t;
} finally {
loading.value = false;
}
@@ -90,34 +103,38 @@ const wheelDesignLabels: Record<string, string> = {
LUXURY_GOLD: 'لاکچری طلایی',
FESTIVAL: 'جشنواره‌ای شاد',
PLAYFUL_CARTOON: 'کارتونی شاد',
RUSTIC_WOOD: 'چوبی',
GALAXY_SPACE: 'کهکشانی',
CANDY_LAND: 'آب‌نباتی',
FIRE_FLAME: 'آتشین',
CRYSTAL_ICE: 'یخی',
};
function getUniquePrizeTypes(record: any): string[] {
if (Array.isArray(record.prizeTypes) && record.prizeTypes.length) {
return record.prizeTypes;
}
if (Array.isArray(record.prizes)) {
return Array.from(new Set(record.prizes.map((p: any) => p.type).filter(Boolean)));
}
return [];
}
const columns = [
{ title: 'عنوان کمپین', dataIndex: 'title', key: 'title' },
{ title: 'اسلاگ (URL)', dataIndex: 'slug', key: 'slug' },
{ title: 'طرح گردونه', dataIndex: 'wheelDesign', key: 'wheelDesign' },
{ title: 'وضعیت', dataIndex: 'status', key: 'status' },
{ title: 'شروع', dataIndex: 'startAt', key: 'startAt' },
{ title: 'پایان', dataIndex: 'endAt', key: 'endAt' },
{ title: 'تاریخ و ساعت شروع', dataIndex: 'startAt', key: 'startAt' },
{ title: 'تاریخ و ساعت پایان', dataIndex: 'endAt', key: 'endAt' },
{ title: 'نوع جوایز', key: 'prizeTypes' },
{ title: 'تعداد جوایز', key: 'prizesCount', align: 'center' as const },
{ title: 'تعداد اسپین‌ها', key: 'spinsCount', align: 'center' as const },
{ title: 'عملیات', key: 'actions', width: 70, align: 'center' as const },
];
function getStatusInfo(status: string) {
switch (status) {
case 'ACTIVE':
return { color: 'green', dotColor: '#10b981', text: 'فعال' };
case 'PAUSED':
return { color: 'orange', dotColor: '#f59e0b', text: 'متوقف' };
case 'FINISHED':
return { color: 'red', dotColor: '#ef4444', text: 'پایان‌یافته' };
case 'DRAFT':
return { color: 'gold', dotColor: '#d97706', text: 'پیش‌نویس' };
default:
return { color: 'default', dotColor: '#94a3b8', text: status };
}
}
const getStatusInfo = getCampaignStatusInfo;
const formatJalaliDateTime = formatFarsiDateTime;
onMounted(() => {
loadCampaigns();
@@ -206,11 +223,25 @@ onMounted(() => {
</template>
<template v-else-if="column.key === 'startAt'">
<span class="date-cell">{{ toFarsiDigits(new Date(record.startAt).toLocaleDateString('fa-IR')) }}</span>
<span class="date-cell">{{ record.startAtFormatted ? toFarsiDigits(record.startAtFormatted) : formatJalaliDateTime(record.startAt) }}</span>
</template>
<template v-else-if="column.key === 'endAt'">
<span class="date-cell">{{ toFarsiDigits(new Date(record.endAt).toLocaleDateString('fa-IR')) }}</span>
<span class="date-cell">{{ record.endAtFormatted ? toFarsiDigits(record.endAtFormatted) : formatJalaliDateTime(record.endAt) }}</span>
</template>
<template v-else-if="column.key === 'prizeTypes'">
<div class="prize-tags-container" v-if="getUniquePrizeTypes(record).length">
<a-tag
v-for="pType in getUniquePrizeTypes(record)"
:key="pType"
:color="getPrizeTypeTag(pType).color"
style="margin: 2px; border-radius: 4px; font-size: 11px"
>
{{ getPrizeTypeTag(pType).text }}
</a-tag>
</div>
<span v-else class="text-muted" style="color: #94a3b8; font-size: 12px">تعریف‌نشده</span>
</template>
<template v-else-if="column.key === 'prizesCount'">
@@ -228,15 +259,11 @@ onMounted(() => {
</a-button>
<template #overlay>
<a-menu class="action-dropdown-menu">
<a-menu-item key="view">
<a
:href="'http://localhost:5174/c/' + record.slug"
target="_blank"
class="dropdown-action-item"
>
<a-menu-item key="view" @click="onViewWheelClick(record.slug)">
<div class="dropdown-action-item">
<EyeOutlined class="dropdown-item-icon" />
<span>مشاهده گردونه</span>
</a>
</div>
</a-menu-item>
<a-menu-item key="edit" @click="router.push(`/campaigns/${record.id}/edit`)">
<div class="dropdown-action-item">
@@ -94,6 +94,76 @@ const designs = [
centerColor: '#facc15',
centerText: '✨',
},
{
id: WheelDesign.RUSTIC_WOOD,
title: 'چوبی روستایی (Rustic Wood)',
badge: 'طبیعی و گرم',
badgeColor: 'orange',
desc: 'بافت چوب طبیعی، پیچ‌های فلزی، رنگ‌های قهوه‌ای گرم و حس دست‌ساز سنتی',
bg: 'radial-gradient(circle, #5c3a1e 0%, #2a160c 100%)',
accent: '#c9a36a',
border: '#8d6e63',
slices: ['#8B5A2B', '#A0522D', '#6B4226', '#C4A484', '#8B5A2B', '#5D4037'],
pointerColor: '#8d6e63',
centerColor: '#a1887f',
centerText: '🪵',
},
{
id: WheelDesign.GALAXY_SPACE,
title: 'کهکشانی (Galaxy Space)',
badge: 'فضایی و مدرن',
badgeColor: 'purple',
desc: 'پس‌زمینه فضایی با ستاره‌ها، گرادیان بنفش-آبی و حلقه نورانی کهکشانی',
bg: 'radial-gradient(circle, #1e1b4b 0%, #050816 100%)',
accent: '#a78bfa',
border: '#6366f1',
slices: ['#1e1b4b', '#312e81', '#4c1d95', '#0f172a', '#1e3a8a', '#581c87'],
pointerColor: '#a78bfa',
centerColor: '#818cf8',
centerText: '🌌',
},
{
id: WheelDesign.CANDY_LAND,
title: 'آب‌نباتی (Candy Land)',
badge: 'شاد و کودکانه',
badgeColor: 'pink',
desc: 'رنگ‌های پاستل صورتی و زرد، حاشیه ابری و حس شیرین و بازیگوش',
bg: 'radial-gradient(circle, #ffe4e6 0%, #fbcfe8 100%)',
accent: '#fb7185',
border: '#f9a8d4',
slices: ['#fb7185', '#fbbf24', '#34d399', '#60a5fa', '#c084fc', '#f472b6'],
pointerColor: '#fb7185',
centerColor: '#fef08a',
centerText: '🍬',
},
{
id: WheelDesign.FIRE_FLAME,
title: 'آتشین (Fire Flame)',
badge: 'انرژی و هیجان',
badgeColor: 'volcano',
desc: 'قرمز و نارنجی درخشان، شعله‌های SVG روی حاشیه و مرکز اخگر',
bg: 'radial-gradient(circle, #7f1d1d 0%, #1a0505 100%)',
accent: '#f97316',
border: '#dc2626',
slices: ['#7f1d1d', '#b91c1c', '#ea580c', '#c2410c', '#9a3412', '#450a0a'],
pointerColor: '#f97316',
centerColor: '#fdba74',
centerText: '🔥',
},
{
id: WheelDesign.CRYSTAL_ICE,
title: 'یخی کریستالی (Crystal Ice)',
badge: 'خنک و لوکس',
badgeColor: 'blue',
desc: 'آبی یخی شفاف، کریستال‌های لبه و حس زمستانی و کریستالی',
bg: 'radial-gradient(circle, #0ea5e9 0%, #082f49 100%)',
accent: '#7dd3fc',
border: '#38bdf8',
slices: ['#0ea5e9', '#38bdf8', '#7dd3fc', '#0284c7', '#e0f2fe', '#0369a1'],
pointerColor: '#38bdf8',
centerColor: '#e0f2fe',
centerText: '❄️',
},
];
function selectDesign(id: WheelDesign) {
@@ -262,6 +262,11 @@ const wheelDesignMap: Record<string, string> = {
MINIMAL_FLAT: 'مینیمال فلت',
LUXURY_GOLD: 'طلایی لوکس',
PLAYFUL_CARTOON: 'کارتونی فانتزی',
RUSTIC_WOOD: 'چوبی',
GALAXY_SPACE: 'کهکشانی',
CANDY_LAND: 'آب‌نباتی',
FIRE_FLAME: 'آتشین',
CRYSTAL_ICE: 'یخی',
};
const columns = [
@@ -0,0 +1,156 @@
<script setup lang="ts">
import { EyeOutlined, ReloadOutlined } from '@ant-design/icons-vue';
import { toFarsiDigits } from '@lucky-wheel/shared-types';
import { onMounted, reactive, ref } from 'vue';
import { apiClient } from '../../services/api/client';
const loading = ref(false);
const logs = ref<any[]>([]);
const total = ref(0);
const detailOpen = ref(false);
const selected = ref<any>(null);
const filters = reactive({
page: 1,
pageSize: 10,
entityType: undefined as string | undefined,
});
async function loadLogs() {
loading.value = true;
try {
const res = await apiClient.get('/admin/audit-logs', {
params: {
page: filters.page,
pageSize: filters.pageSize,
entityType: filters.entityType || undefined,
},
});
const items = Array.isArray(res.data) ? res.data : res.data?.data || [];
logs.value = items;
total.value = res.meta?.total || res.data?.meta?.total || items.length;
} finally {
loading.value = false;
}
}
function openDetail(log: any) {
selected.value = log;
detailOpen.value = true;
}
function actionColor(action: string) {
const a = (action || '').toUpperCase();
if (a.includes('CREATE') || a.includes('ADD')) return 'green';
if (a.includes('UPDATE') || a.includes('EDIT') || a.includes('PATCH')) return 'blue';
if (a.includes('DELETE') || a.includes('REMOVE')) return 'red';
if (a.includes('LOGIN') || a.includes('AUTH')) return 'purple';
return 'default';
}
function formatDate(val?: string) {
if (!val) return '—';
return toFarsiDigits(
new Date(val).toLocaleDateString('fa-IR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}),
);
}
onMounted(loadLogs);
</script>
<template>
<div class="m-page">
<div class="m-filters">
<a-select
v-model:value="filters.entityType"
allow-clear
placeholder="نوع موجودیت"
style="flex: 1"
@change="() => { filters.page = 1; loadLogs(); }"
>
<a-select-option value="Campaign">کمپین</a-select-option>
<a-select-option value="Prize">جایزه</a-select-option>
<a-select-option value="User">کاربر</a-select-option>
<a-select-option value="Chance">شانس</a-select-option>
<a-select-option value="UserPrize">جایزه کاربر</a-select-option>
</a-select>
<button type="button" class="m-icon-btn" @click="loadLogs">
<ReloadOutlined :spin="loading" />
</button>
</div>
<a-spin :spinning="loading">
<div v-if="!logs.length && !loading" class="m-empty">لاگی یافت نشد</div>
<div v-for="log in logs" :key="log.id" class="m-card">
<div class="m-card__top">
<div>
<div class="m-card__admin">{{ log.admin?.name || log.admin?.email || 'سیستم' }}</div>
<div class="m-card__entity">{{ log.entityType }} · {{ log.entityId || '—' }}</div>
</div>
<a-tag :color="actionColor(log.action)">{{ log.action }}</a-tag>
</div>
<div class="m-card__meta">
<span dir="ltr">{{ log.ipAddress || '—' }}</span>
<span>{{ formatDate(log.createdAt) }}</span>
</div>
<button type="button" class="m-detail" @click="openDetail(log)">
<EyeOutlined /> جزئیات JSON
</button>
</div>
<div v-if="total > filters.pageSize" class="m-pager">
<a-pagination
v-model:current="filters.page"
:total="total"
:page-size="filters.pageSize"
size="small"
@change="loadLogs"
/>
</div>
</a-spin>
<a-drawer v-model:open="detailOpen" placement="bottom" :height="'70%'" title="جزئیات لاگ">
<div v-if="selected" class="detail">
<div class="detail__row"><span>ادمین</span><strong>{{ selected.admin?.name || '—' }}</strong></div>
<div class="detail__row"><span>اکشن</span><strong>{{ selected.action }}</strong></div>
<div class="detail__row"><span>موجودیت</span><strong>{{ selected.entityType }}</strong></div>
<h4>قبل</h4>
<pre>{{ JSON.stringify(selected.oldValues || {}, null, 2) }}</pre>
<h4>بعد</h4>
<pre>{{ JSON.stringify(selected.newValues || {}, null, 2) }}</pre>
</div>
</a-drawer>
</div>
</template>
<style scoped>
.m-page { padding: 12px 14px 24px; }
.m-filters { display: flex; gap: 8px; margin-bottom: 12px; }
.m-filters :deep(.ant-select-selector) { border-radius: 12px !important; min-height: 42px !important; }
.m-icon-btn { width: 42px; height: 42px; border: none; border-radius: 12px; background: #fff; color: #4f46e5; }
.m-empty { text-align: center; color: #94a3b8; padding: 40px; }
.m-card { background: #fff; border-radius: 14px; padding: 12px; margin-bottom: 8px; border: 1px solid #f1f5f9; }
.m-card__top { display: flex; justify-content: space-between; gap: 8px; margin-bottom: 8px; }
.m-card__admin { font-weight: 800; font-size: 13px; color: #0f172a; }
.m-card__entity { font-size: 11px; color: #94a3b8; margin-top: 2px; direction: ltr; text-align: right; }
.m-card__meta { display: flex; justify-content: space-between; font-size: 11px; color: #94a3b8; }
.m-detail {
margin-top: 10px; width: 100%; border: 1px solid #e2e8f0; background: #f8fafc;
border-radius: 10px; padding: 10px; font-weight: 700; font-size: 12px; color: #334155; min-height: 42px;
}
.m-pager { display: flex; justify-content: center; padding: 12px 0; }
.detail__row {
display: flex; justify-content: space-between; margin-bottom: 8px; font-size: 13px; color: #64748b;
}
.detail__row strong { color: #0f172a; }
.detail h4 { margin: 16px 0 8px; font-size: 13px; }
.detail pre {
background: #0f172a; color: #e2e8f0; border-radius: 12px; padding: 12px;
font-size: 11px; overflow: auto; direction: ltr; text-align: left; max-height: 180px;
}
</style>
@@ -0,0 +1,316 @@
<script setup lang="ts">
import { WheelDesign } from '@lucky-wheel/shared-types';
import { message } from 'ant-design-vue';
import dayjs, { Dayjs } from 'dayjs';
import { onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { apiClient } from '../../services/api/client';
const route = useRoute();
const router = useRouter();
const isEdit = ref(false);
const campaignId = ref<string | null>(null);
const loading = ref(false);
const submitting = ref(false);
const step = ref(0);
const formState = reactive({
title: '',
slug: '',
description: '',
banner: '',
logo: '',
dateRange: [] as Dayjs[],
status: 'DRAFT',
defaultChances: 3,
rules: '',
ctaText: 'بچرخون و برنده شو!',
wheelDesign: WheelDesign.CLASSIC_CASINO as string,
primaryColor: '#FFD700',
secondaryColor: '#B22222',
});
const wheelOptions = [
{ value: WheelDesign.CLASSIC_CASINO, label: 'کازینویی کلاسیک' },
{ value: WheelDesign.NEON_CYBER, label: 'نئونی سایبر' },
{ value: WheelDesign.MINIMAL_FLAT, label: 'مینیمال فلت' },
{ value: WheelDesign.LUXURY_GOLD, label: 'طلایی لوکس' },
{ value: WheelDesign.PLAYFUL_CARTOON, label: 'کارتونی' },
{ value: WheelDesign.RUSTIC_WOOD, label: 'چوبی' },
{ value: WheelDesign.GALAXY_SPACE, label: 'کهکشانی' },
{ value: WheelDesign.CANDY_LAND, label: 'آب‌نباتی' },
{ value: WheelDesign.FIRE_FLAME, label: 'آتشین' },
{ value: WheelDesign.CRYSTAL_ICE, label: 'یخی' },
];
async function loadCampaign() {
if (!campaignId.value) return;
loading.value = true;
try {
const res = await apiClient.get(`/admin/campaigns/${campaignId.value}`);
const c = res.data;
formState.title = c.title;
formState.slug = c.slug;
formState.description = c.description || '';
formState.banner = c.banner || '';
formState.logo = c.logo || '';
formState.status = c.status;
formState.defaultChances = c.defaultChances;
formState.rules = c.rules || '';
formState.ctaText = c.ctaText || 'بچرخون و برنده شو!';
formState.wheelDesign = c.wheelDesign;
formState.dateRange = [dayjs(c.startAt), dayjs(c.endAt)];
if (c.settings) {
formState.primaryColor = c.settings.primaryColor || '#FFD700';
formState.secondaryColor = c.settings.secondaryColor || '#B22222';
}
} finally {
loading.value = false;
}
}
function nextStep() {
if (step.value === 0) {
if (!formState.title?.trim()) {
message.error('عنوان کمپین را وارد کنید');
return;
}
if (!formState.slug?.trim()) {
formState.slug = 'campaign-' + Math.random().toString(36).substring(2, 8);
}
}
if (step.value === 1) {
if (!formState.dateRange || formState.dateRange.length < 2) {
message.error('بازه زمانی را انتخاب کنید');
return;
}
}
step.value += 1;
}
async function onSubmit() {
let slug = (formState.slug || '').trim().toLowerCase().replace(/\s+/g, '-');
if (!slug) slug = 'campaign-' + Math.random().toString(36).substring(2, 8);
formState.slug = slug;
if (!/^[a-z0-9-]+$/.test(slug)) {
message.error('اسلاگ فقط حروف انگلیسی، عدد و -');
return;
}
submitting.value = true;
try {
const payload = {
title: formState.title.trim(),
slug: formState.slug.trim(),
description: formState.description?.trim() || undefined,
banner: formState.banner ? formState.banner.trim() : null,
logo: formState.logo ? formState.logo.trim() : null,
startAt: formState.dateRange[0].toISOString(),
endAt: formState.dateRange[1].toISOString(),
status: formState.status,
defaultChances: Number(formState.defaultChances) || 0,
rules: formState.rules?.trim() || undefined,
ctaText: formState.ctaText || 'بچرخون و برنده شو!',
wheelDesign: formState.wheelDesign,
settings: {
primaryColor: formState.primaryColor,
secondaryColor: formState.secondaryColor,
},
};
if (isEdit.value && campaignId.value) {
await apiClient.patch(`/admin/campaigns/${campaignId.value}`, payload);
message.success('کمپین به‌روزرسانی شد');
router.push('/m/campaigns');
} else {
const res = await apiClient.post('/admin/campaigns', payload);
message.success('کمپین ایجاد شد');
const createdId = res.data?.id;
router.push(createdId ? `/m/campaigns/${createdId}/prizes` : '/m/campaigns');
}
} catch (err: any) {
const errorMsg = err.response?.data?.message || 'خطا در ذخیره';
message.error(Array.isArray(errorMsg) ? errorMsg.join(' | ') : errorMsg);
} finally {
submitting.value = false;
}
}
onMounted(() => {
if (route.params.id) {
isEdit.value = true;
campaignId.value = route.params.id as string;
loadCampaign();
} else {
formState.dateRange = [dayjs(), dayjs().add(1, 'month')];
}
});
</script>
<template>
<div class="m-form-page">
<a-spin :spinning="loading">
<div class="m-steps">
<div class="m-step" :class="{ active: step === 0, done: step > 0 }">۱. پایه</div>
<div class="m-step" :class="{ active: step === 1, done: step > 1 }">۲. زمان</div>
<div class="m-step" :class="{ active: step === 2 }">۳. ظاهر</div>
</div>
<div v-show="step === 0" class="m-card">
<a-form layout="vertical">
<a-form-item label="عنوان کمپین" required>
<a-input v-model:value="formState.title" size="large" placeholder="مثلاً جشنواره نوروز" />
</a-form-item>
<a-form-item label="اسلاگ (URL)">
<a-input v-model:value="formState.slug" size="large" placeholder="nowruz-2026" dir="ltr" />
</a-form-item>
<a-form-item label="توضیحات">
<a-textarea v-model:value="formState.description" :rows="3" />
</a-form-item>
<a-form-item label="وضعیت">
<a-select v-model:value="formState.status" size="large" style="width: 100%">
<a-select-option value="DRAFT">پیش‌نویس</a-select-option>
<a-select-option value="ACTIVE">فعال</a-select-option>
<a-select-option value="PAUSED">متوقف</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="شانس پیش‌فرض">
<a-input-number v-model:value="formState.defaultChances" :min="0" size="large" style="width: 100%" />
</a-form-item>
</a-form>
</div>
<div v-show="step === 1" class="m-card">
<a-form layout="vertical">
<a-form-item label="بازه زمانی" required>
<a-range-picker
v-model:value="formState.dateRange"
show-time
size="large"
style="width: 100%"
/>
</a-form-item>
<a-form-item label="متن دکمه CTA">
<a-input v-model:value="formState.ctaText" size="large" />
</a-form-item>
<a-form-item label="قوانین">
<a-textarea v-model:value="formState.rules" :rows="4" />
</a-form-item>
</a-form>
</div>
<div v-show="step === 2" class="m-card">
<a-form layout="vertical">
<a-form-item label="طرح گردونه">
<div class="m-wheel-grid">
<button
v-for="opt in wheelOptions"
:key="opt.value"
type="button"
class="m-wheel-opt"
:class="{ active: formState.wheelDesign === opt.value }"
@click="formState.wheelDesign = opt.value"
>
{{ opt.label }}
</button>
</div>
</a-form-item>
<a-form-item label="رنگ اصلی">
<a-input v-model:value="formState.primaryColor" type="color" style="width: 100%; height: 44px" />
</a-form-item>
<a-form-item label="رنگ ثانویه">
<a-input v-model:value="formState.secondaryColor" type="color" style="width: 100%; height: 44px" />
</a-form-item>
</a-form>
</div>
<div class="m-form-actions">
<a-button v-if="step > 0" size="large" block @click="step -= 1">قبلی</a-button>
<a-button
v-if="step < 2"
type="primary"
size="large"
block
@click="nextStep"
>
بعدی
</a-button>
<a-button
v-else
type="primary"
size="large"
block
:loading="submitting"
@click="onSubmit"
>
{{ isEdit ? 'ذخیره تغییرات' : 'ایجاد کمپین' }}
</a-button>
</div>
</a-spin>
</div>
</template>
<style scoped>
.m-form-page { padding: 12px 14px 24px; }
.m-steps {
display: flex;
gap: 6px;
margin-bottom: 14px;
}
.m-step {
flex: 1;
text-align: center;
font-size: 11px;
font-weight: 700;
padding: 8px 4px;
border-radius: 10px;
background: #e2e8f0;
color: #64748b;
}
.m-step.active {
background: #4f46e5;
color: #fff;
}
.m-step.done {
background: #c7d2fe;
color: #3730a3;
}
.m-card {
background: #fff;
border-radius: 16px;
padding: 16px;
border: 1px solid #f1f5f9;
margin-bottom: 14px;
}
.m-wheel-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.m-wheel-opt {
border: 1px solid #e2e8f0;
background: #f8fafc;
border-radius: 12px;
padding: 12px 8px;
font-size: 12px;
font-weight: 700;
color: #334155;
min-height: 48px;
}
.m-wheel-opt.active {
border-color: #4f46e5;
background: #eef2ff;
color: #4f46e5;
}
.m-form-actions {
display: flex;
flex-direction: column;
gap: 8px;
}
.m-form-actions :deep(.ant-btn) {
height: 48px;
border-radius: 14px;
font-weight: 700;
}
</style>
@@ -0,0 +1,308 @@
<script setup lang="ts">
import {
CheckCircleOutlined,
CopyOutlined,
EditOutlined,
EyeOutlined,
GiftOutlined,
MoreOutlined,
PauseCircleOutlined,
PlusOutlined,
SearchOutlined,
} from '@ant-design/icons-vue';
import { formatFarsiNumber, toFarsiDigits } from '@lucky-wheel/shared-types';
import { Modal, message } from 'ant-design-vue';
import { onMounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { apiClient } from '../../services/api/client';
import { openClientCampaign } from '../../utils/clientUrl';
const router = useRouter();
const loading = ref(false);
const campaigns = ref<any[]>([]);
const total = ref(0);
const filters = reactive({
page: 1,
pageSize: 10,
status: undefined as string | undefined,
search: '',
});
async function loadCampaigns() {
loading.value = true;
try {
const res = await apiClient.get('/admin/campaigns', {
params: {
page: filters.page,
pageSize: filters.pageSize,
status: filters.status,
search: filters.search || undefined,
},
});
const items = Array.isArray(res.data) ? res.data : res.data?.data || [];
campaigns.value = items;
total.value = res.meta?.total || res.data?.meta?.total || items.length;
} finally {
loading.value = false;
}
}
async function duplicateCampaign(id: string) {
await apiClient.post(`/admin/campaigns/${id}/duplicate`);
message.success('کمپین کپی شد');
loadCampaigns();
}
function confirmDuplicate(record: any) {
Modal.confirm({
title: 'کپی کمپین',
content: `کمپین «${record.title}» کپی شود؟`,
okText: 'بله',
cancelText: 'انصراف',
onOk: () => duplicateCampaign(record.id),
});
}
async function quickSetStatus(id: string, newStatus: string) {
try {
await apiClient.patch(`/admin/campaigns/${id}`, { status: newStatus });
message.success(newStatus === 'ACTIVE' ? 'کمپین فعال شد' : 'کمپین متوقف شد');
loadCampaigns();
} catch (err: any) {
const errorMsg = err.response?.data?.message || 'خطا در تغییر وضعیت';
message.error(Array.isArray(errorMsg) ? errorMsg.join(' | ') : errorMsg);
}
}
function getStatusInfo(status: string) {
switch (status) {
case 'ACTIVE':
return { color: '#10b981', text: 'فعال', bg: '#ecfdf5' };
case 'PAUSED':
return { color: '#f59e0b', text: 'متوقف', bg: '#fffbeb' };
case 'FINISHED':
return { color: '#ef4444', text: 'پایان', bg: '#fef2f2' };
case 'DRAFT':
return { color: '#d97706', text: 'پیش‌نویس', bg: '#fffbeb' };
default:
return { color: '#94a3b8', text: status, bg: '#f8fafc' };
}
}
function formatDate(val?: string) {
if (!val) return '—';
try {
return toFarsiDigits(
new Date(val).toLocaleDateString('fa-IR', { year: 'numeric', month: '2-digit', day: '2-digit' }),
);
} catch {
return '—';
}
}
function onSearch() {
filters.page = 1;
loadCampaigns();
}
onMounted(loadCampaigns);
</script>
<template>
<div class="m-page">
<div class="m-search-bar">
<a-input
v-model:value="filters.search"
allow-clear
placeholder="جستجوی کمپین..."
@pressEnter="onSearch"
>
<template #prefix><SearchOutlined style="color: #94a3b8" /></template>
</a-input>
<a-select
v-model:value="filters.status"
allow-clear
placeholder="وضعیت"
style="width: 110px"
@change="onSearch"
>
<a-select-option value="ACTIVE">فعال</a-select-option>
<a-select-option value="PAUSED">متوقف</a-select-option>
<a-select-option value="DRAFT">پیش‌نویس</a-select-option>
<a-select-option value="FINISHED">پایان</a-select-option>
</a-select>
</div>
<a-spin :spinning="loading">
<div v-if="!campaigns.length && !loading" class="m-empty">کمپینی یافت نشد</div>
<div v-for="c in campaigns" :key="c.id" class="m-camp-card">
<div class="m-camp-card__top">
<div>
<div class="m-camp-card__title">{{ c.title }}</div>
<div class="m-camp-card__slug">/{{ c.slug }}</div>
</div>
<span
class="m-status"
:style="{ color: getStatusInfo(c.status).color, background: getStatusInfo(c.status).bg }"
>
{{ getStatusInfo(c.status).text }}
</span>
</div>
<div class="m-camp-card__meta">
<span>{{ formatDate(c.startAt) }} → {{ formatDate(c.endAt) }}</span>
<span>
{{ formatFarsiNumber(c._count?.prizes || 0) }} جایزه ·
{{ formatFarsiNumber(c._count?.spins || 0) }} اسپین
</span>
</div>
<div class="m-camp-card__actions">
<button type="button" class="m-action" @click="openClientCampaign(c.slug)">
<EyeOutlined /> گردونه
</button>
<button type="button" class="m-action" @click="router.push(`/m/campaigns/${c.id}/edit`)">
<EditOutlined /> ویرایش
</button>
<button type="button" class="m-action" @click="router.push(`/m/campaigns/${c.id}/prizes`)">
<GiftOutlined /> جوایز
</button>
<a-dropdown>
<button type="button" class="m-action">
<MoreOutlined /> بیشتر
</button>
<template #overlay>
<a-menu>
<a-menu-item v-if="c.status !== 'ACTIVE'" @click="quickSetStatus(c.id, 'ACTIVE')">
<CheckCircleOutlined /> فعال‌سازی
</a-menu-item>
<a-menu-item v-if="c.status === 'ACTIVE'" @click="quickSetStatus(c.id, 'PAUSED')">
<PauseCircleOutlined /> توقف
</a-menu-item>
<a-menu-item @click="confirmDuplicate(c)">
<CopyOutlined /> کپی
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</div>
<div v-if="total > filters.pageSize" class="m-pager">
<a-pagination
v-model:current="filters.page"
:total="total"
:page-size="filters.pageSize"
size="small"
@change="loadCampaigns"
/>
</div>
</a-spin>
<button type="button" class="m-fab" @click="router.push('/m/campaigns/create')">
<PlusOutlined />
</button>
</div>
</template>
<style scoped>
.m-page { padding: 12px 14px 88px; position: relative; }
.m-search-bar {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.m-search-bar :deep(.ant-input-affix-wrapper),
.m-search-bar :deep(.ant-select-selector) {
border-radius: 12px !important;
min-height: 42px !important;
}
.m-empty {
text-align: center;
color: #94a3b8;
padding: 48px 16px;
font-size: 13px;
}
.m-camp-card {
background: #fff;
border-radius: 16px;
padding: 14px;
margin-bottom: 10px;
border: 1px solid #f1f5f9;
box-shadow: 0 2px 10px rgba(15, 23, 42, 0.04);
}
.m-camp-card__top {
display: flex;
justify-content: space-between;
gap: 10px;
margin-bottom: 8px;
}
.m-camp-card__title {
font-size: 15px;
font-weight: 800;
color: #0f172a;
}
.m-camp-card__slug {
font-size: 11px;
color: #94a3b8;
direction: ltr;
text-align: right;
margin-top: 2px;
}
.m-status {
font-size: 11px;
font-weight: 700;
padding: 4px 10px;
border-radius: 999px;
white-space: nowrap;
height: fit-content;
}
.m-camp-card__meta {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 11px;
color: #64748b;
margin-bottom: 12px;
}
.m-camp-card__actions {
display: flex;
gap: 6px;
}
.m-action {
flex: 1;
border: 1px solid #e2e8f0;
background: #f8fafc;
border-radius: 10px;
padding: 10px 6px;
font-size: 11px;
font-weight: 700;
color: #334155;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
min-height: 42px;
}
.m-pager {
display: flex;
justify-content: center;
padding: 12px 0;
}
.m-fab {
position: fixed;
left: 18px;
bottom: calc(80px + env(safe-area-inset-bottom, 0px));
width: 56px;
height: 56px;
border: none;
border-radius: 18px;
background: linear-gradient(135deg, #4f46e5, #7c3aed);
color: #fff;
font-size: 22px;
box-shadow: 0 10px 24px rgba(79, 70, 229, 0.4);
z-index: 90;
}
</style>
@@ -0,0 +1,419 @@
<script setup lang="ts">
import {
ArrowUpOutlined,
PercentageOutlined,
PieChartOutlined,
PlusCircleOutlined,
ReloadOutlined,
TeamOutlined,
TrophyOutlined,
UserOutlined,
} from '@ant-design/icons-vue';
import { formatFarsiNumber, toFarsiDigits } from '@lucky-wheel/shared-types';
import { message } from 'ant-design-vue';
import * as echarts from 'echarts';
import { nextTick, onMounted, onUnmounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { apiClient } from '../../services/api/client';
const router = useRouter();
const loading = ref(true);
const dashboardData = ref<any>(null);
const chartRef = ref<HTMLElement | null>(null);
let chartInstance: echarts.ECharts | null = null;
const statusMap: Record<string, { text: string; color: string }> = {
NEW: { text: 'جدید', color: 'blue' },
CONTACTED: { text: 'تماس', color: 'cyan' },
DELIVERED: { text: 'تحویل', color: 'success' },
ACTIVE_CODE: { text: 'کد فعال', color: 'purple' },
USED: { text: 'مصرف', color: 'green' },
EXPIRED: { text: 'منقضی', color: 'default' },
};
async function loadDashboard() {
loading.value = true;
try {
const res = await apiClient.get('/admin/reports/dashboard');
dashboardData.value = res.data;
await nextTick();
initChart();
} catch (err) {
console.error(err);
} finally {
loading.value = false;
}
}
function initChart() {
if (!chartRef.value || !dashboardData.value?.spinsByDay) return;
if (chartInstance) chartInstance.dispose();
chartInstance = echarts.init(chartRef.value);
const days = dashboardData.value.spinsByDay;
chartInstance.setOption({
grid: { top: 16, right: 8, bottom: 28, left: 36 },
tooltip: { trigger: 'axis' },
xAxis: {
type: 'category',
data: days.map((d: any) => toFarsiDigits(String(d.date).slice(5))),
axisLabel: { fontSize: 10, color: '#94a3b8', fontFamily: 'IRANSans' },
axisLine: { lineStyle: { color: '#e2e8f0' } },
},
yAxis: {
type: 'value',
axisLabel: { fontSize: 10, color: '#94a3b8', fontFamily: 'IRANSans' },
splitLine: { lineStyle: { color: '#f1f5f9' } },
},
series: [
{
name: 'اسپین',
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 6,
data: days.map((d: any) => d.spins),
lineStyle: { width: 3, color: '#4f46e5' },
itemStyle: { color: '#4f46e5' },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(79,70,229,0.28)' },
{ offset: 1, color: 'rgba(79,70,229,0.02)' },
]),
},
},
],
});
}
function copyCode(code: string) {
if (!code) return;
navigator.clipboard.writeText(code);
message.success('کد کپی شد');
}
onMounted(() => {
loadDashboard();
window.addEventListener('resize', () => chartInstance?.resize());
});
onUnmounted(() => {
chartInstance?.dispose();
});
</script>
<template>
<div class="m-dash">
<div class="m-dash__toolbar">
<div>
<div class="m-dash__live">
<span class="pulse" />
آنلاین
</div>
<p class="m-dash__sub">خلاصه عملکرد سامانه</p>
</div>
<button type="button" class="m-icon-btn" :disabled="loading" @click="loadDashboard">
<ReloadOutlined :spin="loading" />
</button>
</div>
<a-spin :spinning="loading">
<div class="m-kpi-grid">
<div class="m-kpi indigo">
<div class="m-kpi__head">
<span>کاربران</span>
<UserOutlined />
</div>
<div class="m-kpi__val">{{ formatFarsiNumber(dashboardData?.stats?.totalUsers || 0) }}</div>
<div class="m-kpi__sub">
<ArrowUpOutlined />
+{{ formatFarsiNumber(dashboardData?.stats?.todayUsers || 0) }} امروز
</div>
</div>
<div class="m-kpi amber">
<div class="m-kpi__head">
<span>اسپین‌ها</span>
<ReloadOutlined />
</div>
<div class="m-kpi__val">{{ formatFarsiNumber(dashboardData?.stats?.totalSpins || 0) }}</div>
<div class="m-kpi__sub">
<ArrowUpOutlined />
+{{ formatFarsiNumber(dashboardData?.stats?.todaySpins || 0) }} امروز
</div>
</div>
<div class="m-kpi emerald">
<div class="m-kpi__head">
<span>برندگان</span>
<TrophyOutlined />
</div>
<div class="m-kpi__val">{{ formatFarsiNumber(dashboardData?.stats?.totalWinners || 0) }}</div>
<div class="m-kpi__sub">کل برندگان ثبت‌شده</div>
</div>
<div class="m-kpi violet">
<div class="m-kpi__head">
<span>نرخ برد</span>
<PercentageOutlined />
</div>
<div class="m-kpi__val">{{ toFarsiDigits(String(dashboardData?.stats?.winRate ?? 0)) }}٪</div>
<div class="m-kpi__sub">
{{ formatFarsiNumber(dashboardData?.stats?.activeCampaignsCount || 0) }} کمپین فعال
</div>
</div>
</div>
<div class="m-quick">
<button type="button" class="m-quick__btn primary" @click="router.push('/m/campaigns/create')">
<PlusCircleOutlined />
کمپین جدید
</button>
<button type="button" class="m-quick__btn" @click="router.push('/m/users')">
<TeamOutlined />
کاربران
</button>
<button type="button" class="m-quick__btn" @click="router.push('/m/winners')">
<TrophyOutlined />
برندگان
</button>
<button type="button" class="m-quick__btn" @click="router.push('/m/reports')">
<PieChartOutlined />
گزارش
</button>
</div>
<div class="m-card">
<div class="m-card__title">روند اسپین روزانه</div>
<div ref="chartRef" class="m-chart" />
</div>
<div class="m-card" v-if="dashboardData?.recentWinners?.length">
<div class="m-card__title">آخرین برندگان</div>
<div
v-for="w in dashboardData.recentWinners.slice(0, 8)"
:key="w.id"
class="m-winner-row"
>
<div class="m-winner-row__main">
<div class="m-winner-row__name">
{{ w.user?.name || toFarsiDigits(w.user?.mobile || '—') }}
</div>
<div class="m-winner-row__meta">
{{ w.prize?.title || '—' }} · {{ w.campaign?.title || '—' }}
</div>
</div>
<div class="m-winner-row__side">
<a-tag :color="statusMap[w.status]?.color || 'default'" class="m-tag">
{{ statusMap[w.status]?.text || w.status }}
</a-tag>
<button
v-if="w.code"
type="button"
class="m-code"
@click="copyCode(w.code)"
>
{{ w.code }}
</button>
</div>
</div>
</div>
</a-spin>
</div>
</template>
<style scoped>
.m-dash {
padding: 12px 14px 24px;
}
.m-dash__toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
}
.m-dash__live {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 700;
color: #059669;
background: #ecfdf5;
border-radius: 999px;
padding: 4px 10px;
}
.pulse {
width: 7px;
height: 7px;
border-radius: 50%;
background: #10b981;
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.5);
animation: pulse 1.6s infinite;
}
@keyframes pulse {
70% { box-shadow: 0 0 0 8px rgba(16, 185, 129, 0); }
100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); }
}
.m-dash__sub {
margin: 8px 2px 0;
color: #64748b;
font-size: 12px;
}
.m-icon-btn {
width: 42px;
height: 42px;
border: none;
border-radius: 12px;
background: #fff;
color: #4f46e5;
box-shadow: 0 2px 10px rgba(15, 23, 42, 0.06);
font-size: 16px;
}
.m-kpi-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-bottom: 12px;
}
.m-kpi {
background: #fff;
border-radius: 16px;
padding: 14px;
box-shadow: 0 2px 12px rgba(15, 23, 42, 0.04);
border: 1px solid #f1f5f9;
}
.m-kpi__head {
display: flex;
justify-content: space-between;
align-items: center;
color: #64748b;
font-size: 12px;
font-weight: 600;
margin-bottom: 8px;
}
.m-kpi__val {
font-size: 22px;
font-weight: 800;
color: #0f172a;
margin-bottom: 4px;
}
.m-kpi__sub {
font-size: 11px;
color: #94a3b8;
display: flex;
align-items: center;
gap: 4px;
}
.m-kpi.indigo .m-kpi__head { color: #4f46e5; }
.m-kpi.amber .m-kpi__head { color: #d97706; }
.m-kpi.emerald .m-kpi__head { color: #059669; }
.m-kpi.violet .m-kpi__head { color: #7c3aed; }
.m-quick {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-bottom: 12px;
}
.m-quick__btn {
border: 1px solid #e2e8f0;
background: #fff;
border-radius: 14px;
padding: 12px 10px;
font-size: 12px;
font-weight: 700;
color: #334155;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 48px;
}
.m-quick__btn.primary {
background: linear-gradient(135deg, #4f46e5, #7c3aed);
border-color: transparent;
color: #fff;
}
.m-card {
background: #fff;
border-radius: 16px;
padding: 14px;
margin-bottom: 12px;
border: 1px solid #f1f5f9;
box-shadow: 0 2px 12px rgba(15, 23, 42, 0.04);
}
.m-card__title {
font-size: 14px;
font-weight: 800;
color: #0f172a;
margin-bottom: 10px;
}
.m-chart {
height: 200px;
width: 100%;
}
.m-winner-row {
display: flex;
justify-content: space-between;
gap: 10px;
padding: 10px 0;
border-bottom: 1px solid #f1f5f9;
}
.m-winner-row:last-child {
border-bottom: none;
padding-bottom: 0;
}
.m-winner-row__name {
font-size: 13px;
font-weight: 700;
color: #0f172a;
}
.m-winner-row__meta {
font-size: 11px;
color: #94a3b8;
margin-top: 2px;
}
.m-winner-row__side {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
}
.m-tag {
margin: 0 !important;
font-size: 10px !important;
line-height: 18px !important;
}
.m-code {
border: none;
background: #f8fafc;
color: #4f46e5;
font-size: 10px;
font-family: monospace;
border-radius: 8px;
padding: 2px 6px;
direction: ltr;
}
</style>
+189
View File
@@ -0,0 +1,189 @@
<script setup lang="ts">
import { LockOutlined, MailOutlined } from '@ant-design/icons-vue';
import { reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useAuthStore } from '../../stores/auth.store';
const router = useRouter();
const authStore = useAuthStore();
const formState = reactive({
email: 'admin@luckywheel.local',
password: 'Admin@SecurePass2026',
});
const loading = ref(false);
async function onFinish() {
loading.value = true;
try {
await authStore.login({
email: formState.email,
password: formState.password,
});
router.push('/m/dashboard');
} catch {
// Handled by axios interceptor
} finally {
loading.value = false;
}
}
</script>
<template>
<div class="m-login">
<div class="m-login__hero">
<div class="m-login__blob blob-1" />
<div class="m-login__blob blob-2" />
<div class="m-login__brand">
<div class="m-login__logo">🎡</div>
<h1>گردونه شانس</h1>
<p>پنل مدیریت موبایل</p>
</div>
</div>
<div class="m-login__sheet">
<h2>ورود به حساب</h2>
<p class="m-login__hint">با حساب ادمین وارد شوید</p>
<a-form :model="formState" layout="vertical" @finish="onFinish">
<a-form-item
label="ایمیل"
name="email"
:rules="[
{ required: true, message: 'ایمیل را وارد کنید' },
{ type: 'email', message: 'فرمت ایمیل نامعتبر است' },
]"
>
<a-input v-model:value="formState.email" size="large" placeholder="admin@example.com">
<template #prefix><MailOutlined style="color: #6366f1" /></template>
</a-input>
</a-form-item>
<a-form-item
label="کلمه عبور"
name="password"
:rules="[{ required: true, message: 'کلمه عبور را وارد کنید' }]"
>
<a-input-password v-model:value="formState.password" size="large" placeholder="••••••••">
<template #prefix><LockOutlined style="color: #6366f1" /></template>
</a-input-password>
</a-form-item>
<a-button type="primary" html-type="submit" size="large" block :loading="loading" class="m-login__btn">
ورود
</a-button>
</a-form>
</div>
</div>
</template>
<style scoped>
.m-login {
min-height: 100vh;
min-height: 100dvh;
display: flex;
flex-direction: column;
background: #0f172a;
}
.m-login__hero {
position: relative;
flex: 0 0 auto;
padding: calc(env(safe-area-inset-top, 0px) + 48px) 24px 56px;
overflow: hidden;
background: linear-gradient(160deg, #312e81 0%, #4f46e5 45%, #7c3aed 100%);
}
.m-login__blob {
position: absolute;
border-radius: 50%;
filter: blur(40px);
opacity: 0.35;
}
.blob-1 {
width: 180px;
height: 180px;
background: #a78bfa;
top: -40px;
left: -30px;
}
.blob-2 {
width: 140px;
height: 140px;
background: #38bdf8;
bottom: 20px;
right: -20px;
}
.m-login__brand {
position: relative;
z-index: 1;
text-align: center;
color: #fff;
}
.m-login__logo {
width: 72px;
height: 72px;
margin: 0 auto 14px;
border-radius: 20px;
background: rgba(255, 255, 255, 0.18);
display: flex;
align-items: center;
justify-content: center;
font-size: 36px;
backdrop-filter: blur(8px);
}
.m-login__brand h1 {
margin: 0 0 6px;
font-size: 24px;
font-weight: 800;
}
.m-login__brand p {
margin: 0;
opacity: 0.85;
font-size: 13px;
}
.m-login__sheet {
flex: 1;
margin-top: -28px;
background: #fff;
border-radius: 28px 28px 0 0;
padding: 28px 20px calc(24px + env(safe-area-inset-bottom, 0px));
position: relative;
z-index: 2;
}
.m-login__sheet h2 {
margin: 0 0 4px;
font-size: 20px;
font-weight: 800;
color: #0f172a;
}
.m-login__hint {
margin: 0 0 22px;
color: #64748b;
font-size: 13px;
}
.m-login__btn {
height: 48px !important;
border-radius: 14px !important;
font-weight: 700 !important;
margin-top: 8px;
background: linear-gradient(135deg, #4f46e5, #7c3aed) !important;
border: none !important;
}
:deep(.ant-input-affix-wrapper),
:deep(.ant-input) {
border-radius: 12px !important;
}
</style>
@@ -0,0 +1,361 @@
<script setup lang="ts">
import {
DeleteOutlined,
EditOutlined,
PlusOutlined,
ReloadOutlined,
} from '@ant-design/icons-vue';
import { formatFarsiNumber, parseFarsiNumber, toFarsiDigits } from '@lucky-wheel/shared-types';
import { Modal, message } from 'ant-design-vue';
import { computed, onMounted, reactive, ref } from 'vue';
import { useRoute } from 'vue-router';
import { apiClient } from '../../services/api/client';
const route = useRoute();
const campaignId = ref((route.params.campaignId as string) || '');
const campaign = ref<any>(null);
const loading = ref(false);
const prizes = ref<any[]>([]);
const isFormVisible = ref(false);
const isStockVisible = ref(false);
const isSubmitting = ref(false);
const editingPrizeId = ref<string | null>(null);
const formState = reactive({
title: '',
description: '',
type: 'DISCOUNT_CODE',
stockType: 'LIMITED',
initialStock: 100,
remainingStock: 100,
weight: 10,
segmentColor: '#FF6B6B',
sortOrder: 1,
status: 'ACTIVE',
});
const stockState = reactive({
prizeId: '',
prizeTitle: '',
currentStock: 0,
change: 0,
reason: '',
});
const totalWeight = computed(() =>
prizes.value.reduce((acc, p) => acc + (p.status === 'ACTIVE' || p.isActive ? p.weight : 0), 0),
);
async function loadCampaignInfo() {
if (!campaignId.value) return;
const res = await apiClient.get(`/admin/campaigns/${campaignId.value}`);
campaign.value = res.data;
}
async function loadPrizes() {
if (!campaignId.value) return;
loading.value = true;
try {
const res = await apiClient.get(`/admin/campaigns/${campaignId.value}/prizes`);
prizes.value = Array.isArray(res.data) ? res.data : res.data?.data || [];
} finally {
loading.value = false;
}
}
function openCreate() {
editingPrizeId.value = null;
Object.assign(formState, {
title: '',
description: '',
type: 'DISCOUNT_CODE',
stockType: 'LIMITED',
initialStock: 100,
remainingStock: 100,
weight: 10,
segmentColor: '#3880ff',
sortOrder: prizes.value.length + 1,
status: 'ACTIVE',
});
isFormVisible.value = true;
}
function openEdit(record: any) {
editingPrizeId.value = record.id;
formState.title = record.title;
formState.description = record.description || '';
formState.type = record.type || 'DISCOUNT_CODE';
formState.stockType = record.stockType || 'LIMITED';
formState.initialStock = record.initialStock ?? 100;
formState.remainingStock = record.remainingStock ?? 100;
formState.weight = record.weight ?? 10;
formState.segmentColor = record.segmentColor || record.color || '#3880ff';
formState.sortOrder = record.sortOrder ?? 1;
formState.status = record.status || 'ACTIVE';
isFormVisible.value = true;
}
function openStock(record: any) {
stockState.prizeId = record.id;
stockState.prizeTitle = record.title;
stockState.currentStock = record.remainingStock;
stockState.change = 0;
stockState.reason = '';
isStockVisible.value = true;
}
async function submitForm() {
if (!formState.title?.trim()) {
message.error('عنوان جایزه الزامی است');
return;
}
isSubmitting.value = true;
try {
if (formState.type === 'NO_PRIZE') formState.stockType = 'UNLIMITED';
const payload: any = {
title: formState.title.trim(),
description: formState.description?.trim() || undefined,
type: formState.type,
stockType: formState.stockType,
weight: parseFarsiNumber(formState.weight, 1) || 1,
segmentColor: formState.segmentColor,
sortOrder: parseFarsiNumber(formState.sortOrder, 1) || 1,
status: formState.status,
};
if (formState.stockType === 'LIMITED') {
payload.initialStock = parseFarsiNumber(formState.initialStock, 1);
payload.remainingStock = parseFarsiNumber(formState.remainingStock, payload.initialStock);
}
if (editingPrizeId.value) {
await apiClient.patch(`/admin/prizes/${editingPrizeId.value}`, payload);
message.success('جایزه به‌روزرسانی شد');
} else {
await apiClient.post(`/admin/campaigns/${campaignId.value}/prizes`, payload);
message.success('جایزه ایجاد شد');
}
isFormVisible.value = false;
loadPrizes();
} catch (err: any) {
const errorMsg = err.response?.data?.message || 'خطا در ثبت جایزه';
message.error(Array.isArray(errorMsg) ? errorMsg.join(' | ') : errorMsg);
} finally {
isSubmitting.value = false;
}
}
async function submitStock() {
const changeVal = parseFarsiNumber(stockState.change, 0);
if (!changeVal) {
message.warning('میزان تغییر نباید صفر باشد');
return;
}
isSubmitting.value = true;
try {
await apiClient.patch(`/admin/prizes/${stockState.prizeId}/stock`, {
change: changeVal,
reason: stockState.reason || undefined,
});
message.success('موجودی تغییر کرد');
isStockVisible.value = false;
loadPrizes();
} finally {
isSubmitting.value = false;
}
}
function confirmDelete(record: any) {
Modal.confirm({
title: 'حذف جایزه',
content: `جایزه «${record.title}» حذف شود؟`,
okText: 'حذف',
okType: 'danger',
cancelText: 'انصراف',
onOk: async () => {
await apiClient.delete(`/admin/prizes/${record.id}`);
message.success('حذف شد');
loadPrizes();
},
});
}
function typeLabel(type: string) {
const map: Record<string, string> = {
PHYSICAL: 'فیزیکی',
DISCOUNT_CODE: 'کد تخفیف',
CREDIT: 'اعتبار',
CASH: 'نقدی',
NO_PRIZE: 'پوچ',
OTHER: 'سایر',
};
return map[type] || type;
}
function weightPct(p: any) {
if (!totalWeight.value) return '۰';
return toFarsiDigits(((p.weight / totalWeight.value) * 100).toFixed(1));
}
onMounted(() => {
loadCampaignInfo();
loadPrizes();
});
</script>
<template>
<div class="m-page">
<div class="m-banner" v-if="campaign">
<div>
<div class="m-banner__title">{{ campaign.title }}</div>
<div class="m-banner__sub">مدیریت جوایز کمپین</div>
</div>
<button type="button" class="m-icon-btn" @click="loadPrizes">
<ReloadOutlined :spin="loading" />
</button>
</div>
<a-spin :spinning="loading">
<div v-if="!prizes.length && !loading" class="m-empty">هنوز جایزه‌ای تعریف نشده</div>
<div v-for="p in prizes" :key="p.id" class="m-prize-card">
<div class="m-prize-card__color" :style="{ background: p.segmentColor || p.color || '#4f46e5' }" />
<div class="m-prize-card__body">
<div class="m-prize-card__top">
<div class="m-prize-card__title">{{ p.title }}</div>
<a-tag :color="p.status === 'ACTIVE' || p.isActive ? 'green' : 'default'">
{{ p.status === 'ACTIVE' || p.isActive ? 'فعال' : 'غیرفعال' }}
</a-tag>
</div>
<div class="m-prize-card__meta">
{{ typeLabel(p.type) }} · وزن {{ formatFarsiNumber(p.weight) }} ({{ weightPct(p) }}٪)
</div>
<div class="m-prize-card__meta">
موجودی:
{{
p.stockType === 'UNLIMITED'
? 'نامحدود'
: formatFarsiNumber(p.remainingStock ?? 0) + ' / ' + formatFarsiNumber(p.initialStock ?? 0)
}}
</div>
<div class="m-prize-card__actions">
<button type="button" class="m-action" @click="openEdit(p)"><EditOutlined /> ویرایش</button>
<button
v-if="p.stockType !== 'UNLIMITED'"
type="button"
class="m-action"
@click="openStock(p)"
>
موجودی
</button>
<button type="button" class="m-action danger" @click="confirmDelete(p)">
<DeleteOutlined />
</button>
</div>
</div>
</div>
</a-spin>
<button type="button" class="m-fab" @click="openCreate"><PlusOutlined /></button>
<a-drawer v-model:open="isFormVisible" placement="bottom" :height="'85%'" title="جایزه" :closable="true">
<a-form layout="vertical">
<a-form-item label="عنوان" required>
<a-input v-model:value="formState.title" size="large" />
</a-form-item>
<a-form-item label="نوع">
<a-select v-model:value="formState.type" size="large" style="width: 100%">
<a-select-option value="DISCOUNT_CODE">کد تخفیف</a-select-option>
<a-select-option value="PHYSICAL">فیزیکی</a-select-option>
<a-select-option value="CREDIT">اعتبار</a-select-option>
<a-select-option value="CASH">نقدی</a-select-option>
<a-select-option value="NO_PRIZE">پوچ</a-select-option>
<a-select-option value="OTHER">سایر</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="نوع موجودی">
<a-select v-model:value="formState.stockType" size="large" style="width: 100%">
<a-select-option value="LIMITED">محدود</a-select-option>
<a-select-option value="UNLIMITED">نامحدود</a-select-option>
</a-select>
</a-form-item>
<a-form-item v-if="formState.stockType === 'LIMITED'" label="موجودی اولیه">
<a-input-number v-model:value="formState.initialStock" :min="0" size="large" style="width: 100%" />
</a-form-item>
<a-form-item v-if="formState.stockType === 'LIMITED'" label="موجودی مانده">
<a-input-number v-model:value="formState.remainingStock" :min="0" size="large" style="width: 100%" />
</a-form-item>
<a-form-item label="وزن">
<a-input-number v-model:value="formState.weight" :min="1" size="large" style="width: 100%" />
</a-form-item>
<a-form-item label="رنگ سگمنت">
<a-input v-model:value="formState.segmentColor" type="color" style="width: 100%; height: 44px" />
</a-form-item>
<a-form-item label="وضعیت">
<a-select v-model:value="formState.status" size="large" style="width: 100%">
<a-select-option value="ACTIVE">فعال</a-select-option>
<a-select-option value="INACTIVE">غیرفعال</a-select-option>
</a-select>
</a-form-item>
<a-button type="primary" block size="large" :loading="isSubmitting" @click="submitForm">
ذخیره
</a-button>
</a-form>
</a-drawer>
<a-drawer v-model:open="isStockVisible" placement="bottom" :height="360" title="تغییر موجودی">
<p>{{ stockState.prizeTitle }} — فعلی: {{ formatFarsiNumber(stockState.currentStock) }}</p>
<a-form layout="vertical">
<a-form-item label="میزان تغییر (+/-)">
<a-input-number v-model:value="stockState.change" size="large" style="width: 100%" />
</a-form-item>
<a-form-item label="دلیل">
<a-input v-model:value="stockState.reason" size="large" />
</a-form-item>
<a-button type="primary" block size="large" :loading="isSubmitting" @click="submitStock">
اعمال
</a-button>
</a-form>
</a-drawer>
</div>
</template>
<style scoped>
.m-page { padding: 12px 14px 88px; }
.m-banner {
display: flex;
justify-content: space-between;
align-items: center;
background: #fff;
border-radius: 16px;
padding: 14px;
margin-bottom: 12px;
border: 1px solid #f1f5f9;
}
.m-banner__title { font-weight: 800; font-size: 15px; color: #0f172a; }
.m-banner__sub { font-size: 11px; color: #94a3b8; margin-top: 2px; }
.m-icon-btn {
width: 40px; height: 40px; border: none; border-radius: 12px;
background: #eef2ff; color: #4f46e5; font-size: 15px;
}
.m-empty { text-align: center; color: #94a3b8; padding: 40px 12px; }
.m-prize-card {
display: flex; gap: 0; background: #fff; border-radius: 16px;
overflow: hidden; margin-bottom: 10px; border: 1px solid #f1f5f9;
}
.m-prize-card__color { width: 8px; flex-shrink: 0; }
.m-prize-card__body { flex: 1; padding: 12px; }
.m-prize-card__top { display: flex; justify-content: space-between; gap: 8px; margin-bottom: 6px; }
.m-prize-card__title { font-weight: 800; font-size: 14px; color: #0f172a; }
.m-prize-card__meta { font-size: 11px; color: #64748b; margin-bottom: 4px; }
.m-prize-card__actions { display: flex; gap: 6px; margin-top: 10px; }
.m-action {
flex: 1; border: 1px solid #e2e8f0; background: #f8fafc; border-radius: 10px;
padding: 10px; font-size: 11px; font-weight: 700; color: #334155; min-height: 42px;
}
.m-action.danger { color: #ef4444; flex: 0 0 48px; }
.m-fab {
position: fixed; left: 18px; bottom: calc(80px + env(safe-area-inset-bottom, 0px));
width: 56px; height: 56px; border: none; border-radius: 18px;
background: linear-gradient(135deg, #4f46e5, #7c3aed); color: #fff; font-size: 22px;
box-shadow: 0 10px 24px rgba(79, 70, 229, 0.4); z-index: 90;
}
</style>
+125
View File
@@ -0,0 +1,125 @@
<script setup lang="ts">
import {
GiftOutlined,
ReloadOutlined,
TeamOutlined,
ThunderboltOutlined,
TrophyOutlined,
UserOutlined,
} from '@ant-design/icons-vue';
import { formatFarsiNumber } from '@lucky-wheel/shared-types';
import { onMounted, reactive, ref } from 'vue';
import { apiClient } from '../../services/api/client';
const loading = ref(false);
const report = ref<any>(null);
const campaigns = ref<any[]>([]);
const filters = reactive({
campaignId: undefined as string | undefined,
});
async function loadCampaigns() {
const res = await apiClient.get('/admin/campaigns', { params: { pageSize: 100 } });
campaigns.value = Array.isArray(res.data) ? res.data : res.data?.data || [];
}
async function loadReport() {
loading.value = true;
try {
const res = await apiClient.get('/admin/reports/participation', {
params: { campaignId: filters.campaignId || undefined },
});
report.value = res.data;
} finally {
loading.value = false;
}
}
onMounted(() => {
loadCampaigns();
loadReport();
});
</script>
<template>
<div class="m-page">
<div class="m-filters">
<a-select
v-model:value="filters.campaignId"
allow-clear
placeholder="همه کمپین‌ها"
style="flex: 1"
@change="loadReport"
>
<a-select-option v-for="c in campaigns" :key="c.id" :value="c.id">{{ c.title }}</a-select-option>
</a-select>
<button type="button" class="m-icon-btn" @click="loadReport">
<ReloadOutlined :spin="loading" />
</button>
</div>
<a-spin :spinning="loading">
<div class="m-kpi-grid">
<div class="m-kpi">
<UserOutlined class="ico indigo" />
<div class="val">{{ formatFarsiNumber(report?.totalUsers || 0) }}</div>
<div class="lbl">کل کاربران</div>
</div>
<div class="m-kpi">
<ThunderboltOutlined class="ico amber" />
<div class="val">{{ formatFarsiNumber(report?.totalSpins || 0) }}</div>
<div class="lbl">کل اسپین</div>
</div>
<div class="m-kpi">
<TrophyOutlined class="ico emerald" />
<div class="val">{{ formatFarsiNumber(report?.winnersCount || 0) }}</div>
<div class="lbl">برندگان</div>
</div>
<div class="m-kpi">
<GiftOutlined class="ico violet" />
<div class="val">{{ formatFarsiNumber(report?.totalChancesAllocated || 0) }}</div>
<div class="lbl">شانس تخصیص</div>
</div>
<div class="m-kpi">
<TeamOutlined class="ico blue" />
<div class="val">{{ formatFarsiNumber(report?.totalChancesUsed || 0) }}</div>
<div class="lbl">شانس مصرف</div>
</div>
<div class="m-kpi">
<UserOutlined class="ico slate" />
<div class="val">{{ formatFarsiNumber(report?.zeroSpinUsers || 0) }}</div>
<div class="lbl">بدون اسپین</div>
</div>
<div class="m-kpi wide">
<ThunderboltOutlined class="ico indigo" />
<div class="val">{{ formatFarsiNumber(report?.remainingChanceUsers || 0) }}</div>
<div class="lbl">کاربران با شانس باقی‌مانده</div>
</div>
</div>
</a-spin>
</div>
</template>
<style scoped>
.m-page { padding: 12px 14px 24px; }
.m-filters { display: flex; gap: 8px; margin-bottom: 12px; }
.m-filters :deep(.ant-select-selector) { border-radius: 12px !important; min-height: 42px !important; }
.m-icon-btn {
width: 42px; height: 42px; border: none; border-radius: 12px; background: #fff; color: #4f46e5;
}
.m-kpi-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.m-kpi {
background: #fff; border-radius: 16px; padding: 16px 12px; text-align: center;
border: 1px solid #f1f5f9; box-shadow: 0 2px 10px rgba(15,23,42,.04);
}
.m-kpi.wide { grid-column: 1 / -1; }
.ico { font-size: 18px; margin-bottom: 8px; }
.ico.indigo { color: #4f46e5; }
.ico.amber { color: #d97706; }
.ico.emerald { color: #059669; }
.ico.violet { color: #7c3aed; }
.ico.blue { color: #2563eb; }
.ico.slate { color: #64748b; }
.val { font-size: 22px; font-weight: 800; color: #0f172a; }
.lbl { font-size: 11px; color: #64748b; margin-top: 4px; font-weight: 600; }
</style>
+141
View File
@@ -0,0 +1,141 @@
<script setup lang="ts">
import { ReloadOutlined, SendOutlined } from '@ant-design/icons-vue';
import { toFarsiDigits } from '@lucky-wheel/shared-types';
import { message } from 'ant-design-vue';
import { onMounted, reactive, ref } from 'vue';
import { apiClient } from '../../services/api/client';
const loading = ref(false);
const logs = ref<any[]>([]);
const total = ref(0);
const resendingId = ref<string | null>(null);
const filters = reactive({
page: 1,
pageSize: 10,
mobile: '',
status: undefined as string | undefined,
});
const statusMap: Record<string, { text: string; color: string }> = {
SENT: { text: 'ارسال شد', color: 'success' },
QUEUED: { text: 'صف', color: 'processing' },
FAILED: { text: 'ناموفق', color: 'error' },
};
async function loadLogs() {
loading.value = true;
try {
const res = await apiClient.get('/admin/sms-logs', {
params: {
page: filters.page,
pageSize: filters.pageSize,
mobile: filters.mobile || undefined,
status: filters.status || undefined,
},
});
const items = Array.isArray(res.data) ? res.data : res.data?.data || [];
logs.value = items;
total.value = res.meta?.total || res.data?.meta?.total || items.length;
} finally {
loading.value = false;
}
}
async function resend(id: string) {
resendingId.value = id;
try {
await apiClient.post(`/admin/sms-logs/${id}/resend`);
message.success('ارسال مجدد انجام شد');
loadLogs();
} finally {
resendingId.value = null;
}
}
function onSearch() {
filters.page = 1;
loadLogs();
}
function formatDate(val?: string) {
if (!val) return '—';
return toFarsiDigits(
new Date(val).toLocaleDateString('fa-IR', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}),
);
}
onMounted(loadLogs);
</script>
<template>
<div class="m-page">
<div class="m-filters">
<a-input v-model:value="filters.mobile" allow-clear placeholder="شماره موبایل..." @pressEnter="onSearch" />
<div class="m-filters__row">
<a-select v-model:value="filters.status" allow-clear placeholder="وضعیت" style="flex:1" @change="onSearch">
<a-select-option value="SENT">ارسال شد</a-select-option>
<a-select-option value="QUEUED">صف</a-select-option>
<a-select-option value="FAILED">ناموفق</a-select-option>
</a-select>
<button type="button" class="m-icon-btn" @click="loadLogs"><ReloadOutlined :spin="loading" /></button>
</div>
</div>
<a-spin :spinning="loading">
<div v-if="!logs.length && !loading" class="m-empty">لاگی یافت نشد</div>
<div v-for="log in logs" :key="log.id" class="m-card">
<div class="m-card__top">
<span class="mobile" dir="ltr">{{ toFarsiDigits(log.mobile) }}</span>
<a-tag :color="statusMap[log.status]?.color || 'default'">
{{ statusMap[log.status]?.text || log.status }}
</a-tag>
</div>
<div class="m-card__msg">{{ log.message }}</div>
<div class="m-card__meta">
<span>{{ log.provider || '—' }}</span>
<span>{{ formatDate(log.createdAt) }}</span>
</div>
<div v-if="log.errorMessage" class="m-card__err">{{ log.errorMessage }}</div>
<button
v-if="log.status === 'FAILED'"
type="button"
class="m-resend"
:disabled="resendingId === log.id"
@click="resend(log.id)"
>
<SendOutlined /> ارسال مجدد
</button>
</div>
<div v-if="total > filters.pageSize" class="m-pager">
<a-pagination v-model:current="filters.page" :total="total" :page-size="filters.pageSize" size="small" @change="loadLogs" />
</div>
</a-spin>
</div>
</template>
<style scoped>
.m-page { padding: 12px 14px 24px; }
.m-filters { display: flex; flex-direction: column; gap: 8px; margin-bottom: 12px; }
.m-filters__row { display: flex; gap: 8px; }
.m-filters :deep(.ant-input),
.m-filters :deep(.ant-select-selector) { border-radius: 12px !important; min-height: 42px !important; }
.m-icon-btn { width: 42px; height: 42px; border: none; border-radius: 12px; background: #fff; color: #4f46e5; }
.m-empty { text-align: center; color: #94a3b8; padding: 40px; }
.m-card { background: #fff; border-radius: 14px; padding: 12px; margin-bottom: 8px; border: 1px solid #f1f5f9; }
.m-card__top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.mobile { font-weight: 800; color: #0f172a; font-size: 13px; }
.m-card__msg { font-size: 12px; color: #334155; line-height: 1.6; margin-bottom: 8px; }
.m-card__meta { display: flex; justify-content: space-between; font-size: 11px; color: #94a3b8; }
.m-card__err { margin-top: 8px; font-size: 11px; color: #ef4444; background: #fef2f2; padding: 8px; border-radius: 8px; }
.m-resend {
margin-top: 10px; width: 100%; border: none; background: #eef2ff; color: #4f46e5;
border-radius: 10px; padding: 10px; font-weight: 700; font-size: 12px; min-height: 42px;
}
.m-pager { display: flex; justify-content: center; padding: 12px 0; }
</style>
@@ -0,0 +1,156 @@
<script setup lang="ts">
import { ReloadOutlined, SearchOutlined } from '@ant-design/icons-vue';
import { toFarsiDigits } from '@lucky-wheel/shared-types';
import { onMounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { apiClient } from '../../services/api/client';
const router = useRouter();
const loading = ref(false);
const spins = ref<any[]>([]);
const total = ref(0);
const campaigns = ref<any[]>([]);
const filters = reactive({
page: 1,
pageSize: 10,
campaignId: undefined as string | undefined,
search: '',
});
async function loadCampaigns() {
const res = await apiClient.get('/admin/campaigns', { params: { pageSize: 100 } });
campaigns.value = Array.isArray(res.data) ? res.data : res.data?.data || [];
}
async function loadSpins() {
loading.value = true;
try {
const res = await apiClient.get('/admin/spins', {
params: {
page: filters.page,
pageSize: filters.pageSize,
campaignId: filters.campaignId || undefined,
search: filters.search || undefined,
},
});
const items = Array.isArray(res.data) ? res.data : res.data?.data || [];
spins.value = items;
total.value = res.meta?.total || res.data?.meta?.total || items.length;
} finally {
loading.value = false;
}
}
function onSearch() {
filters.page = 1;
loadSpins();
}
function formatDate(val?: string) {
if (!val) return '—';
return toFarsiDigits(
new Date(val).toLocaleDateString('fa-IR', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}),
);
}
onMounted(() => {
loadCampaigns();
loadSpins();
});
</script>
<template>
<div class="m-page">
<div class="m-filters">
<a-input
v-model:value="filters.search"
allow-clear
placeholder="موبایل کاربر..."
@pressEnter="onSearch"
>
<template #prefix><SearchOutlined style="color: #94a3b8" /></template>
</a-input>
<div class="m-filters__row">
<a-select
v-model:value="filters.campaignId"
allow-clear
placeholder="کمپین"
style="flex: 1"
@change="onSearch"
>
<a-select-option v-for="c in campaigns" :key="c.id" :value="c.id">{{ c.title }}</a-select-option>
</a-select>
<button type="button" class="m-icon-btn" @click="loadSpins"><ReloadOutlined :spin="loading" /></button>
</div>
</div>
<a-spin :spinning="loading">
<div v-if="!spins.length && !loading" class="m-empty">اسپینی یافت نشد</div>
<div v-for="s in spins" :key="s.id" class="m-card">
<div class="m-card__top">
<button
type="button"
class="m-card__user"
@click="s.user?.id && router.push(`/m/users/${s.user.id}`)"
>
{{ s.user?.name || toFarsiDigits(s.user?.mobile || '—') }}
</button>
<span class="m-pill" :class="s.isWin ? 'win' : 'lose'">{{ s.isWin ? 'برد' : 'باخت' }}</span>
</div>
<div class="m-card__line">{{ s.campaign?.title || '—' }}</div>
<div class="m-card__line">جایزه: {{ s.prize?.title || '—' }}</div>
<div class="m-card__meta">
<span dir="ltr">{{ s.ipAddress || '—' }}</span>
<span>{{ formatDate(s.createdAt) }}</span>
</div>
</div>
<div v-if="total > filters.pageSize" class="m-pager">
<a-pagination
v-model:current="filters.page"
:total="total"
:page-size="filters.pageSize"
size="small"
@change="loadSpins"
/>
</div>
</a-spin>
</div>
</template>
<style scoped>
.m-page { padding: 12px 14px 24px; }
.m-filters { display: flex; flex-direction: column; gap: 8px; margin-bottom: 12px; }
.m-filters__row { display: flex; gap: 8px; }
.m-filters :deep(.ant-input-affix-wrapper),
.m-filters :deep(.ant-select-selector) {
border-radius: 12px !important; min-height: 42px !important;
}
.m-icon-btn {
width: 42px; height: 42px; border: none; border-radius: 12px;
background: #fff; color: #4f46e5; box-shadow: 0 2px 8px rgba(15,23,42,.06);
}
.m-empty { text-align: center; color: #94a3b8; padding: 40px; }
.m-card {
background: #fff; border-radius: 14px; padding: 12px; margin-bottom: 8px; border: 1px solid #f1f5f9;
}
.m-card__top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
.m-card__user {
border: none; background: transparent; padding: 0; font-weight: 800; color: #4f46e5; font-size: 13px;
}
.m-pill {
font-size: 10px; font-weight: 700; padding: 3px 8px; border-radius: 999px;
}
.m-pill.win { background: #ecfdf5; color: #059669; }
.m-pill.lose { background: #f1f5f9; color: #64748b; }
.m-card__line { font-size: 12px; color: #334155; margin-bottom: 2px; }
.m-card__meta {
display: flex; justify-content: space-between; margin-top: 8px; font-size: 11px; color: #94a3b8;
}
.m-pager { display: flex; justify-content: center; padding: 12px 0; }
</style>
@@ -0,0 +1,231 @@
<script setup lang="ts">
import { PlusCircleOutlined, ReloadOutlined } from '@ant-design/icons-vue';
import { formatFarsiNumber, toFarsiDigits } from '@lucky-wheel/shared-types';
import { message } from 'ant-design-vue';
import { onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { apiClient } from '../../services/api/client';
const route = useRoute();
const userId = ref((route.params.id as string) || '');
const loading = ref(false);
const user = ref<any>(null);
const tab = ref('chances');
const allCampaigns = ref<any[]>([]);
const isChanceVisible = ref(false);
const isSubmittingChance = ref(false);
const selectedCampaignId = ref('');
const chanceAmount = ref(1);
const chanceReason = ref('');
async function loadCampaigns() {
const res = await apiClient.get('/admin/campaigns', { params: { pageSize: 100 } });
allCampaigns.value = Array.isArray(res.data) ? res.data : res.data?.data || [];
}
async function loadUser() {
if (!userId.value) return;
loading.value = true;
try {
const res = await apiClient.get(`/admin/users/${userId.value}`);
user.value = res.data;
} finally {
loading.value = false;
}
}
function openChance(campaignId?: string) {
selectedCampaignId.value =
campaignId ||
user.value?.userCampaigns?.[0]?.campaignId ||
allCampaigns.value?.[0]?.id ||
'';
chanceAmount.value = 1;
chanceReason.value = 'اهدای شانس تشویقی توسط مدیر';
isChanceVisible.value = true;
}
async function submitChance() {
if (!selectedCampaignId.value) {
message.error('انتخاب کمپین الزامی است');
return;
}
isSubmittingChance.value = true;
try {
await apiClient.post('/admin/chances/adjust', {
userId: userId.value,
campaignId: selectedCampaignId.value,
amount: chanceAmount.value,
reason: chanceReason.value,
});
message.success('شانس تغییر کرد');
isChanceVisible.value = false;
loadUser();
} finally {
isSubmittingChance.value = false;
}
}
function formatDate(val?: string) {
if (!val) return '—';
return toFarsiDigits(
new Date(val).toLocaleDateString('fa-IR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}),
);
}
onMounted(() => {
loadUser();
loadCampaigns();
});
</script>
<template>
<div class="m-page">
<a-spin :spinning="loading">
<div class="m-profile" v-if="user">
<div class="m-profile__avatar">{{ (user.name || user.mobile || '؟').charAt(0) }}</div>
<div class="m-profile__name">{{ user.name || 'بدون نام' }}</div>
<div class="m-profile__mobile">{{ toFarsiDigits(user.mobile) }}</div>
<div class="m-profile__meta">
<span :class="user.status === 'BLOCKED' || user.isBlocked ? 'bad' : 'ok'">
{{ user.status === 'BLOCKED' || user.isBlocked ? 'مسدود' : 'فعال' }}
</span>
<span>عضویت {{ formatDate(user.createdAt) }}</span>
</div>
<div class="m-profile__actions">
<button type="button" class="m-btn ghost" @click="loadUser"><ReloadOutlined /> بروزرسانی</button>
<button type="button" class="m-btn primary" @click="openChance()">
<PlusCircleOutlined /> افزودن شانس
</button>
</div>
</div>
<div class="m-tabs">
<button type="button" :class="{ active: tab === 'chances' }" @click="tab = 'chances'">شانس‌ها</button>
<button type="button" :class="{ active: tab === 'prizes' }" @click="tab = 'prizes'">جوایز</button>
<button type="button" :class="{ active: tab === 'spins' }" @click="tab = 'spins'">اسپین‌ها</button>
</div>
<div v-if="tab === 'chances'">
<div
v-for="uc in user?.userCampaigns || []"
:key="uc.id || uc.campaignId"
class="m-card"
>
<div class="m-card__title">{{ uc.campaign?.title || 'کمپین' }}</div>
<div class="m-card__row">
<span>باقی‌مانده</span>
<strong>{{ formatFarsiNumber(uc.remainingChances || 0) }}</strong>
</div>
<div class="m-card__row">
<span>کل / مصرف</span>
<strong>
{{ formatFarsiNumber(uc.totalChances || 0) }} /
{{ formatFarsiNumber(uc.usedChances || 0) }}
</strong>
</div>
<button type="button" class="m-link" @click="openChance(uc.campaignId || uc.campaign?.id)">
تنظیم شانس
</button>
</div>
<div v-if="!(user?.userCampaigns || []).length" class="m-empty">شانسی ثبت نشده</div>
</div>
<div v-if="tab === 'prizes'">
<div v-for="p in user?.prizes || []" :key="p.id" class="m-card">
<div class="m-card__title">{{ p.prize?.title || '—' }}</div>
<div class="m-card__row"><span>کد</span><strong dir="ltr">{{ p.code || '—' }}</strong></div>
<div class="m-card__row"><span>وضعیت</span><strong>{{ p.status }}</strong></div>
<div class="m-card__row"><span>تاریخ</span><strong>{{ formatDate(p.createdAt) }}</strong></div>
</div>
<div v-if="!(user?.prizes || []).length" class="m-empty">جایزه‌ای نیست</div>
</div>
<div v-if="tab === 'spins'">
<div v-for="s in user?.spins || []" :key="s.id" class="m-card">
<div class="m-card__title">{{ s.campaign?.title || '—' }}</div>
<div class="m-card__row">
<span>نتیجه</span>
<strong>{{ s.isWin ? 'برد' : 'باخت' }} · {{ s.prize?.title || '—' }}</strong>
</div>
<div class="m-card__row"><span>IP</span><strong dir="ltr">{{ s.ipAddress || '—' }}</strong></div>
<div class="m-card__row"><span>زمان</span><strong>{{ formatDate(s.createdAt) }}</strong></div>
</div>
<div v-if="!(user?.spins || []).length" class="m-empty">اسپینی نیست</div>
</div>
</a-spin>
<a-drawer v-model:open="isChanceVisible" placement="bottom" :height="420" title="تنظیم شانس">
<a-form layout="vertical">
<a-form-item label="کمپین">
<a-select v-model:value="selectedCampaignId" size="large" style="width: 100%">
<a-select-option v-for="c in allCampaigns" :key="c.id" :value="c.id">{{ c.title }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="مقدار">
<a-input-number v-model:value="chanceAmount" size="large" style="width: 100%" />
</a-form-item>
<a-form-item label="دلیل">
<a-input v-model:value="chanceReason" size="large" />
</a-form-item>
<a-button type="primary" block size="large" :loading="isSubmittingChance" @click="submitChance">
اعمال
</a-button>
</a-form>
</a-drawer>
</div>
</template>
<style scoped>
.m-page { padding: 12px 14px 24px; }
.m-profile {
background: #fff; border-radius: 18px; padding: 20px 16px; text-align: center;
border: 1px solid #f1f5f9; margin-bottom: 12px;
}
.m-profile__avatar {
width: 64px; height: 64px; margin: 0 auto 10px; border-radius: 20px;
background: linear-gradient(135deg, #4f46e5, #7c3aed); color: #fff;
display: flex; align-items: center; justify-content: center; font-size: 24px; font-weight: 800;
}
.m-profile__name { font-size: 17px; font-weight: 800; color: #0f172a; }
.m-profile__mobile { font-size: 13px; color: #64748b; direction: ltr; margin-top: 4px; }
.m-profile__meta {
display: flex; justify-content: center; gap: 10px; margin-top: 10px; font-size: 11px; color: #94a3b8;
}
.m-profile__meta .ok { color: #059669; font-weight: 700; }
.m-profile__meta .bad { color: #ef4444; font-weight: 700; }
.m-profile__actions { display: flex; gap: 8px; margin-top: 14px; }
.m-btn {
flex: 1; border: none; border-radius: 12px; min-height: 44px; font-weight: 700; font-size: 12px;
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
}
.m-btn.ghost { background: #f1f5f9; color: #334155; }
.m-btn.primary { background: linear-gradient(135deg, #4f46e5, #7c3aed); color: #fff; }
.m-tabs {
display: flex; background: #fff; border-radius: 14px; padding: 4px; margin-bottom: 12px; gap: 4px;
}
.m-tabs button {
flex: 1; border: none; background: transparent; border-radius: 10px; padding: 10px;
font-size: 12px; font-weight: 700; color: #64748b; min-height: 40px;
}
.m-tabs button.active { background: #eef2ff; color: #4f46e5; }
.m-card {
background: #fff; border-radius: 14px; padding: 14px; margin-bottom: 8px; border: 1px solid #f1f5f9;
}
.m-card__title { font-weight: 800; font-size: 14px; margin-bottom: 8px; color: #0f172a; }
.m-card__row {
display: flex; justify-content: space-between; font-size: 12px; color: #64748b; margin-bottom: 4px;
}
.m-card__row strong { color: #0f172a; }
.m-link {
margin-top: 8px; border: none; background: #eef2ff; color: #4f46e5; border-radius: 10px;
width: 100%; padding: 10px; font-weight: 700; font-size: 12px;
}
.m-empty { text-align: center; color: #94a3b8; padding: 28px; font-size: 13px; }
</style>
@@ -0,0 +1,253 @@
<script setup lang="ts">
import {
EyeOutlined,
LockOutlined,
PlusCircleOutlined,
SearchOutlined,
UnlockOutlined,
} from '@ant-design/icons-vue';
import { formatFarsiNumber, toFarsiDigits } from '@lucky-wheel/shared-types';
import { Modal, message } from 'ant-design-vue';
import { onMounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { apiClient } from '../../services/api/client';
const router = useRouter();
const loading = ref(false);
const users = ref<any[]>([]);
const total = ref(0);
const campaigns = ref<any[]>([]);
const isChanceVisible = ref(false);
const isSubmittingChance = ref(false);
const filters = reactive({
page: 1,
pageSize: 10,
search: '',
status: undefined as string | undefined,
});
const chanceForm = reactive({
userId: '',
userMobile: '',
campaignId: '',
amount: 1,
reason: '',
});
async function loadCampaigns() {
const res = await apiClient.get('/admin/campaigns', { params: { pageSize: 100 } });
campaigns.value = Array.isArray(res.data) ? res.data : res.data?.data || [];
}
async function loadUsers() {
loading.value = true;
try {
const res = await apiClient.get('/admin/users', {
params: {
page: filters.page,
pageSize: filters.pageSize,
search: filters.search || undefined,
status: filters.status || undefined,
},
});
const items = Array.isArray(res.data) ? res.data : res.data?.data || [];
users.value = items;
total.value = res.meta?.total || res.data?.meta?.total || items.length;
} finally {
loading.value = false;
}
}
function confirmToggleBlock(record: any) {
const isBlocked = record.status === 'BLOCKED';
Modal.confirm({
title: isBlocked ? 'رفع مسدودی' : 'مسدودسازی',
content: `${toFarsiDigits(record.mobile)} را ${isBlocked ? 'رفع مسدودی' : 'مسدود'} می‌کنید؟`,
okText: 'تأیید',
okType: isBlocked ? 'primary' : 'danger',
cancelText: 'انصراف',
onOk: async () => {
await apiClient.patch(`/admin/users/${record.id}/block`, {
blocked: !isBlocked,
reason: isBlocked ? 'رفع مسدودی توسط مدیر' : 'مسدودسازی توسط مدیر',
});
message.success(isBlocked ? 'مسدودی رفع شد' : 'کاربر مسدود شد');
loadUsers();
},
});
}
function openChance(record: any) {
chanceForm.userId = record.id;
chanceForm.userMobile = record.mobile;
chanceForm.campaignId = campaigns.value[0]?.id || '';
chanceForm.amount = 1;
chanceForm.reason = 'اهدای شانس توسط مدیر';
isChanceVisible.value = true;
}
async function submitChance() {
if (!chanceForm.campaignId) {
message.error('کمپین را انتخاب کنید');
return;
}
isSubmittingChance.value = true;
try {
await apiClient.post('/admin/chances/adjust', {
userId: chanceForm.userId,
campaignId: chanceForm.campaignId,
amount: chanceForm.amount,
reason: chanceForm.reason,
});
message.success('شانس تنظیم شد');
isChanceVisible.value = false;
} finally {
isSubmittingChance.value = false;
}
}
function onSearch() {
filters.page = 1;
loadUsers();
}
function formatDate(val?: string) {
if (!val) return '—';
return toFarsiDigits(new Date(val).toLocaleDateString('fa-IR'));
}
onMounted(() => {
loadUsers();
loadCampaigns();
});
</script>
<template>
<div class="m-page">
<div class="m-search-bar">
<a-input
v-model:value="filters.search"
allow-clear
placeholder="موبایل یا نام..."
@pressEnter="onSearch"
>
<template #prefix><SearchOutlined style="color: #94a3b8" /></template>
</a-input>
<a-select
v-model:value="filters.status"
allow-clear
placeholder="وضعیت"
style="width: 110px"
@change="onSearch"
>
<a-select-option value="ACTIVE">فعال</a-select-option>
<a-select-option value="BLOCKED">مسدود</a-select-option>
</a-select>
</div>
<a-spin :spinning="loading">
<div v-if="!users.length && !loading" class="m-empty">کاربری یافت نشد</div>
<div v-for="u in users" :key="u.id" class="m-user-card">
<div class="m-user-card__avatar">{{ (u.name || u.mobile || '؟').charAt(0) }}</div>
<div class="m-user-card__body">
<div class="m-user-card__top">
<div>
<div class="m-user-card__name">{{ u.name || 'بدون نام' }}</div>
<div class="m-user-card__mobile">{{ toFarsiDigits(u.mobile) }}</div>
</div>
<span class="m-badge" :class="u.status === 'BLOCKED' ? 'blocked' : 'active'">
{{ u.status === 'BLOCKED' ? 'مسدود' : 'فعال' }}
</span>
</div>
<div class="m-user-card__meta">
{{ formatFarsiNumber(u._count?.spins || 0) }} اسپین ·
{{ formatFarsiNumber(u._count?.userPrizes || u._count?.prizes || 0) }} جایزه ·
عضویت {{ formatDate(u.createdAt) }}
</div>
<div class="m-user-card__actions">
<button type="button" class="m-action" @click="router.push(`/m/users/${u.id}`)">
<EyeOutlined /> پروفایل
</button>
<button type="button" class="m-action" @click="openChance(u)">
<PlusCircleOutlined /> شانس
</button>
<button type="button" class="m-action" @click="confirmToggleBlock(u)">
<LockOutlined v-if="u.status !== 'BLOCKED'" />
<UnlockOutlined v-else />
</button>
</div>
</div>
</div>
<div v-if="total > filters.pageSize" class="m-pager">
<a-pagination
v-model:current="filters.page"
:total="total"
:page-size="filters.pageSize"
size="small"
@change="loadUsers"
/>
</div>
</a-spin>
<a-drawer v-model:open="isChanceVisible" placement="bottom" :height="420" title="تنظیم شانس">
<p class="m-drawer-hint">کاربر: {{ toFarsiDigits(chanceForm.userMobile) }}</p>
<a-form layout="vertical">
<a-form-item label="کمپین">
<a-select v-model:value="chanceForm.campaignId" size="large" style="width: 100%">
<a-select-option v-for="c in campaigns" :key="c.id" :value="c.id">{{ c.title }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="مقدار (+/-)">
<a-input-number v-model:value="chanceForm.amount" size="large" style="width: 100%" />
</a-form-item>
<a-form-item label="دلیل">
<a-input v-model:value="chanceForm.reason" size="large" />
</a-form-item>
<a-button type="primary" block size="large" :loading="isSubmittingChance" @click="submitChance">
اعمال
</a-button>
</a-form>
</a-drawer>
</div>
</template>
<style scoped>
.m-page { padding: 12px 14px 24px; }
.m-search-bar { display: flex; gap: 8px; margin-bottom: 12px; }
.m-search-bar :deep(.ant-input-affix-wrapper),
.m-search-bar :deep(.ant-select-selector) {
border-radius: 12px !important;
min-height: 42px !important;
}
.m-empty { text-align: center; color: #94a3b8; padding: 48px 12px; }
.m-user-card {
display: flex; gap: 12px; background: #fff; border-radius: 16px;
padding: 14px; margin-bottom: 10px; border: 1px solid #f1f5f9;
}
.m-user-card__avatar {
width: 44px; height: 44px; border-radius: 14px; flex-shrink: 0;
background: linear-gradient(135deg, #4f46e5, #7c3aed); color: #fff;
display: flex; align-items: center; justify-content: center; font-weight: 800;
}
.m-user-card__body { flex: 1; min-width: 0; }
.m-user-card__top { display: flex; justify-content: space-between; gap: 8px; }
.m-user-card__name { font-weight: 800; font-size: 14px; color: #0f172a; }
.m-user-card__mobile { font-size: 12px; color: #64748b; direction: ltr; text-align: right; }
.m-badge {
font-size: 10px; font-weight: 700; padding: 3px 8px; border-radius: 999px; height: fit-content;
}
.m-badge.active { background: #ecfdf5; color: #059669; }
.m-badge.blocked { background: #fef2f2; color: #ef4444; }
.m-user-card__meta { font-size: 11px; color: #94a3b8; margin: 8px 0; }
.m-user-card__actions { display: flex; gap: 6px; }
.m-action {
flex: 1; border: 1px solid #e2e8f0; background: #f8fafc; border-radius: 10px;
padding: 10px 4px; font-size: 11px; font-weight: 700; color: #334155;
display: inline-flex; align-items: center; justify-content: center; gap: 4px; min-height: 42px;
}
.m-pager { display: flex; justify-content: center; padding: 12px 0; }
.m-drawer-hint { color: #64748b; margin-bottom: 12px; }
</style>
@@ -0,0 +1,197 @@
<script setup lang="ts">
import { CopyOutlined, EditOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons-vue';
import { toFarsiDigits } from '@lucky-wheel/shared-types';
import { message } from 'ant-design-vue';
import { onMounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { apiClient } from '../../services/api/client';
const router = useRouter();
const loading = ref(false);
const winners = ref<any[]>([]);
const total = ref(0);
const campaigns = ref<any[]>([]);
const isStatusVisible = ref(false);
const isSubmitting = ref(false);
const filters = reactive({
page: 1,
pageSize: 10,
campaignId: undefined as string | undefined,
status: undefined as string | undefined,
search: '',
});
const statusForm = reactive({
id: '',
winnerMobile: '',
prizeTitle: '',
status: 'CONTACTED',
note: '',
});
const statusMap: Record<string, { text: string; color: string }> = {
NEW: { text: 'جدید', color: 'blue' },
CONTACTED: { text: 'تماس', color: 'cyan' },
DELIVERED: { text: 'تحویل', color: 'success' },
ACTIVE_CODE: { text: 'کد فعال', color: 'purple' },
USED: { text: 'مصرف', color: 'green' },
EXPIRED: { text: 'منقضی', color: 'default' },
};
async function loadCampaigns() {
const res = await apiClient.get('/admin/campaigns', { params: { pageSize: 100 } });
campaigns.value = Array.isArray(res.data) ? res.data : res.data?.data || [];
}
async function loadWinners() {
loading.value = true;
try {
const res = await apiClient.get('/admin/user-prizes', {
params: {
page: filters.page,
pageSize: filters.pageSize,
campaignId: filters.campaignId || undefined,
status: filters.status || undefined,
search: filters.search || undefined,
},
});
const items = Array.isArray(res.data) ? res.data : res.data?.data || [];
winners.value = items;
total.value = res.meta?.total || res.data?.meta?.total || items.length;
} finally {
loading.value = false;
}
}
function openStatus(record: any) {
statusForm.id = record.id;
statusForm.winnerMobile = record.user?.mobile || '';
statusForm.prizeTitle = record.prize?.title || '';
statusForm.status = record.status || 'CONTACTED';
statusForm.note = record.note || '';
isStatusVisible.value = true;
}
async function submitStatus() {
isSubmitting.value = true;
try {
await apiClient.patch(`/admin/user-prizes/${statusForm.id}/status`, {
status: statusForm.status,
note: statusForm.note || undefined,
});
message.success('وضعیت به‌روزرسانی شد');
isStatusVisible.value = false;
loadWinners();
} finally {
isSubmitting.value = false;
}
}
function copyCode(code: string) {
if (!code) return;
navigator.clipboard.writeText(code);
message.success('کد کپی شد');
}
function onSearch() {
filters.page = 1;
loadWinners();
}
function formatDate(val?: string) {
if (!val) return '—';
return toFarsiDigits(new Date(val).toLocaleDateString('fa-IR', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }));
}
onMounted(() => {
loadCampaigns();
loadWinners();
});
</script>
<template>
<div class="m-page">
<div class="m-filters">
<a-input v-model:value="filters.search" allow-clear placeholder="جستجو..." @pressEnter="onSearch">
<template #prefix><SearchOutlined style="color: #94a3b8" /></template>
</a-input>
<div class="m-filters__row">
<a-select v-model:value="filters.campaignId" allow-clear placeholder="کمپین" style="flex:1" @change="onSearch">
<a-select-option v-for="c in campaigns" :key="c.id" :value="c.id">{{ c.title }}</a-select-option>
</a-select>
<a-select v-model:value="filters.status" allow-clear placeholder="وضعیت" style="width:110px" @change="onSearch">
<a-select-option v-for="(v, k) in statusMap" :key="k" :value="k">{{ v.text }}</a-select-option>
</a-select>
<button type="button" class="m-icon-btn" @click="loadWinners"><ReloadOutlined :spin="loading" /></button>
</div>
</div>
<a-spin :spinning="loading">
<div v-if="!winners.length && !loading" class="m-empty">برنده‌ای یافت نشد</div>
<div v-for="w in winners" :key="w.id" class="m-card">
<div class="m-card__top">
<button
type="button"
class="m-card__user"
@click="w.user?.id && router.push(`/m/users/${w.user.id}`)"
>
{{ w.user?.name || toFarsiDigits(w.user?.mobile || '—') }}
</button>
<a-tag :color="statusMap[w.status]?.color || 'default'">
{{ statusMap[w.status]?.text || w.status }}
</a-tag>
</div>
<div class="m-card__line">{{ w.prize?.title || '—' }} · {{ w.campaign?.title || '—' }}</div>
<div class="m-card__meta">
<button v-if="w.code" type="button" class="m-code" @click="copyCode(w.code)">
<CopyOutlined /> {{ w.code }}
</button>
<span v-else>—</span>
<span>{{ formatDate(w.wonAt || w.createdAt) }}</span>
</div>
<button type="button" class="m-edit" @click="openStatus(w)"><EditOutlined /> تغییر وضعیت</button>
</div>
<div v-if="total > filters.pageSize" class="m-pager">
<a-pagination v-model:current="filters.page" :total="total" :page-size="filters.pageSize" size="small" @change="loadWinners" />
</div>
</a-spin>
<a-drawer v-model:open="isStatusVisible" placement="bottom" :height="420" title="وضعیت برنده">
<p class="hint">{{ toFarsiDigits(statusForm.winnerMobile) }} — {{ statusForm.prizeTitle }}</p>
<a-form layout="vertical">
<a-form-item label="وضعیت">
<a-select v-model:value="statusForm.status" size="large" style="width:100%">
<a-select-option v-for="(v, k) in statusMap" :key="k" :value="k">{{ v.text }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="یادداشت">
<a-textarea v-model:value="statusForm.note" :rows="3" />
</a-form-item>
<a-button type="primary" block size="large" :loading="isSubmitting" @click="submitStatus">ذخیره</a-button>
</a-form>
</a-drawer>
</div>
</template>
<style scoped>
.m-page { padding: 12px 14px 24px; }
.m-filters { display: flex; flex-direction: column; gap: 8px; margin-bottom: 12px; }
.m-filters__row { display: flex; gap: 6px; }
.m-filters :deep(.ant-input-affix-wrapper),
.m-filters :deep(.ant-select-selector) { border-radius: 12px !important; min-height: 42px !important; }
.m-icon-btn { width: 42px; height: 42px; border: none; border-radius: 12px; background: #fff; color: #4f46e5; }
.m-empty { text-align: center; color: #94a3b8; padding: 40px; }
.m-card { background: #fff; border-radius: 14px; padding: 12px; margin-bottom: 8px; border: 1px solid #f1f5f9; }
.m-card__top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
.m-card__user { border: none; background: transparent; padding: 0; font-weight: 800; color: #4f46e5; font-size: 13px; }
.m-card__line { font-size: 12px; color: #334155; }
.m-card__meta { display: flex; justify-content: space-between; align-items: center; margin-top: 8px; font-size: 11px; color: #94a3b8; }
.m-code { border: none; background: #f8fafc; color: #4f46e5; border-radius: 8px; padding: 4px 8px; font-size: 11px; direction: ltr; }
.m-edit {
margin-top: 10px; width: 100%; border: 1px solid #e2e8f0; background: #f8fafc; border-radius: 10px;
padding: 10px; font-weight: 700; font-size: 12px; color: #334155; min-height: 42px;
}
.m-pager { display: flex; justify-content: center; padding: 12px 0; }
.hint { color: #64748b; margin-bottom: 12px; font-size: 12px; }
</style>
@@ -17,6 +17,7 @@ import { computed, onMounted, reactive, ref } from 'vue';
import { formatFarsiNumber, parseFarsiNumber, toEnglishDigits, toFarsiDigits } from '@lucky-wheel/shared-types';
import { useRoute, useRouter } from 'vue-router';
import { apiClient } from '../../services/api/client';
import { openClientCampaign } from '../../utils/clientUrl';
const route = useRoute();
const router = useRouter();
@@ -349,8 +350,7 @@ onMounted(() => {
<a-space>
<a-button
v-if="campaign?.slug"
:href="'http://localhost:5174/c/' + campaign.slug"
target="_blank"
@click="openClientCampaign(campaign.slug)"
>
<template #icon><EyeOutlined /></template>
مشاهده گردونه کاربر ↗
+44 -12
View File
@@ -79,25 +79,54 @@ async function exportExcel() {
}
}
const prizeTypeLabels: Record<string, string> = {
DIGITAL_CODE: 'کد دیجیتال',
PHYSICAL: 'کالای فیزیکی',
DISCOUNT: 'کد تخفیف',
CASH: 'جایزه نقدی',
FREE_CHANCE: 'شانس مجدد',
CUSTOM: 'سفارشی',
const prizeTypeMap: Record<string, { text: string; color: string }> = {
PHYSICAL: { text: 'کالای فیزیکی', color: 'blue' },
DISCOUNT_CODE: { text: 'کد تخفیف', color: 'green' },
DISCOUNT: { text: 'کد تخفیف', color: 'green' },
CREDIT: { text: 'شارژ کیف پول', color: 'purple' },
WALLET_CREDIT: { text: 'شارژ کیف پول', color: 'purple' },
CASH: { text: 'جایزه نقدی', color: 'gold' },
NO_PRIZE: { text: 'پوچ', color: 'default' },
OTHER: { text: 'شانس مجدد', color: 'cyan' },
FREE_CHANCE: { text: 'شانس مجدد', color: 'cyan' },
DIGITAL_CODE: { text: 'کد دیجیتال', color: 'green' },
};
function getPrizeTypeTag(type?: string) {
if (!type) return { text: '—', color: 'default' };
return prizeTypeMap[type] || { text: type, color: 'blue' };
}
function formatJalaliDateTime(val?: string | Date | null) {
if (!val) return '—';
try {
const d = new Date(val);
if (isNaN(d.getTime())) return '—';
const dateStr = d.toLocaleDateString('fa-IR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
const timeStr = d.toLocaleTimeString('fa-IR', {
hour: '2-digit',
minute: '2-digit',
});
return toFarsiDigits(`${dateStr} - ${timeStr}`);
} catch {
return '—';
}
}
const columns = [
{ title: 'شماره همراه', dataIndex: ['user', 'mobile'], key: 'userMobile' },
{ title: 'نام کاربر', dataIndex: ['user', 'name'], key: 'userName' },
{ title: 'کمپین', dataIndex: ['campaign', 'title'], key: 'campaignTitle' },
{ title: 'جایزه / نتیجه', dataIndex: ['prize', 'title'], key: 'prizeTitle' },
{ title: 'نوع جایزه', dataIndex: ['prize', 'prizeType'], key: 'prizeType' },
{ title: 'نوع جایزه', key: 'prizeType' },
{ title: 'نتیجه', dataIndex: 'isWin', key: 'isWin', align: 'center' as const },
{ title: 'کلید تکرارناپذیری (Idempotency)', dataIndex: 'idempotencyKey', key: 'idempotencyKey', ellipsis: true },
{ title: 'آی‌پی کاربر', dataIndex: 'ipAddress', key: 'ipAddress' },
{ title: 'زمان اسپین', dataIndex: 'createdAt', key: 'createdAt' },
{ title: 'تاریخ و ساعت چرخش', key: 'createdAt' },
];
onMounted(() => {
@@ -188,8 +217,9 @@ onMounted(() => {
</template>
<template v-else-if="column.key === 'prizeType'">
<a-tag v-if="record.prize?.prizeType">{{ prizeTypeLabels[record.prize.prizeType] || record.prize.prizeType }}</a-tag>
<span v-else class="text-muted">—</span>
<a-tag :color="getPrizeTypeTag(record.prize?.type || record.prizeType).color" style="border-radius: 4px">
{{ getPrizeTypeTag(record.prize?.type || record.prizeType).text }}
</a-tag>
</template>
<template v-else-if="column.key === 'isWin'">
@@ -208,7 +238,9 @@ onMounted(() => {
</template>
<template v-else-if="column.key === 'createdAt'">
<span class="date-cell">{{ toFarsiDigits(new Date(record.createdAt).toLocaleString('fa-IR')) }}</span>
<span class="date-cell">
{{ record.createdAtFormatted ? toFarsiDigits(record.createdAtFormatted) : formatJalaliDateTime(record.createdAt) }}
</span>
</template>
</template>
</a-table>
@@ -311,8 +311,7 @@ onMounted(() => {
background: #fff;
margin-bottom: 20px;
border-radius: 12px;
padding: 16px 24px;
border: 1px solid #f1f5f9;
border: 1px solid #f1f5f9;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
}
+45 -12
View File
@@ -128,24 +128,53 @@ function copyToClipboard(text: string, label: string) {
message.success(`${label} با موفقیت کپی شد.`);
}
const prizeTypeLabels: Record<string, string> = {
DIGITAL_CODE: 'کد دیجیتال',
PHYSICAL: 'کالای فیزیکی',
DISCOUNT: 'کد تخفیف',
CASH: 'جایزه نقدی',
FREE_CHANCE: 'شانس مجدد',
CUSTOM: 'سفارشی',
const prizeTypeMap: Record<string, { text: string; color: string }> = {
PHYSICAL: { text: 'کالای فیزیکی', color: 'blue' },
DISCOUNT_CODE: { text: 'کد تخفیف', color: 'green' },
DISCOUNT: { text: 'کد تخفیف', color: 'green' },
CREDIT: { text: 'شارژ کیف پول', color: 'purple' },
WALLET_CREDIT: { text: 'شارژ کیف پول', color: 'purple' },
CASH: { text: 'جایزه نقدی', color: 'gold' },
NO_PRIZE: { text: 'پوچ', color: 'default' },
OTHER: { text: 'شانس مجدد', color: 'cyan' },
FREE_CHANCE: { text: 'شانس مجدد', color: 'cyan' },
DIGITAL_CODE: { text: 'کد دیجیتال', color: 'green' },
};
function getPrizeTypeTag(type?: string) {
if (!type) return { text: '—', color: 'default' };
return prizeTypeMap[type] || { text: type, color: 'blue' };
}
function formatJalaliDateTime(val?: string | Date | null) {
if (!val) return '—';
try {
const d = new Date(val);
if (isNaN(d.getTime())) return '—';
const dateStr = d.toLocaleDateString('fa-IR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
const timeStr = d.toLocaleTimeString('fa-IR', {
hour: '2-digit',
minute: '2-digit',
});
return toFarsiDigits(`${dateStr} - ${timeStr}`);
} catch {
return '—';
}
}
const columns = [
{ title: 'شماره همراه', dataIndex: ['user', 'mobile'], key: 'mobile' },
{ title: 'نام کاربر', dataIndex: ['user', 'name'], key: 'name' },
{ title: 'کمپین', dataIndex: ['campaign', 'title'], key: 'campaign' },
{ title: 'عنوان جایزه', dataIndex: ['prize', 'title'], key: 'prizeTitle' },
{ title: 'نوع جایزه', dataIndex: ['prize', 'prizeType'], key: 'prizeType' },
{ title: 'نوع جایزه', key: 'prizeType' },
{ title: 'کد دیجیتال / پیگیری', dataIndex: 'code', key: 'code' },
{ title: 'وضعیت پیگیری', dataIndex: 'status', key: 'status' },
{ title: 'تاریخ برنده شدن', dataIndex: 'createdAt', key: 'createdAt' },
{ title: 'تاریخ و ساعت برد', key: 'wonAt' },
{ title: 'عملیات', key: 'actions', width: 70, align: 'center' as const },
];
@@ -273,7 +302,9 @@ onMounted(() => {
</template>
<template v-else-if="column.key === 'prizeType'">
<a-tag>{{ prizeTypeLabels[record.prize?.prizeType] || record.prize?.prizeType || '—' }}</a-tag>
<a-tag :color="getPrizeTypeTag(record.prize?.type || record.prizeType).color" style="border-radius: 4px">
{{ getPrizeTypeTag(record.prize?.type || record.prizeType).text }}
</a-tag>
</template>
<template v-else-if="column.key === 'code'">
@@ -295,8 +326,10 @@ onMounted(() => {
</a-tag>
</template>
<template v-else-if="column.key === 'createdAt'">
<span class="date-cell">{{ toFarsiDigits(new Date(record.createdAt).toLocaleString('fa-IR')) }}</span>
<template v-else-if="column.key === 'wonAt' || column.key === 'createdAt'">
<span class="date-cell">
{{ record.wonAtFormatted ? toFarsiDigits(record.wonAtFormatted) : formatJalaliDateTime(record.wonAt || record.createdAt) }}
</span>
</template>
<template v-else-if="column.key === 'actions'">
+9
View File
@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_CLIENT_PUBLIC_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+1
View File
@@ -12,6 +12,7 @@ export default defineConfig({
},
server: {
port: 5173,
strictPort: true,
proxy: {
'/api': {
target: 'http://localhost:3000',
+6
View File
@@ -2,6 +2,7 @@ FROM node:20-alpine AS builder
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN apk add --no-cache openssl
RUN corepack enable pnpm
WORKDIR /app
@@ -9,11 +10,16 @@ WORKDIR /app
COPY . .
RUN pnpm install --frozen-lockfile
RUN pnpm --filter @lucky-wheel/api exec prisma generate
RUN pnpm run build:types
RUN pnpm --filter @lucky-wheel/api build
RUN pnpm deploy --filter @lucky-wheel/api --prod /prod/api
RUN cp -r /app/node_modules/.pnpm/@prisma+client* /prod/api/node_modules/.pnpm/
FROM node:20-alpine AS runner
RUN apk add --no-cache openssl
WORKDIR /app
COPY --from=builder /prod/api .
@@ -0,0 +1,6 @@
-- AlterEnum: add 5 new wheel design themes
ALTER TYPE "WheelDesign" ADD VALUE IF NOT EXISTS 'RUSTIC_WOOD';
ALTER TYPE "WheelDesign" ADD VALUE IF NOT EXISTS 'GALAXY_SPACE';
ALTER TYPE "WheelDesign" ADD VALUE IF NOT EXISTS 'CANDY_LAND';
ALTER TYPE "WheelDesign" ADD VALUE IF NOT EXISTS 'FIRE_FLAME';
ALTER TYPE "WheelDesign" ADD VALUE IF NOT EXISTS 'CRYSTAL_ICE';
+7 -1
View File
@@ -1,5 +1,6 @@
generator client {
provider = "prisma-client-js"
provider = "prisma-client-js"
binaryTargets = ["native", "linux-musl-openssl-3.0.x"]
}
datasource db {
@@ -93,6 +94,11 @@ enum WheelDesign {
MINIMAL_FLAT
LUXURY_GOLD
PLAYFUL_CARTOON
RUSTIC_WOOD
GALAXY_SPACE
CANDY_LAND
FIRE_FLAME
CRYSTAL_ICE
}
// ==================== MODELS ====================
@@ -62,13 +62,27 @@ export class CampaignService {
];
}
const [data, total] = await Promise.all([
const [campaigns, total] = await Promise.all([
this.prisma.campaign.findMany({
where,
skip,
take: pageSize,
orderBy: { createdAt: 'desc' },
include: {
prizes: {
select: {
id: true,
title: true,
type: true,
stockType: true,
initialStock: true,
remainingStock: true,
weight: true,
segmentColor: true,
sortOrder: true,
},
orderBy: { sortOrder: 'asc' },
},
_count: {
select: {
prizes: true,
@@ -81,6 +95,32 @@ export class CampaignService {
this.prisma.campaign.count({ where }),
]);
const formatFarsiDateTime = (d?: Date | null) => {
if (!d) return null;
try {
return new Intl.DateTimeFormat('fa-IR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(d));
} catch {
return null;
}
};
const data = campaigns.map((campaign) => {
const prizeTypes = Array.from(new Set(campaign.prizes?.map((p) => p.type) || []));
return {
...campaign,
prizeTypes,
startAtFormatted: formatFarsiDateTime(campaign.startAt),
endAtFormatted: formatFarsiDateTime(campaign.endAt),
createdAtFormatted: formatFarsiDateTime(campaign.createdAt),
};
});
return {
data,
meta: {
+46 -2
View File
@@ -56,8 +56,31 @@ export class WinnerService {
this.prisma.userPrize.count({ where }),
]);
const formatFarsiDateTime = (d?: Date | null) => {
if (!d) return null;
try {
return new Intl.DateTimeFormat('fa-IR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(d));
} catch {
return null;
}
};
const enrichedData = data.map((item) => ({
...item,
createdAt: item.wonAt,
wonAtFormatted: formatFarsiDateTime(item.wonAt),
redeemedAtFormatted: formatFarsiDateTime(item.redeemedAt),
prizeType: item.prize?.type,
}));
return {
data,
data: enrichedData,
meta: {
page,
pageSize,
@@ -200,8 +223,29 @@ export class WinnerService {
this.prisma.spin.count({ where }),
]);
const formatFarsiDateTime = (d?: Date | null) => {
if (!d) return null;
try {
return new Intl.DateTimeFormat('fa-IR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(d));
} catch {
return null;
}
};
const enrichedData = data.map((item) => ({
...item,
createdAtFormatted: formatFarsiDateTime(item.createdAt),
prizeType: item.prize?.type,
}));
return {
data,
data: enrichedData,
meta: {
page,
pageSize,
+2
View File
@@ -0,0 +1,2 @@
# Local dev: auto-open demo campaign on /
VITE_DEFAULT_CAMPAIGN_SLUG=default-campaign
+2
View File
@@ -0,0 +1,2 @@
# Optional: redirect / to /{slug}. Leave unset on production so / shows landing page.
# VITE_DEFAULT_CAMPAIGN_SLUG=default-campaign
+3 -11
View File
@@ -9,22 +9,14 @@ WORKDIR /app
COPY . .
RUN pnpm install --frozen-lockfile
RUN pnpm --filter @lucky-wheel/api exec prisma generate
RUN pnpm run build:types
RUN pnpm --filter @lucky-wheel/client build
FROM nginx:alpine
COPY --from=builder /app/apps/client/dist /usr/share/nginx/html
# Configure Nginx for Single Page Application
RUN echo 'server { \
listen 80; \
server_name localhost; \
location / { \
root /usr/share/nginx/html; \
index index.html index.htm; \
try_files $uri $uri/ /index.html; \
} \
}' > /etc/nginx/conf.d/default.conf
COPY apps/client/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
+30
View File
@@ -0,0 +1,30 @@
server {
listen 80;
server_name localhost;
location /api/ {
proxy_pass http://api:3000/api/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /uploads/ {
proxy_pass http://api:3000/uploads/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
}
@@ -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;
}
+23
View File
@@ -10,8 +10,31 @@ export default defineConfig({
'@lucky-wheel/shared-types': path.resolve(__dirname, '../../packages/shared-types/src/index.ts'),
},
},
build: {
target: 'es2020',
cssCodeSplit: true,
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules')) {
if (id.includes('vue') || id.includes('pinia') || id.includes('vue-router')) {
return 'vendor-vue';
}
if (id.includes('lucide') || id.includes('canvas-confetti')) {
return 'vendor-ui';
}
}
if (id.includes('/components/wheels/')) {
const name = id.split('/components/wheels/')[1]?.split('.')[0];
if (name) return `wheel-${name.toLowerCase()}`;
}
},
},
},
},
server: {
port: 5174,
strictPort: true,
proxy: {
'/api': {
target: 'http://localhost:3000',
+5 -1
View File
@@ -38,7 +38,7 @@ services:
container_name: lucky_wheel_api
restart: unless-stopped
ports:
- "3000:3000"
- "3001:3000"
environment:
- DATABASE_URL=postgresql://postgres:postgrespassword@postgres:5432/lucky_wheel?schema=public
- REDIS_HOST=redis
@@ -54,9 +54,12 @@ services:
build:
context: .
dockerfile: apps/admin/Dockerfile
args:
VITE_CLIENT_PUBLIC_URL: ${VITE_CLIENT_PUBLIC_URL:-}
container_name: lucky_wheel_admin
restart: unless-stopped
ports:
- "5173:80"
- "8080:80"
depends_on:
- api
@@ -68,6 +71,7 @@ services:
container_name: lucky_wheel_client
restart: unless-stopped
ports:
- "5174:80"
- "80:80"
depends_on:
- api
+16
View File
@@ -3,7 +3,9 @@
"version": "1.0.0",
"private": true,
"description": "Lucky Wheel & Lottery Monorepo Platform",
"packageManager": "pnpm@9.12.3",
"scripts": {
"dev": "concurrently -n \"API,ADMIN,CLIENT\" -c \"blue,green,magenta\" \"pnpm dev:api\" \"pnpm dev:admin\" \"pnpm dev:client\"",
"dev:api": "pnpm --filter @lucky-wheel/api start:dev",
"dev:admin": "pnpm --filter @lucky-wheel/admin dev",
"dev:client": "pnpm --filter @lucky-wheel/client dev",
@@ -17,6 +19,20 @@
"docker:down": "docker-compose down"
},
"devDependencies": {
"concurrently": "^10.0.5",
"typescript": "^5.4.5"
},
"pnpm": {
"onlyBuiltDependencies": [
"@nestjs/core",
"@prisma/client",
"@prisma/engines",
"bcrypt",
"core-js",
"esbuild",
"msgpackr-extract",
"prisma",
"vue-demi"
]
}
}
+5
View File
@@ -86,6 +86,11 @@ export enum WheelDesign {
MINIMAL_FLAT = 'MINIMAL_FLAT',
LUXURY_GOLD = 'LUXURY_GOLD',
PLAYFUL_CARTOON = 'PLAYFUL_CARTOON',
RUSTIC_WOOD = 'RUSTIC_WOOD',
GALAXY_SPACE = 'GALAXY_SPACE',
CANDY_LAND = 'CANDY_LAND',
FIRE_FLAME = 'FIRE_FLAME',
CRYSTAL_ICE = 'CRYSTAL_ICE',
}
// ==========================================
+89 -14
View File
@@ -8,6 +8,9 @@ importers:
.:
devDependencies:
concurrently:
specifier: ^10.0.5
version: 10.0.5
typescript:
specifier: ^5.4.5
version: 5.9.3
@@ -777,7 +780,6 @@ packages:
engines: {node: ^22.20 || ^24.12 || >=25}
cpu: [x64]
os: [linux]
libc: [glibc]
'@nestjs/bull-shared@10.2.3':
resolution: {integrity: sha512-XcgAjNOgq6b5DVCytxhR5BKiwWo7hsusVeyE7sfFnlXRHeEtIuC2hYWBr/ZAtvL/RH0/O0tqtq0rVl972nbhJw==}
@@ -1005,79 +1007,66 @@ packages:
resolution: {integrity: sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.63.1':
resolution: {integrity: sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.63.1':
resolution: {integrity: sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.63.1':
resolution: {integrity: sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.63.1':
resolution: {integrity: sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.63.1':
resolution: {integrity: sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.63.1':
resolution: {integrity: sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.63.1':
resolution: {integrity: sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.63.1':
resolution: {integrity: sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.63.1':
resolution: {integrity: sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.63.1':
resolution: {integrity: sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.63.1':
resolution: {integrity: sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.63.1':
resolution: {integrity: sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.63.1':
resolution: {integrity: sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==}
@@ -1761,6 +1750,10 @@ packages:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
cliui@9.0.1:
resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==}
engines: {node: '>=20'}
clone@1.0.4:
resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==}
engines: {node: '>=0.8'}
@@ -1823,6 +1816,11 @@ packages:
resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==}
engines: {'0': node >= 6.0}
concurrently@10.0.5:
resolution: {integrity: sha512-JaP/CoftUrCcAFW/g//RbgEGwlelnEae6cfBLgH6ZdO6s8jPkn6p9SB9u6pdVxYXoiSnFqseOlHfrEfF82TVOg==}
engines: {node: '>=22'}
hasBin: true
consola@2.15.3:
resolution: {integrity: sha512-9vAdYbHj6x2fLKC4+oPH0kFzY/orMZyG2Aj+kNylHxKGJ/Ed4dpNyAQYwJOdqO4zdM7XpVHmyejQDcQHrnuXbw==}
@@ -2036,6 +2034,9 @@ packages:
resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==}
engines: {node: '>=12'}
emoji-regex@10.6.0:
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
@@ -2295,6 +2296,10 @@ packages:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
get-east-asian-width@1.6.0:
resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==}
engines: {node: '>=18'}
get-intrinsic@1.3.0:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'}
@@ -3487,6 +3492,10 @@ packages:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
shell-quote@1.9.0:
resolution: {integrity: sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==}
engines: {node: '>= 0.4'}
side-channel-list@1.0.1:
resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==}
engines: {node: '>= 0.4'}
@@ -3569,6 +3578,10 @@ packages:
resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==}
engines: {node: '>=12'}
string-width@7.2.0:
resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==}
engines: {node: '>=18'}
string_decoder@1.1.1:
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
@@ -3619,6 +3632,10 @@ packages:
resolution: {integrity: sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==}
engines: {node: '>=14.18.0'}
supports-color@10.2.2:
resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==}
engines: {node: '>=18'}
supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
@@ -4054,6 +4071,10 @@ packages:
resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
engines: {node: '>=12'}
wrap-ansi@9.0.2:
resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==}
engines: {node: '>=18'}
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
@@ -4082,10 +4103,18 @@ packages:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
yargs-parser@22.0.0:
resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==}
engines: {node: ^20.19.0 || ^22.12.0 || >=23}
yargs@17.7.3:
resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==}
engines: {node: '>=12'}
yargs@18.0.0:
resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=23}
yn@3.1.1:
resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==}
engines: {node: '>=6'}
@@ -5839,6 +5868,12 @@ snapshots:
strip-ansi: 6.0.1
wrap-ansi: 7.0.0
cliui@9.0.1:
dependencies:
string-width: 7.2.0
strip-ansi: 7.2.0
wrap-ansi: 9.0.2
clone@1.0.4: {}
cluster-key-slot@1.1.1: {}
@@ -5898,6 +5933,15 @@ snapshots:
readable-stream: 3.6.2
typedarray: 0.0.6
concurrently@10.0.5:
dependencies:
chalk: 5.6.2
rxjs: 7.8.2
shell-quote: 1.9.0
supports-color: 10.2.2
tree-kill: 1.2.2
yargs: 18.0.0
consola@2.15.3: {}
console-control-strings@1.1.0: {}
@@ -6067,6 +6111,8 @@ snapshots:
emittery@0.13.1: {}
emoji-regex@10.6.0: {}
emoji-regex@8.0.0: {}
emoji-regex@9.2.2: {}
@@ -6393,6 +6439,8 @@ snapshots:
get-caller-file@2.0.5: {}
get-east-asian-width@1.6.0: {}
get-intrinsic@1.3.0:
dependencies:
call-bind-apply-helpers: 1.0.2
@@ -7768,6 +7816,8 @@ snapshots:
shebang-regex@3.0.0: {}
shell-quote@1.9.0: {}
side-channel-list@1.0.1:
dependencies:
es-errors: 1.3.0
@@ -7851,6 +7901,12 @@ snapshots:
emoji-regex: 9.2.2
strip-ansi: 7.2.0
string-width@7.2.0:
dependencies:
emoji-regex: 10.6.0
get-east-asian-width: 1.6.0
strip-ansi: 7.2.0
string_decoder@1.1.1:
dependencies:
safe-buffer: 5.1.2
@@ -7913,6 +7969,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
supports-color@10.2.2: {}
supports-color@7.2.0:
dependencies:
has-flag: 4.0.0
@@ -8314,6 +8372,12 @@ snapshots:
string-width: 5.1.2
strip-ansi: 7.2.0
wrap-ansi@9.0.2:
dependencies:
ansi-styles: 6.2.3
string-width: 7.2.0
strip-ansi: 7.2.0
wrappy@1.0.2: {}
write-file-atomic@4.0.2:
@@ -8333,6 +8397,8 @@ snapshots:
yargs-parser@21.1.1: {}
yargs-parser@22.0.0: {}
yargs@17.7.3:
dependencies:
cliui: 8.0.1
@@ -8343,6 +8409,15 @@ snapshots:
y18n: 5.0.8
yargs-parser: 21.1.1
yargs@18.0.0:
dependencies:
cliui: 9.0.1
escalade: 3.2.0
get-caller-file: 2.0.5
string-width: 7.2.0
y18n: 5.0.8
yargs-parser: 22.0.0
yn@3.1.1: {}
yocto-queue@0.1.0: {}