diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..164ea9d
--- /dev/null
+++ b/.env.example
@@ -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
diff --git a/.gitignore b/.gitignore
index da76eec..8c6704d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,5 @@ uploads/
coverage/
.DS_Store
Thumbs.db
+package-lock.json
+*.ps1
diff --git a/.npmrc b/.npmrc
new file mode 100644
index 0000000..bb9770e
--- /dev/null
+++ b/.npmrc
@@ -0,0 +1 @@
+require-build-scripts-approval=false
diff --git a/apps/admin/.env.example b/apps/admin/.env.example
new file mode 100644
index 0000000..aad8eff
--- /dev/null
+++ b/apps/admin/.env.example
@@ -0,0 +1,2 @@
+# Public client app URL for "view wheel" links in admin panel
+VITE_CLIENT_PUBLIC_URL=http://localhost:5174
\ No newline at end of file
diff --git a/apps/admin/Dockerfile b/apps/admin/Dockerfile
index fbf74a5..f4a6ba8 100644
--- a/apps/admin/Dockerfile
+++ b/apps/admin/Dockerfile
@@ -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
diff --git a/apps/admin/index.html b/apps/admin/index.html
index 76b8e7e..781ceb8 100644
--- a/apps/admin/index.html
+++ b/apps/admin/index.html
@@ -3,7 +3,12 @@
-
+
+
+
+
+
+
پنل مدیریت گردونه شانس و قرعهکشی
@@ -11,11 +16,18 @@
diff --git a/apps/admin/nginx.conf b/apps/admin/nginx.conf
new file mode 100644
index 0000000..a9ad253
--- /dev/null
+++ b/apps/admin/nginx.conf
@@ -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;
+ }
+}
diff --git a/apps/admin/src/components/mobile/MobileBottomNav.vue b/apps/admin/src/components/mobile/MobileBottomNav.vue
new file mode 100644
index 0000000..1d8301d
--- /dev/null
+++ b/apps/admin/src/components/mobile/MobileBottomNav.vue
@@ -0,0 +1,341 @@
+
+
+
+
+
+
+
+ منوی بیشتر
+
+
+
+
+
+
+
+
+ {{ (authStore.admin.name || 'ا').charAt(0) }}
+
+
+
{{ authStore.admin.name }}
+
{{ authStore.admin.email }}
+
+
+
+
+
+
+
+
diff --git a/apps/admin/src/components/mobile/MobileTopBar.vue b/apps/admin/src/components/mobile/MobileTopBar.vue
new file mode 100644
index 0000000..001cf68
--- /dev/null
+++ b/apps/admin/src/components/mobile/MobileTopBar.vue
@@ -0,0 +1,154 @@
+
+
+
+
+
+
+
+
+
{{ title }}
+
+
+
+
+
+
+
+
+
diff --git a/apps/admin/src/composables/useDevice.ts b/apps/admin/src/composables/useDevice.ts
new file mode 100644
index 0000000..62bd563
--- /dev/null
+++ b/apps/admin/src/composables/useDevice.ts
@@ -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 = {
+ 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 = 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();
+}
diff --git a/apps/admin/src/layouts/MobileLayout.vue b/apps/admin/src/layouts/MobileLayout.vue
new file mode 100644
index 0000000..5946066
--- /dev/null
+++ b/apps/admin/src/layouts/MobileLayout.vue
@@ -0,0 +1,134 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/admin/src/main.ts b/apps/admin/src/main.ts
index 85e72ea..cc1fb97 100644
--- a/apps/admin/src/main.ts
+++ b/apps/admin/src/main.ts
@@ -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');
diff --git a/apps/admin/src/router/index.ts b/apps/admin/src/router/index.ts
index 66672c6..548fc86 100644
--- a/apps/admin/src/router/index.ts
+++ b/apps/admin/src/router/index.ts
@@ -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; query: Record }) {
+ 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; query: Record }) {
+ 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();
});
diff --git a/apps/admin/src/utils/adminListUtils.ts b/apps/admin/src/utils/adminListUtils.ts
new file mode 100644
index 0000000..ce26a10
--- /dev/null
+++ b/apps/admin/src/utils/adminListUtils.ts
@@ -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 = {
+ 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 = {
+ 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(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 };
+}
diff --git a/apps/admin/src/utils/clientUrl.ts b/apps/admin/src/utils/clientUrl.ts
new file mode 100644
index 0000000..7d11ed4
--- /dev/null
+++ b/apps/admin/src/utils/clientUrl.ts
@@ -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');
+}
diff --git a/apps/admin/src/views/auth/LoginView.vue b/apps/admin/src/views/auth/LoginView.vue
index fa60ef9..3208fa4 100644
--- a/apps/admin/src/views/auth/LoginView.vue
+++ b/apps/admin/src/views/auth/LoginView.vue
@@ -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 {
diff --git a/apps/admin/src/views/campaigns/CampaignListView.vue b/apps/admin/src/views/campaigns/CampaignListView.vue
index d08d28f..100a0b3 100644
--- a/apps/admin/src/views/campaigns/CampaignListView.vue
+++ b/apps/admin/src/views/campaigns/CampaignListView.vue
@@ -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([]);
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 = {
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(() => {
- {{ toFarsiDigits(new Date(record.startAt).toLocaleDateString('fa-IR')) }}
+ {{ record.startAtFormatted ? toFarsiDigits(record.startAtFormatted) : formatJalaliDateTime(record.startAt) }}
- {{ toFarsiDigits(new Date(record.endAt).toLocaleDateString('fa-IR')) }}
+ {{ record.endAtFormatted ? toFarsiDigits(record.endAtFormatted) : formatJalaliDateTime(record.endAt) }}
+
+
+
+
+ تعریفنشده
@@ -228,15 +259,11 @@ onMounted(() => {