From afde44ab8a7c5ff7b2c2f50d72fbf6f246ad4408 Mon Sep 17 00:00:00 2001 From: "s.esmaeili" Date: Sun, 13 Sep 2026 02:02:46 +0330 Subject: [PATCH] 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 --- .env.example | 4 + .gitignore | 2 + .npmrc | 1 + apps/admin/.env.example | 2 + apps/admin/Dockerfile | 17 +- apps/admin/index.html | 16 +- apps/admin/nginx.conf | 30 ++ .../src/components/mobile/MobileBottomNav.vue | 341 ++++++++++++++ .../src/components/mobile/MobileTopBar.vue | 154 +++++++ apps/admin/src/composables/useDevice.ts | 126 ++++++ apps/admin/src/layouts/MobileLayout.vue | 134 ++++++ apps/admin/src/main.ts | 3 + apps/admin/src/router/index.ts | 152 ++++++- apps/admin/src/utils/adminListUtils.ts | 87 ++++ apps/admin/src/utils/clientUrl.ts | 77 ++++ apps/admin/src/views/auth/LoginView.vue | 3 +- .../src/views/campaigns/CampaignListView.vue | 81 ++-- .../src/views/campaigns/WheelDesignPicker.vue | 70 +++ .../src/views/dashboard/DashboardView.vue | 5 + apps/admin/src/views/mobile/MAuditLogView.vue | 156 +++++++ .../src/views/mobile/MCampaignFormView.vue | 316 +++++++++++++ .../src/views/mobile/MCampaignListView.vue | 308 +++++++++++++ .../admin/src/views/mobile/MDashboardView.vue | 419 ++++++++++++++++++ apps/admin/src/views/mobile/MLoginView.vue | 189 ++++++++ .../admin/src/views/mobile/MPrizeListView.vue | 361 +++++++++++++++ apps/admin/src/views/mobile/MReportView.vue | 125 ++++++ apps/admin/src/views/mobile/MSmsLogView.vue | 141 ++++++ apps/admin/src/views/mobile/MSpinListView.vue | 156 +++++++ .../src/views/mobile/MUserDetailView.vue | 231 ++++++++++ apps/admin/src/views/mobile/MUserListView.vue | 253 +++++++++++ .../src/views/mobile/MWinnerListView.vue | 197 ++++++++ apps/admin/src/views/prizes/PrizeListView.vue | 4 +- apps/admin/src/views/spins/SpinListView.vue | 56 ++- apps/admin/src/views/users/UserDetailView.vue | 3 +- .../src/views/winners/WinnerListView.vue | 57 ++- apps/admin/src/vite-env.d.ts | 9 + apps/admin/vite.config.ts | 1 + apps/api/Dockerfile | 6 + .../migration.sql | 6 + apps/api/prisma/schema.prisma | 8 +- .../src/modules/campaign/campaign.service.ts | 42 +- apps/api/src/modules/winner/winner.service.ts | 48 +- apps/client/.env.development | 2 + apps/client/.env.example | 2 + apps/client/Dockerfile | 14 +- apps/client/nginx.conf | 30 ++ .../src/components/wheels/CandyLandWheel.vue | 83 ++++ .../components/wheels/ClassicCasinoWheel.vue | 130 +----- .../src/components/wheels/CrystalIceWheel.vue | 89 ++++ .../src/components/wheels/FireFlameWheel.vue | 93 ++++ .../components/wheels/GalaxySpaceWheel.vue | 99 +++++ .../src/components/wheels/LuxuryGoldWheel.vue | 91 +--- .../components/wheels/MinimalFlatWheel.vue | 86 +--- .../src/components/wheels/NeonCyberWheel.vue | 83 +--- .../components/wheels/PlayfulCartoonWheel.vue | 89 +--- .../src/components/wheels/RusticWoodWheel.vue | 103 +++++ apps/client/src/composables/useWheelBase.ts | 112 +++++ apps/client/src/router/index.ts | 33 +- apps/client/src/views/CampaignWheelView.vue | 44 +- apps/client/src/views/HomeView.vue | 24 + apps/client/src/views/NotFoundView.vue | 20 + apps/client/src/vite-env.d.ts | 10 + apps/client/vite.config.ts | 23 + docker-compose.yml | 6 +- package.json | 16 + packages/shared-types/src/index.ts | 5 + pnpm-lock.yaml | 103 ++++- 67 files changed, 5229 insertions(+), 558 deletions(-) create mode 100644 .env.example create mode 100644 .npmrc create mode 100644 apps/admin/.env.example create mode 100644 apps/admin/nginx.conf create mode 100644 apps/admin/src/components/mobile/MobileBottomNav.vue create mode 100644 apps/admin/src/components/mobile/MobileTopBar.vue create mode 100644 apps/admin/src/composables/useDevice.ts create mode 100644 apps/admin/src/layouts/MobileLayout.vue create mode 100644 apps/admin/src/utils/adminListUtils.ts create mode 100644 apps/admin/src/utils/clientUrl.ts create mode 100644 apps/admin/src/views/mobile/MAuditLogView.vue create mode 100644 apps/admin/src/views/mobile/MCampaignFormView.vue create mode 100644 apps/admin/src/views/mobile/MCampaignListView.vue create mode 100644 apps/admin/src/views/mobile/MDashboardView.vue create mode 100644 apps/admin/src/views/mobile/MLoginView.vue create mode 100644 apps/admin/src/views/mobile/MPrizeListView.vue create mode 100644 apps/admin/src/views/mobile/MReportView.vue create mode 100644 apps/admin/src/views/mobile/MSmsLogView.vue create mode 100644 apps/admin/src/views/mobile/MSpinListView.vue create mode 100644 apps/admin/src/views/mobile/MUserDetailView.vue create mode 100644 apps/admin/src/views/mobile/MUserListView.vue create mode 100644 apps/admin/src/views/mobile/MWinnerListView.vue create mode 100644 apps/admin/src/vite-env.d.ts create mode 100644 apps/api/prisma/migrations/20260912194500_add_wheel_designs/migration.sql create mode 100644 apps/client/.env.development create mode 100644 apps/client/.env.example create mode 100644 apps/client/nginx.conf create mode 100644 apps/client/src/components/wheels/CandyLandWheel.vue create mode 100644 apps/client/src/components/wheels/CrystalIceWheel.vue create mode 100644 apps/client/src/components/wheels/FireFlameWheel.vue create mode 100644 apps/client/src/components/wheels/GalaxySpaceWheel.vue create mode 100644 apps/client/src/components/wheels/RusticWoodWheel.vue create mode 100644 apps/client/src/composables/useWheelBase.ts create mode 100644 apps/client/src/views/HomeView.vue create mode 100644 apps/client/src/views/NotFoundView.vue create mode 100644 apps/client/src/vite-env.d.ts 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 @@ + + + + + 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 @@ + + + + + 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(() => { + + diff --git a/apps/admin/src/views/users/UserDetailView.vue b/apps/admin/src/views/users/UserDetailView.vue index df9c240..2c47de5 100644 --- a/apps/admin/src/views/users/UserDetailView.vue +++ b/apps/admin/src/views/users/UserDetailView.vue @@ -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); } diff --git a/apps/admin/src/views/winners/WinnerListView.vue b/apps/admin/src/views/winners/WinnerListView.vue index 7033d46..5e9389e 100644 --- a/apps/admin/src/views/winners/WinnerListView.vue +++ b/apps/admin/src/views/winners/WinnerListView.vue @@ -128,24 +128,53 @@ function copyToClipboard(text: string, label: string) { message.success(`${label} با موفقیت کپی شد.`); } -const prizeTypeLabels: Record = { - DIGITAL_CODE: 'کد دیجیتال', - PHYSICAL: 'کالای فیزیکی', - DISCOUNT: 'کد تخفیف', - CASH: 'جایزه نقدی', - FREE_CHANCE: 'شانس مجدد', - CUSTOM: 'سفارشی', +const prizeTypeMap: Record = { + 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(() => { -