fix(core): resolve local client url redirects and stabilize dev environment
- enforce localhost:5174 for wheel campaign client URL in local development - lock vite dev server ports using strictPort on admin (5173) and client (5174) - sanitize .env.example and remove hardcoded staging domain - configure root dev orchestration and docker multi-stage build context
This commit is contained in:
@@ -0,0 +1,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
|
||||
@@ -8,3 +8,5 @@ uploads/
|
||||
coverage/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
package-lock.json
|
||||
*.ps1
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# Public client app URL for "view wheel" links in admin panel
|
||||
VITE_CLIENT_PUBLIC_URL=http://localhost:5174
|
||||
+6
-11
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
مشاهده گردونه کاربر ↗
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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'">
|
||||
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_CLIENT_PUBLIC_URL?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
@@ -12,6 +12,7 @@ export default defineConfig({
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
|
||||
@@ -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';
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# Local dev: auto-open demo campaign on /
|
||||
VITE_DEFAULT_CAMPAIGN_SLUG=default-campaign
|
||||
@@ -0,0 +1,2 @@
|
||||
# Optional: redirect / to /{slug}. Leave unset on production so / shows landing page.
|
||||
# VITE_DEFAULT_CAMPAIGN_SLUG=default-campaign
|
||||
+3
-11
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { WheelDesignProps, WheelSegment } from '@lucky-wheel/shared-types';
|
||||
import { computed, watch, type Ref } from 'vue';
|
||||
import { useWheelRotation } from './useWheelRotation';
|
||||
|
||||
export interface UseWheelBaseOptions {
|
||||
segments: Ref<WheelSegment[]> | (() => WheelSegment[]);
|
||||
spinning: Ref<boolean> | (() => boolean);
|
||||
targetSegmentIndex: Ref<number | null> | (() => number | null);
|
||||
onFinished: () => void;
|
||||
/** Slice radius inside viewBox 0..300 (default 140) */
|
||||
radius?: number;
|
||||
/** Peg / bulb radius from center (default 144) */
|
||||
pegRadius?: number;
|
||||
pegCount?: number;
|
||||
}
|
||||
|
||||
function unwrap<T>(source: Ref<T> | (() => T)): () => T {
|
||||
return typeof source === 'function' ? (source as () => T) : () => (source as Ref<T>).value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared geometry + spin wiring for all wheel skins.
|
||||
*/
|
||||
export function useWheelBase(options: UseWheelBaseOptions) {
|
||||
const getSegments = unwrap(options.segments);
|
||||
const getSpinning = unwrap(options.spinning);
|
||||
const getTarget = unwrap(options.targetSegmentIndex);
|
||||
const radius = options.radius ?? 140;
|
||||
const pegRadius = options.pegRadius ?? 144;
|
||||
const pegCount = options.pegCount ?? 24;
|
||||
|
||||
const { wheelStyle, pointerStyle, isSpinning, spinTo } = useWheelRotation({
|
||||
segmentCount: () => getSegments().length,
|
||||
pegCount: () => pegCount,
|
||||
onFinished: options.onFinished,
|
||||
});
|
||||
|
||||
watch(
|
||||
[getSpinning, getTarget],
|
||||
([newSpinning, newTargetIdx]) => {
|
||||
if (newSpinning && newTargetIdx !== null && newTargetIdx !== undefined && !isSpinning.value) {
|
||||
spinTo(newTargetIdx as number);
|
||||
}
|
||||
},
|
||||
{ flush: 'post' },
|
||||
);
|
||||
|
||||
const totalSegments = computed(() => getSegments().length);
|
||||
const sliceAngle = computed(() => (totalSegments.value > 0 ? 360 / totalSegments.value : 0));
|
||||
|
||||
function getSlicePath(index: number): string {
|
||||
if (totalSegments.value <= 1) return '';
|
||||
const cx = 150;
|
||||
const cy = 150;
|
||||
const startAngle = (index * sliceAngle.value - 90) * (Math.PI / 180);
|
||||
const endAngle = ((index + 1) * sliceAngle.value - 90) * (Math.PI / 180);
|
||||
|
||||
const x1 = cx + radius * Math.cos(startAngle);
|
||||
const y1 = cy + radius * Math.sin(startAngle);
|
||||
const x2 = cx + radius * Math.cos(endAngle);
|
||||
const y2 = cy + radius * Math.sin(endAngle);
|
||||
|
||||
const largeArc = sliceAngle.value > 180 ? 1 : 0;
|
||||
return `M ${cx} ${cy} L ${x1} ${y1} A ${radius} ${radius} 0 ${largeArc} 1 ${x2} ${y2} Z`;
|
||||
}
|
||||
|
||||
function getTextRotation(index: number): string {
|
||||
const midAngle = index * sliceAngle.value + sliceAngle.value / 2;
|
||||
return `rotate(${midAngle} 150 150)`;
|
||||
}
|
||||
|
||||
function makePegs(count = pegCount, r = pegRadius) {
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const angle = (i * (360 / count) - 90) * (Math.PI / 180);
|
||||
return {
|
||||
x: 150 + r * Math.cos(angle),
|
||||
y: 150 + r * Math.sin(angle),
|
||||
index: i,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const pegs = computed(() => makePegs());
|
||||
|
||||
return {
|
||||
wheelStyle,
|
||||
pointerStyle,
|
||||
isSpinning,
|
||||
spinTo,
|
||||
totalSegments,
|
||||
sliceAngle,
|
||||
getSlicePath,
|
||||
getTextRotation,
|
||||
pegs,
|
||||
makePegs,
|
||||
};
|
||||
}
|
||||
|
||||
/** Convenience helper when using defineProps<WheelDesignProps>() */
|
||||
export function useWheelFromProps(
|
||||
props: WheelDesignProps,
|
||||
emitFinished: () => void,
|
||||
opts?: { radius?: number; pegRadius?: number; pegCount?: number },
|
||||
) {
|
||||
return useWheelBase({
|
||||
segments: () => props.segments,
|
||||
spinning: () => props.spinning,
|
||||
targetSegmentIndex: () => props.targetSegmentIndex,
|
||||
onFinished: emitFinished,
|
||||
...opts,
|
||||
});
|
||||
}
|
||||
@@ -1,29 +1,46 @@
|
||||
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router';
|
||||
|
||||
const defaultCampaignSlug = (import.meta.env.VITE_DEFAULT_CAMPAIGN_SLUG as string | undefined)?.trim();
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/my-prizes',
|
||||
name: 'my-prizes',
|
||||
component: () => import('../views/MyPrizesView.vue'),
|
||||
},
|
||||
// Legacy URLs: /c/slug → /slug
|
||||
{
|
||||
path: '/c/:slug',
|
||||
name: 'campaign-wheel-slug',
|
||||
component: () => import('../views/CampaignWheelView.vue'),
|
||||
redirect: (to) => `/${String(to.params.slug)}`,
|
||||
},
|
||||
{
|
||||
path: '/c',
|
||||
redirect: '/',
|
||||
},
|
||||
...(defaultCampaignSlug
|
||||
? [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
redirect: `/${defaultCampaignSlug}`,
|
||||
} as RouteRecordRaw,
|
||||
]
|
||||
: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: () => import('../views/HomeView.vue'),
|
||||
} as RouteRecordRaw,
|
||||
]),
|
||||
{
|
||||
path: '/:slug',
|
||||
name: 'campaign-wheel',
|
||||
component: () => import('../views/CampaignWheelView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
redirect: '/c/default-campaign',
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: '/',
|
||||
name: 'not-found',
|
||||
component: () => import('../views/NotFoundView.vue'),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -12,15 +12,10 @@ import {
|
||||
Volume2,
|
||||
VolumeX,
|
||||
} from 'lucide-vue-next';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { computed, defineAsyncComponent, onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import AuthModal from '../components/modals/AuthModal.vue';
|
||||
import ResultModal from '../components/modals/ResultModal.vue';
|
||||
import ClassicCasinoWheel from '../components/wheels/ClassicCasinoWheel.vue';
|
||||
import LuxuryGoldWheel from '../components/wheels/LuxuryGoldWheel.vue';
|
||||
import MinimalFlatWheel from '../components/wheels/MinimalFlatWheel.vue';
|
||||
import NeonCyberWheel from '../components/wheels/NeonCyberWheel.vue';
|
||||
import PlayfulCartoonWheel from '../components/wheels/PlayfulCartoonWheel.vue';
|
||||
import { useSoundEffects } from '../composables/useSoundEffects';
|
||||
import { apiClient } from '../services/api/client';
|
||||
import { useAuthStore } from '../stores/auth.store';
|
||||
@@ -40,16 +35,41 @@ const showAuthModal = ref(false);
|
||||
const showRules = ref(false);
|
||||
|
||||
const wheelComponents: Record<string, any> = {
|
||||
[WheelDesign.CLASSIC_CASINO]: ClassicCasinoWheel,
|
||||
[WheelDesign.NEON_CYBER]: NeonCyberWheel,
|
||||
[WheelDesign.MINIMAL_FLAT]: MinimalFlatWheel,
|
||||
[WheelDesign.LUXURY_GOLD]: LuxuryGoldWheel,
|
||||
[WheelDesign.PLAYFUL_CARTOON]: PlayfulCartoonWheel,
|
||||
[WheelDesign.CLASSIC_CASINO]: defineAsyncComponent(
|
||||
() => import('../components/wheels/ClassicCasinoWheel.vue'),
|
||||
),
|
||||
[WheelDesign.NEON_CYBER]: defineAsyncComponent(
|
||||
() => import('../components/wheels/NeonCyberWheel.vue'),
|
||||
),
|
||||
[WheelDesign.MINIMAL_FLAT]: defineAsyncComponent(
|
||||
() => import('../components/wheels/MinimalFlatWheel.vue'),
|
||||
),
|
||||
[WheelDesign.LUXURY_GOLD]: defineAsyncComponent(
|
||||
() => import('../components/wheels/LuxuryGoldWheel.vue'),
|
||||
),
|
||||
[WheelDesign.PLAYFUL_CARTOON]: defineAsyncComponent(
|
||||
() => import('../components/wheels/PlayfulCartoonWheel.vue'),
|
||||
),
|
||||
[WheelDesign.RUSTIC_WOOD]: defineAsyncComponent(
|
||||
() => import('../components/wheels/RusticWoodWheel.vue'),
|
||||
),
|
||||
[WheelDesign.GALAXY_SPACE]: defineAsyncComponent(
|
||||
() => import('../components/wheels/GalaxySpaceWheel.vue'),
|
||||
),
|
||||
[WheelDesign.CANDY_LAND]: defineAsyncComponent(
|
||||
() => import('../components/wheels/CandyLandWheel.vue'),
|
||||
),
|
||||
[WheelDesign.FIRE_FLAME]: defineAsyncComponent(
|
||||
() => import('../components/wheels/FireFlameWheel.vue'),
|
||||
),
|
||||
[WheelDesign.CRYSTAL_ICE]: defineAsyncComponent(
|
||||
() => import('../components/wheels/CrystalIceWheel.vue'),
|
||||
),
|
||||
};
|
||||
|
||||
const activeWheelComponent = computed(() => {
|
||||
const design = campaign.value?.wheelDesign || WheelDesign.CLASSIC_CASINO;
|
||||
return wheelComponents[design] || ClassicCasinoWheel;
|
||||
return wheelComponents[design] || wheelComponents[WheelDesign.CLASSIC_CASINO];
|
||||
});
|
||||
|
||||
async function loadCampaign() {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { Sparkles } from 'lucide-vue-next';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen flex flex-col items-center justify-center px-6 py-12 text-center">
|
||||
<div class="w-20 h-20 rounded-3xl bg-gradient-to-br from-amber-500 to-orange-600 flex items-center justify-center text-4xl shadow-lg shadow-amber-500/20 mb-6">
|
||||
🎡
|
||||
</div>
|
||||
<h1 class="text-2xl font-extrabold text-white mb-3">گردونه شانس</h1>
|
||||
<p class="text-slate-400 text-sm max-w-sm leading-7 mb-2">
|
||||
برای شرکت در قرعهکشی، لینک اختصاصی کمپین خود را از برگزارکننده دریافت کنید.
|
||||
</p>
|
||||
<p class="text-slate-500 text-xs max-w-xs">
|
||||
آدرس معمولاً به شکل
|
||||
<code dir="ltr" class="text-amber-400/90 bg-slate-800 px-1.5 py-0.5 rounded">/نام-کمپین</code>
|
||||
است.
|
||||
</p>
|
||||
<div class="mt-8 inline-flex items-center gap-2 text-amber-400/80 text-xs font-medium">
|
||||
<Sparkles class="w-4 h-4" />
|
||||
منتظر لینک کمپین باشید
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const router = useRouter();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen flex flex-col items-center justify-center px-6 py-12 text-center">
|
||||
<div class="text-6xl font-black text-slate-700 mb-4">۴۰۴</div>
|
||||
<h1 class="text-xl font-bold text-white mb-2">صفحه پیدا نشد</h1>
|
||||
<p class="text-slate-400 text-sm mb-8">آدرس وارد شده معتبر نیست.</p>
|
||||
<button
|
||||
type="button"
|
||||
class="px-6 py-3 rounded-xl bg-slate-800 border border-slate-700 text-slate-200 text-sm font-semibold"
|
||||
@click="router.push('/')"
|
||||
>
|
||||
بازگشت به صفحه اصلی
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_DEFAULT_CAMPAIGN_SLUG?: string;
|
||||
readonly VITE_CLIENT_PUBLIC_URL?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
|
||||
Generated
+89
-14
@@ -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: {}
|
||||
|
||||
Reference in New Issue
Block a user