first commit

This commit is contained in:
2026-04-28 17:22:50 +03:30
commit 8041ce692a
19 changed files with 19295 additions and 0 deletions

14
.env.example Normal file
View File

@@ -0,0 +1,14 @@
# GEMINI_API_KEY: Required for Gemini AI API calls.
# AI Studio automatically injects this at runtime from user secrets.
# Users configure this via the Secrets panel in the AI Studio UI.
GEMINI_API_KEY="MY_GEMINI_API_KEY"
# APP_URL: The URL where this applet is hosted.
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
# Used for self-referential links, OAuth callbacks, and API endpoints.
APP_URL="MY_APP_URL"
# Backend API base URL for auth and dashboard requests.
# Example:
# VITE_API_BASE_URL="https://api.example.com"
VITE_API_BASE_URL="http://qkg4sggc0ocoo04cwokk0g80.65.109.214.67.sslip.io"

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
node_modules/
build/
dist/
coverage/
.DS_Store
*.log
.env*
!.env.example

21
README.md Normal file
View File

@@ -0,0 +1,21 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
</div>
# Run and deploy your AI Studio app
This contains everything you need to run your app locally.
View your app in AI Studio: https://ai.studio/apps/8a661eb5-dd66-42cd-a58c-87ef78c4d19c
## Run Locally
**Prerequisites:** Node.js
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in `.env.local`
3. Set `VITE_API_BASE_URL` in `.env.local` to your backend address
4. Run the app:
`npm run dev`

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>My Google AI Studio App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

6
metadata.json Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "GymPro Admin Panel",
"description": "یک پنل مدیریت پیشرفته برای باشگاه‌های ورزشی با قابلیت مدیریت اعضا، کلاس‌ها، و پرداخت‌ها به همراه تم تاریک و روشن.",
"requestFramePermissions": [],
"majorCapabilities": []
}

4852
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

38
package.json Normal file
View File

@@ -0,0 +1,38 @@
{
"name": "react-example",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --port=3000 --host=0.0.0.0",
"build": "vite build",
"preview": "vite preview",
"clean": "rm -rf dist",
"lint": "tsc --noEmit"
},
"dependencies": {
"@google/genai": "^1.29.0",
"@tailwindcss/vite": "^4.1.14",
"@vitejs/plugin-react": "^5.0.4",
"axios": "^1.15.2",
"clsx": "^2.1.1",
"dotenv": "^17.2.3",
"express": "^4.21.2",
"lucide-react": "^0.546.0",
"motion": "^12.23.24",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"recharts": "^3.8.1",
"tailwind-merge": "^3.5.0",
"vite": "^6.2.0"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^22.14.0",
"autoprefixer": "^10.4.21",
"tailwindcss": "^4.1.14",
"tsx": "^4.21.0",
"typescript": "~5.8.2",
"vite": "^6.2.0"
}
}

1387
src/App.tsx Normal file

File diff suppressed because it is too large Load Diff

26
src/index.css Normal file
View File

@@ -0,0 +1,26 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Vazirmatn:wght@400;500;700&display=swap');
@import "tailwindcss";
@theme {
--font-sans: "Inter", "Vazirmatn", ui-sans-serif, system-ui, sans-serif;
}
@layer base {
body {
@apply bg-slate-50 text-slate-900 transition-colors duration-300;
}
.dark body {
@apply bg-[#0A0A0B] text-zinc-100;
}
}
@theme {
--color-accent: #A3E635;
--color-card: #161618;
--color-border: #27272a;
}
.rtl {
direction: rtl;
}

50
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,50 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
const PERSIAN_DIGITS = '۰۱۲۳۴۵۶۷۸۹';
const ARABIC_DIGITS = '٠١٢٣٤٥٦٧٨٩';
export function normalizeDigits(value: string) {
return value
.split('')
.map((char) => {
const persianIndex = PERSIAN_DIGITS.indexOf(char);
if (persianIndex >= 0) {
return String(persianIndex);
}
const arabicIndex = ARABIC_DIGITS.indexOf(char);
if (arabicIndex >= 0) {
return String(arabicIndex);
}
return char;
})
.join('');
}
export function normalizeMobileNumber(value: string) {
const digitsOnly = normalizeDigits(value).replace(/\D/g, '');
if (digitsOnly.startsWith('0098')) {
return digitsOnly.slice(4);
}
if (digitsOnly.startsWith('98')) {
return digitsOnly.slice(2);
}
if (digitsOnly.startsWith('0')) {
return digitsOnly.slice(1);
}
return digitsOnly;
}
export function normalizeVerificationCode(value: string) {
return normalizeDigits(value).replace(/\D/g, '');
}

10
src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import {StrictMode} from 'react';
import {createRoot} from 'react-dom/client';
import App from './App.tsx';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);

84
src/services/api.ts Normal file
View File

@@ -0,0 +1,84 @@
import axios from 'axios';
const DEFAULT_API_HOST = 'qkg4sggc0ocoo04cwokk0g80.65.109.214.67.sslip.io';
const resolveApiBaseUrl = () => {
const configuredBaseUrl = import.meta.env.VITE_API_BASE_URL?.trim();
if (configuredBaseUrl) {
return configuredBaseUrl;
}
if (typeof window !== 'undefined' && window.location.protocol === 'https:') {
return `https://${DEFAULT_API_HOST}`;
}
return `http://${DEFAULT_API_HOST}`;
};
export const API_BASE_URL = resolveApiBaseUrl();
export const getApiErrorMessage = (error: unknown, fallbackMessage: string) => {
if (axios.isAxiosError(error)) {
if (!error.response) {
return 'اتصال به سرور برقرار نشد. آدرس بک‌اند یا در دسترس بودن سرور را بررسی کنید.';
}
const responseMessage =
error.response.data?.errors?.[0]?.message ||
error.response.data?.message ||
error.response.data?.title;
if (typeof responseMessage === 'string' && responseMessage.trim()) {
return responseMessage;
}
}
if (error instanceof Error && error.message.trim()) {
return error.message;
}
return fallbackMessage;
};
export const api = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
});
api.interceptors.request.use((config) => {
const token = localStorage.getItem('accessToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
const refreshToken = localStorage.getItem('refreshToken');
if (refreshToken) {
try {
const response = await axios.post(`${API_BASE_URL}/api/v1/accounts/refresh-token`, { refreshToken });
if (response.data.isSuccess) {
const { accessToken, refreshToken: newRefreshToken } = response.data.value;
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', newRefreshToken);
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
return api(originalRequest);
}
} catch (refreshError) {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
window.location.reload();
}
}
}
return Promise.reject(error);
}
);

View File

@@ -0,0 +1,49 @@
import { api } from './api';
import { normalizeMobileNumber, normalizeVerificationCode } from '../lib/utils';
export const authService = {
sendOtp: async (mobileNumber: string) => {
const normalizedMobile = normalizeMobileNumber(mobileNumber);
const response = await api.post('/api/v1/accounts/send-otp-sms', {
mobile: {
number: normalizedMobile,
countryCode: '98'
}
});
return response.data;
},
login: async (mobileNumber: string, code: string) => {
const normalizedMobile = normalizeMobileNumber(mobileNumber);
const normalizedCode = normalizeVerificationCode(code);
const response = await api.post('/api/v1/accounts/register-and-login-by-mobile', {
mobile: {
number: normalizedMobile,
countryCode: '98'
},
verificationCode: normalizedCode
});
if (response.data.isSuccess) {
const { accessToken, refreshToken } = response.data.value;
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', refreshToken);
}
return response.data;
},
logout: async () => {
const refreshToken = localStorage.getItem('refreshToken');
if (refreshToken) {
await api.post('/api/v1/accounts/logout', { refreshToken });
}
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
},
getMe: async () => {
const response = await api.get('/api/v1/users/me');
return response.data;
}
};

View File

@@ -0,0 +1,40 @@
import { api } from './api';
export const dashboardService = {
getUsers: async (offset = 0, limit = 10, search = '') => {
const response = await api.get('/api/v1/users', {
params: { 'Pagination.Offset': offset, 'Pagination.Limit': limit, Search: search }
});
return response.data;
},
updateUserBlockStatus: async (userId: string, isBlocked: boolean) => {
const response = await api.put(`/api/v1/users/${userId}/block-status`, { isBlocked });
return response.data;
},
promoteUserToAdmin: async (userId: string) => {
const response = await api.post(`/api/v1/users/${userId}/promote-to-admin`);
return response.data;
},
getInvoices: async (offset = 0, limit = 10) => {
const response = await api.get('/api/v1/subscriptions/invoices', {
params: { 'Pagination.Offset': offset, 'Pagination.Limit': limit }
});
return response.data;
},
getSubscriptions: async (offset = 0, limit = 10, isActive: boolean | null = null) => {
const params: any = { 'Pagination.Offset': offset, 'Pagination.Limit': limit };
if (isActive !== null) params.IsActive = isActive;
const response = await api.get('/api/v1/subscriptions/user-subscriptions', { params });
return response.data;
},
getPackages: async () => {
const response = await api.get('/api/v1/subscriptions/packages');
return response.data;
}
};

View File

@@ -0,0 +1,149 @@
import { api } from './api';
type MasterEntityKey =
| 'tags'
| 'safety-levels'
| 'muscles'
| 'muscle-groups'
| 'metric-types'
| 'locations'
| 'exercise-types'
| 'exercise-synonyms'
| 'equipments'
| 'difficulty-levels';
const masterPaths: Record<MasterEntityKey, string> = {
'tags': '/api/v1/tags',
'safety-levels': '/api/v1/safety-levels',
'muscles': '/api/v1/muscles',
'muscle-groups': '/api/v1/muscle-groups',
'metric-types': '/api/v1/metric-types',
'locations': '/api/v1/locations',
'exercise-types': '/api/v1/exercise-types',
'exercise-synonyms': '/api/v1/exercise-synonyms',
'equipments': '/api/v1/equipments',
'difficulty-levels': '/api/v1/difficulty-levels',
};
export const exerciseService = {
getExercises: async (offset = 0, limit = 10, search = '') => {
const response = await api.get('/api/v1/exercises', {
params: {
'Pagination.Offset': offset,
'Pagination.Limit': limit,
Search: search,
},
});
return response.data;
},
getExerciseByPublicId: async (publicId: string) => {
const response = await api.get(`/api/v1/exercises/${publicId}`);
return response.data;
},
createExercise: async (payload: {
title: string;
description: string;
instructions: string;
difficultyLevelId: number | string;
exerciseTypeId: number | string;
safetyLevelId: number | string;
}) => {
const response = await api.post('/api/v1/exercises', payload);
return response.data;
},
getMasterList: async (entity: MasterEntityKey, offset = 0, limit = 50, search = '') => {
const response = await api.get(masterPaths[entity], {
params: {
'Pagination.Offset': offset,
'Pagination.Limit': limit,
Search: search,
},
});
return response.data;
},
createMasterItem: async (entity: MasterEntityKey, payload: Record<string, unknown>) => {
const response = await api.post(masterPaths[entity], payload);
return response.data;
},
updateMasterItem: async (entity: MasterEntityKey, payload: Record<string, unknown>) => {
const response = await api.put(masterPaths[entity], payload);
return response.data;
},
deleteMasterItem: async (entity: MasterEntityKey, id: number | string) => {
const response = await api.delete(`${masterPaths[entity]}/${id}`);
return response.data;
},
addExerciseMuscle: async (exerciseId: number | string, muscleId: number | string, isPrimary: boolean) => {
const response = await api.post(`/api/v1/exercises/${exerciseId}/muscles`, { exerciseId, muscleId, isPrimary });
return response.data;
},
deleteExerciseMuscle: async (exerciseId: number | string, muscleId: number | string) => {
const response = await api.delete(`/api/v1/exercises/${exerciseId}/muscles/${muscleId}`);
return response.data;
},
addExerciseMetric: async (exerciseId: number | string, metricTypeId: number | string, isPrimary: boolean) => {
const response = await api.post(`/api/v1/exercises/${exerciseId}/metrics`, { exerciseId, metricTypeId, isPrimary });
return response.data;
},
deleteExerciseMetric: async (exerciseId: number | string, metricTypeId: number | string) => {
const response = await api.delete(`/api/v1/exercises/${exerciseId}/metrics/${metricTypeId}`);
return response.data;
},
addExerciseTag: async (exerciseId: number | string, tagId: number | string) => {
const response = await api.post(`/api/v1/exercises/${exerciseId}/tags`, { exerciseId, tagId });
return response.data;
},
deleteExerciseTag: async (exerciseId: number | string, tagId: number | string) => {
const response = await api.delete(`/api/v1/exercises/${exerciseId}/tags/${tagId}`);
return response.data;
},
addExerciseLocation: async (exerciseId: number | string, locationId: number | string) => {
const response = await api.post(`/api/v1/exercises/${exerciseId}/locations`, { exerciseId, locationId });
return response.data;
},
deleteExerciseLocation: async (exerciseId: number | string, locationId: number | string) => {
const response = await api.delete(`/api/v1/exercises/${exerciseId}/locations/${locationId}`);
return response.data;
},
addExerciseEquipment: async (exerciseId: number | string, equipmentId: number | string) => {
const response = await api.post(`/api/v1/exercises/${exerciseId}/equipments`, { exerciseId, equipmentId });
return response.data;
},
deleteExerciseEquipment: async (exerciseId: number | string, equipmentId: number | string) => {
const response = await api.delete(`/api/v1/exercises/${exerciseId}/equipments/${equipmentId}`);
return response.data;
},
addExerciseSynonym: async (exerciseId: number | string, synonym: string, languageCode: string) => {
const response = await api.post('/api/v1/exercise-synonyms', { exerciseId, synonym, languageCode });
return response.data;
},
updateExerciseSynonym: async (id: number | string, synonym: string, languageCode: string) => {
const response = await api.put('/api/v1/exercise-synonyms', { id, synonym, languageCode });
return response.data;
},
deleteExerciseSynonym: async (id: number | string) => {
const response = await api.delete(`/api/v1/exercise-synonyms/${id}`);
return response.data;
},
};
export type { MasterEntityKey };

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

26
tsconfig.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}

12497
v1.json Normal file

File diff suppressed because it is too large Load Diff

24
vite.config.ts Normal file
View File

@@ -0,0 +1,24 @@
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import path from 'path';
import {defineConfig, loadEnv} from 'vite';
export default defineConfig(({mode}) => {
const env = loadEnv(mode, '.', '');
return {
plugins: [react(), tailwindcss()],
define: {
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY),
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
},
},
server: {
// HMR is disabled in AI Studio via DISABLE_HMR env var.
// Do not modify—file watching is disabled to prevent flickering during agent edits.
hmr: process.env.DISABLE_HMR !== 'true',
},
};
});