diff --git a/.env.backend b/.env.backend new file mode 100644 index 0000000..a62e683 --- /dev/null +++ b/.env.backend @@ -0,0 +1,14 @@ +# Server port +PORT=8080 + +# PostgreSQL connection string +DATABASE_URL=postgres://user:password@localhost:5432/quiz_app?sslmode=disable + +# Redis connection string +REDIS_URL=redis://localhost:6379/0 + +# Secret key for signing JWT or validating Telegram data +SECRET_KEY=your-very-secret-key + +# Bot token +BOT_TOKEN=8130773519:AAG__5YpiFROrdJovts6xm5Edl0EEvD0bxo \ No newline at end of file diff --git a/.env.bot b/.env.bot new file mode 100644 index 0000000..0a49f77 --- /dev/null +++ b/.env.bot @@ -0,0 +1,17 @@ +# Telegram Bot Token +BOT_TOKEN=8130773519:AAG__5YpiFROrdJovts6xm5Edl0EEvD0bxo + +# Backend API URL +BACKEND_API_URL=http://localhost:8080 + +# Frontend URL (for mini app) +FRONTEND_URL=https://www.google.com + +# Bot username (without @) +BOT_USERNAME=volsu_sno_bot + +# Admin user IDs (comma-separated) +ADMIN_USER_IDS=6113992941 + +# Operator user IDs (comma-separated) +OPERATOR_USER_IDS=6968371470 diff --git a/.env.db b/.env.db new file mode 100644 index 0000000..c1c6f1f --- /dev/null +++ b/.env.db @@ -0,0 +1,5 @@ +# Database Configuration +POSTGRES_USER=user +POSTGRES_PASSWORD=password +POSTGRES_DB=quiz_app +POSTGRES_PORT=5432 \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1c01389 --- /dev/null +++ b/.env.example @@ -0,0 +1,26 @@ +# PostgreSQL Configuration +POSTGRES_USER=user +POSTGRES_PASSWORD=password +POSTGRES_DB=quiz_app +POSTGRES_PORT=5432 + +# Redis Configuration +REDIS_PORT=6379 + +# Nginx Configuration +NGINX_PORT=80 + +# Frontend Configuration +FRONTEND_API_URL=http://localhost:80 + +# Backend Configuration +PORT=8080 +SECRET_KEY=your-very-secret-key + +# Bot Configuration +BOT_TOKEN=your-telegram-bot-token +BACKEND_API_URL=http://backend:8080 +FRONTEND_URL=http://localhost +BOT_USERNAME=your_bot_username +ADMIN_USER_IDS=123456789 +OPERATOR_USER_IDS=123456789 \ No newline at end of file diff --git a/.env.redis b/.env.redis new file mode 100644 index 0000000..14df72f --- /dev/null +++ b/.env.redis @@ -0,0 +1,2 @@ +# Redis Configuration +REDIS_PORT=6379 \ No newline at end of file diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..26841d4 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,51 @@ +# Build outputs +*.exe +*.exe~ +*.dll +*.so +*.dylib +main +main.exe + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool +*.out + +# Go workspace files +go.work + +# Environment files +.env +.env.local +.env.production +.env.development + +# IDE and editor files +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Git files +.git +.gitignore + +# Temporary files +*.tmp +*.temp +*.log + +# Dependencies +vendor/ \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..6cd97df --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,48 @@ +# Build stage +FROM golang:1.24-alpine AS builder + +WORKDIR /app + +# Install git and other dependencies +RUN apk add --no-cache git + +# Copy go mod files +COPY go.mod go.sum ./ + +# Download dependencies +RUN go mod download + +# Install goose +RUN go install github.com/pressly/goose/v3/cmd/goose@latest + +# Copy source code +COPY . . + +# Build the application +RUN CGO_ENABLED=0 GOOS=linux go build -o main ./cmd/server/main.go + +# Production stage +FROM alpine:latest + +# Install ca-certificates for HTTPS and postgresql client +RUN apk --no-cache add ca-certificates tzdata postgresql-client + +WORKDIR /app + +# Copy binary from builder stage +COPY --from=builder /app/main . + +# Copy goose for migrations +COPY --from=builder /go/bin/goose /usr/local/bin/goose + +# Copy migrations +COPY migrations ./migrations + +# Copy environment file if exists +COPY .env.example .env + +# Expose port +EXPOSE 8080 + +# Run the application +CMD ["./main"] \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7c3502b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,145 @@ +version: '3.8' + +services: + # База данных PostgreSQL + postgres: + image: postgres:14-alpine + container_name: sno_postgres + env_file: + - .env.db + environment: + POSTGRES_USER: ${POSTGRES_USER:-user} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-password} + POSTGRES_DB: ${POSTGRES_DB:-quiz_app} + ports: + - "${POSTGRES_PORT:-5432}:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - sno_network + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-user} -d ${POSTGRES_DB:-quiz_app}"] + interval: 10s + timeout: 5s + retries: 5 + + # Migration runner + migrations: + build: + context: ./backend + dockerfile: Dockerfile + container_name: sno_migrations + env_file: + - .env.db + - .env.backend + environment: + - DATABASE_URL=postgres://${POSTGRES_USER:-user}:${POSTGRES_PASSWORD:-password}@postgres:5432/${POSTGRES_DB:-quiz_app}?sslmode=disable + depends_on: + postgres: + condition: service_healthy + networks: + - sno_network + command: > + sh -c " + echo 'Running database migrations...' + goose -dir ./migrations postgres \"$$DATABASE_URL\" up + echo 'Running setup admin script...' + psql \"$$DATABASE_URL\" < /app/setup_admin.sql + echo 'Migrations completed successfully' + " + volumes: + - ./setup_admin.sql:/app/setup_admin.sql:ro + profiles: + - migrations + + # Redis для кэширования + redis: + image: redis:7-alpine + container_name: sno_redis + env_file: + - .env.redis + ports: + - "${REDIS_PORT:-6379}:6379" + volumes: + - redis_data:/data + networks: + - sno_network + restart: unless-stopped + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 3 + + # Backend на Go + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: sno_backend + env_file: + - ./backend/.env + - .env.backend + environment: + - DATABASE_URL=postgres://${POSTGRES_USER:-user}:${POSTGRES_PASSWORD:-password}@postgres:5432/${POSTGRES_DB:-quiz_app}?sslmode=disable + - REDIS_URL=redis://redis:6379/0 + ports: + - "8080:8080" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + networks: + - sno_network + restart: unless-stopped + + # Telegram бот на Python + bot: + build: + context: ./bot + dockerfile: Dockerfile + container_name: sno_bot + env_file: + - ./bot/.env + - .env.bot + environment: + - DATABASE_URL=postgres://${POSTGRES_USER:-user}:${POSTGRES_PASSWORD:-password}@postgres:5432/${POSTGRES_DB:-quiz_app}?sslmode=disable + - REDIS_URL=redis://redis:6379/0 + - BACKEND_API_URL=http://backend:8080 + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + networks: + - sno_network + restart: unless-stopped + volumes: + - ./bot:/app + working_dir: /app + + # Frontend на React (production build) + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: sno_frontend + environment: + - VITE_API_BASE_URL=http://localhost/api + ports: + - "${NGINX_PORT:-80}:80" + depends_on: + - backend + networks: + - sno_network + restart: unless-stopped + +volumes: + postgres_data: + redis_data: + +networks: + sno_network: + driver: bridge \ No newline at end of file diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..a316a75 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,38 @@ +# Dependencies +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Build outputs +dist/ +build/ + +# Environment variables +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Logs +*.log + +# Editor directories and files +.idea/ +.vscode/ +*.swp +*.swo + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Temporary files +*.tmp +*.temp \ No newline at end of file diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..ad9c001 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,34 @@ +# Build stage +FROM node:20-alpine AS builder + +# Install pnpm +RUN npm install -g pnpm + +WORKDIR /app + +# Copy package files +COPY package.json pnpm-lock.yaml ./ + +# Install dependencies +RUN pnpm install + +# Copy source code +COPY . . + +# Build the application +RUN pnpm run build + +# Production stage +FROM nginx:alpine + +# Copy built files from builder stage +COPY --from=builder /app/dist /usr/share/nginx/html + +# Copy nginx configuration +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# Expose port +EXPOSE 80 + +# Start nginx +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..8a29f95 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,48 @@ +server { + listen 80; + server_name localhost; + + # Frontend (React build) + location / { + root /usr/share/nginx/html; + index index.html; + try_files $uri $uri/ /index.html; + + # Cache static files + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + } + + # Backend API + location /api/ { + proxy_pass http://backend:8080/; + 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; + + # CORS headers + add_header Access-Control-Allow-Origin *; + add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"; + add_header Access-Control-Allow-Headers "Origin, X-Requested-With, Content-Type, Accept, Authorization"; + + # Handle OPTIONS requests + if ($request_method = 'OPTIONS') { + add_header Access-Control-Allow-Origin *; + add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"; + add_header Access-Control-Allow-Headers "Origin, X-Requested-With, Content-Type, Accept, Authorization"; + add_header Access-Control-Max-Age 1728000; + add_header Content-Type 'text/plain; charset=utf-8'; + add_header Content-Length 0; + return 204; + } + } + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; +} \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index a8a1b31..49e954f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,9 +16,11 @@ "@mui/material": "^7.3.2", "@mui/x-data-grid": "^8.11.2", "@types/node": "^24.5.1", + "@types/qrcode": "^1.5.5", "axios": "^1.12.2", "html5-qrcode": "^2.3.8", "lucide-react": "^0.544.0", + "qrcode": "^1.5.4", "react": "^19.1.1", "react-dom": "^19.1.1", "react-router-dom": "^7.9.1" diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 76bb1e1..da17d6b 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: '@types/node': specifier: ^24.5.1 version: 24.5.1 + '@types/qrcode': + specifier: ^1.5.5 + version: 1.5.5 axios: specifier: ^1.12.2 version: 1.12.2 @@ -35,6 +38,9 @@ importers: lucide-react: specifier: ^0.544.0 version: 0.544.0(react@19.1.1) + qrcode: + specifier: ^1.5.4 + version: 1.5.4 react: specifier: ^19.1.1 version: 19.1.1 @@ -718,6 +724,9 @@ packages: '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + '@types/qrcode@1.5.5': + resolution: {integrity: sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==} + '@types/react-dom@19.1.9': resolution: {integrity: sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==} peerDependencies: @@ -809,6 +818,10 @@ packages: ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} @@ -856,6 +869,10 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + caniuse-lite@1.0.30001743: resolution: {integrity: sha512-e6Ojr7RV14Un7dz6ASD0aZDmQPT/A+eZU+nuTNfjqmRrmkmQlnTNWH0SKmqagx9PeW87UVqapSurtAXifmtdmw==} @@ -863,6 +880,9 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -911,6 +931,10 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -918,6 +942,9 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + dom-helpers@5.2.1: resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} @@ -928,6 +955,9 @@ packages: electron-to-chromium@1.5.218: resolution: {integrity: sha512-uwwdN0TUHs8u6iRgN8vKeWZMRll4gBkz+QMqdS7DDe49uiK68/UX92lFb61oiFPrpYZNeZIqa4bA7O6Aiasnzg==} + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} @@ -1049,6 +1079,10 @@ packages: find-root@1.1.0: resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==} + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -1085,6 +1119,10 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -1165,6 +1203,10 @@ packages: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} @@ -1215,6 +1257,10 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -1283,14 +1329,26 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -1325,6 +1383,10 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} @@ -1343,6 +1405,11 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -1388,6 +1455,13 @@ packages: resolution: {integrity: sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==} engines: {node: '>=0.10.0'} + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + reselect@5.1.1: resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} @@ -1424,6 +1498,9 @@ packages: engines: {node: '>=10'} hasBin: true + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + set-cookie-parser@2.7.1: resolution: {integrity: sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==} @@ -1443,6 +1520,14 @@ packages: resolution: {integrity: sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==} engines: {node: '>=0.10.0'} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -1545,6 +1630,9 @@ packages: yaml: optional: true + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -1554,6 +1642,13 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -1561,6 +1656,14 @@ packages: resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} engines: {node: '>= 6'} + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -2154,6 +2257,10 @@ snapshots: '@types/prop-types@15.7.15': {} + '@types/qrcode@1.5.5': + dependencies: + '@types/node': 24.5.1 + '@types/react-dom@19.1.9(@types/react@19.1.13)': dependencies: '@types/react': 19.1.13 @@ -2284,6 +2391,8 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ansi-regex@5.0.1: {} + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 @@ -2338,6 +2447,8 @@ snapshots: callsites@3.1.0: {} + camelcase@5.3.1: {} + caniuse-lite@1.0.30001743: {} chalk@4.1.2: @@ -2345,6 +2456,12 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + clsx@2.1.1: {} color-convert@2.0.1: @@ -2385,10 +2502,14 @@ snapshots: dependencies: ms: 2.1.3 + decamelize@1.2.0: {} + deep-is@0.1.4: {} delayed-stream@1.0.0: {} + dijkstrajs@1.0.3: {} + dom-helpers@5.2.1: dependencies: '@babel/runtime': 7.28.4 @@ -2402,6 +2523,8 @@ snapshots: electron-to-chromium@1.5.218: {} + emoji-regex@8.0.0: {} + error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 @@ -2561,6 +2684,11 @@ snapshots: find-root@1.1.0: {} + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -2590,6 +2718,8 @@ snapshots: gensync@1.0.0-beta.2: {} + get-caller-file@2.0.5: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -2661,6 +2791,8 @@ snapshots: is-extglob@2.1.1: {} + is-fullwidth-code-point@3.0.0: {} + is-glob@4.0.3: dependencies: is-extglob: 2.1.1 @@ -2698,6 +2830,10 @@ snapshots: lines-and-columns@1.2.4: {} + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -2758,14 +2894,24 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 + p-try@2.2.0: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -2791,6 +2937,8 @@ snapshots: picomatch@4.0.3: {} + pngjs@5.0.0: {} + postcss@8.5.6: dependencies: nanoid: 3.3.11 @@ -2809,6 +2957,12 @@ snapshots: punycode@2.3.1: {} + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + queue-microtask@1.2.3: {} react-dom@19.1.1(react@19.1.1): @@ -2847,6 +3001,10 @@ snapshots: react@19.1.1: {} + require-directory@2.1.1: {} + + require-main-filename@2.0.0: {} + reselect@5.1.1: {} resolve-from@4.0.0: {} @@ -2896,6 +3054,8 @@ snapshots: semver@7.7.2: {} + set-blocking@2.0.0: {} + set-cookie-parser@2.7.1: {} shebang-command@2.0.0: @@ -2908,6 +3068,16 @@ snapshots: source-map@0.5.7: {} + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + strip-json-comments@3.1.1: {} stylis@4.2.0: {} @@ -2976,14 +3146,43 @@ snapshots: '@types/node': 24.5.1 fsevents: 2.3.3 + which-module@2.0.1: {} + which@2.0.2: dependencies: isexe: 2.0.0 word-wrap@1.2.5: {} + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + y18n@4.0.3: {} + yallist@3.1.1: {} yaml@1.10.2: {} + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + yocto-queue@0.1.0: {} diff --git a/frontend/src/components/QRCodeDisplay.tsx b/frontend/src/components/QRCodeDisplay.tsx new file mode 100644 index 0000000..9b92f8a --- /dev/null +++ b/frontend/src/components/QRCodeDisplay.tsx @@ -0,0 +1,103 @@ +import React from 'react'; +import QRCode from 'qrcode'; +import { Box, Typography, Button } from '@mui/material'; +import { Download } from '@mui/icons-material'; + +interface QRCodeDisplayProps { + token: string; + type: string; + value: string; + index: number; +} + +export const QRCodeDisplay: React.FC = ({ token, type, value, index }) => { + const [qrCodeDataUrl, setQrCodeDataUrl] = React.useState(''); + + React.useEffect(() => { + const generateQRCode = async () => { + try { + const dataUrl = await QRCode.toDataURL(token, { + width: 200, + margin: 2, + color: { + dark: '#000000', + light: '#FFFFFF' + } + }); + setQrCodeDataUrl(dataUrl); + } catch (err) { + console.error('Error generating QR code:', err); + } + }; + + generateQRCode(); + }, [token]); + + const downloadQRCode = () => { + if (!qrCodeDataUrl) return; + + const link = document.createElement('a'); + link.download = `qr_${type}_${value}_${index + 1}.png`; + link.href = qrCodeDataUrl; + link.click(); + }; + + return ( + + + QR #{index + 1} + + + {token} + + {qrCodeDataUrl ? ( + + {`QR + + ) : ( + + + Генерация... + + + )} + + + ); +}; \ No newline at end of file diff --git a/frontend/src/components/layout/Navigation.tsx b/frontend/src/components/layout/Navigation.tsx index ef849fe..21dcae6 100644 --- a/frontend/src/components/layout/Navigation.tsx +++ b/frontend/src/components/layout/Navigation.tsx @@ -17,7 +17,7 @@ import { useAuth } from '../../context/AuthContext'; export const Navigation: React.FC = () => { const navigate = useNavigate(); const location = useLocation(); - const { user, isAdmin } = useAuth(); + const { isAdmin } = useAuth(); const getValue = () => { if (location.pathname === '/' || location.pathname === '/home') return 0; diff --git a/frontend/src/components/ui/AnswerOption.tsx b/frontend/src/components/ui/AnswerOption.tsx index 5583491..61d4747 100644 --- a/frontend/src/components/ui/AnswerOption.tsx +++ b/frontend/src/components/ui/AnswerOption.tsx @@ -104,7 +104,7 @@ export const AnswerOption: React.FC = ({ transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)', position: 'relative', overflow: 'hidden', - cursor: !disabled && !showResult ? 'pointer' : 'default', + pointerEvents: !disabled && !showResult ? 'auto' : 'none', zIndex: 1, ...getCardStyles(), '&:hover': !disabled && !showResult ? { diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index 8e34582..8990583 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -1,4 +1,5 @@ import React, { useState, useEffect } from 'react'; +import QRCode from 'qrcode'; import { Box, Typography, @@ -24,6 +25,7 @@ import { Chip, } from '@mui/material'; import { GridItem } from '../components/GridItem'; +import { QRCodeDisplay } from '../components/QRCodeDisplay'; import { Dashboard, Quiz as QuizIcon, @@ -35,6 +37,8 @@ import { Delete, BarChart, DragIndicator, + QrCode, + Download, } from '@mui/icons-material'; import { useAuth } from '../context/AuthContext'; import { apiService } from '../services/api'; @@ -106,10 +110,19 @@ export const AdminPage: React.FC = () => { // Dialog states const [quizDialogOpen, setQuizDialogOpen] = useState(false); const [rewardDialogOpen, setRewardDialogOpen] = useState(false); + const [qrDialogOpen, setQrDialogOpen] = useState(false); const [editingQuiz, setEditingQuiz] = useState(null); const [editingReward, setEditingReward] = useState(null); const [submitting, setSubmitting] = useState(false); + // QR form states + const [qrForm, setQrForm] = useState({ + type: 'reward', + value: '', + count: 1, + }); + const [generatedQRCodes, setGeneratedQRCodes] = useState([]); + useEffect(() => { const fetchData = async () => { try { @@ -311,6 +324,116 @@ export const AdminPage: React.FC = () => { } }; + const handleGenerateQRCodes = async () => { + if (!qrForm.value || !qrForm.count) { + setError('Заполните все поля'); + return; + } + + setSubmitting(true); + try { + const response = await apiService.generateQRCodes(qrForm); + if (response.success) { + setGeneratedQRCodes(response.data.tokens || []); + } else { + setError(response.message || 'Не удалось сгенерировать QR-коды'); + } + } catch (err) { + console.error('Error generating QR codes:', err); + setError('Произошла ошибка при генерации QR-кодов'); + } finally { + setSubmitting(false); + } + }; + + const downloadQRCodes = async () => { + try { + // Create a zip-like file with all QR codes as base64 images + const qrData = await Promise.all( + generatedQRCodes.map(async (token, index) => { + try { + const dataUrl = await QRCode.toDataURL(token, { + width: 300, + margin: 2, + color: { + dark: '#000000', + light: '#FFFFFF' + } + }); + + // Convert base64 to blob + const base64Data = dataUrl.split(',')[1]; + const binaryData = atob(base64Data); + const bytes = new Uint8Array(binaryData.length); + for (let i = 0; i < binaryData.length; i++) { + bytes[i] = binaryData.charCodeAt(i); + } + + return { + blob: new Blob([bytes], { type: 'image/png' }), + filename: `qr_${qrForm.type}_${qrForm.value}_${index + 1}.png`, + token + }; + } catch (err) { + console.error('Error generating QR code:', err); + return null; + } + }) + ); + + const validQrData = qrData.filter(item => item !== null); + + if (validQrData.length === 0) { + setError('Не удалось сгенерировать QR-коды для скачивания'); + return; + } + + // Download each QR code individually + validQrData.forEach((qr, index) => { + if (qr) { + setTimeout(() => { + const url = URL.createObjectURL(qr.blob); + const a = document.createElement('a'); + a.href = url; + a.download = qr.filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }, index * 200); // Small delay between downloads + } + }); + + // Also create a text file with all tokens + const textContent = generatedQRCodes.map((token, index) => + `QR Code ${index + 1}\nType: ${qrForm.type}\nValue: ${qrForm.value}\nToken: ${token}\nFile: qr_${qrForm.type}_${qrForm.value}_${index + 1}.png\n${'-'.repeat(60)}` + ).join('\n'); + + const textBlob = new Blob([textContent], { type: 'text/plain' }); + const textUrl = URL.createObjectURL(textBlob); + const textA = document.createElement('a'); + textA.href = textUrl; + textA.download = `qrcodes_tokens_${qrForm.type}_${qrForm.value}_${new Date().toISOString().split('T')[0]}.txt`; + document.body.appendChild(textA); + textA.click(); + document.body.removeChild(textA); + URL.revokeObjectURL(textUrl); + + } catch (err) { + console.error('Error downloading QR codes:', err); + setError('Произошла ошибка при скачивании QR-кодов'); + } + }; + + const resetQRForm = () => { + setQrForm({ + type: 'reward', + value: '', + count: 1, + }); + setGeneratedQRCodes([]); + }; + const openEditQuizDialog = async (quiz: Quiz) => { setEditingQuiz(quiz); @@ -566,11 +689,17 @@ export const AdminPage: React.FC = () => { aria-controls="admin-tabpanel-2" /> } - label="Настройки" + icon={} + label="QR-коды" id="admin-tab-3" aria-controls="admin-tabpanel-3" /> + } + label="Настройки" + id="admin-tab-4" + aria-controls="admin-tabpanel-4" + /> @@ -644,6 +773,17 @@ export const AdminPage: React.FC = () => { > + { + setQrForm({ type: 'quiz', value: quiz.id.toString(), count: 1 }); + setQrDialogOpen(true); + }} + title="Сгенерировать QR-код" + > + + { > + { + setQrForm({ type: 'reward', value: reward.price_stars.toString(), count: 1 }); + setQrDialogOpen(true); + }} + title="Сгенерировать QR-код" + > + + { + + + Генерация QR-кодов + + + + + + + + Используйте эту функцию для создания QR-кодов для: + + +
  • Начисления звезд (reward)
  • +
  • Доступа к викторинам (quiz)
  • +
  • Специальных акций в магазине (shop)
  • +
    + + Каждый QR-код содержит уникальный токен и может быть использован только один раз. + Срок действия токена - 30 дней. + +
    +
    +
    + + Настройки системы @@ -1227,6 +1414,165 @@ export const AdminPage: React.FC = () => { + + {/* Generate QR Codes Dialog */} + { + setQrDialogOpen(false); + resetQRForm(); + }} + maxWidth="md" + fullWidth + PaperProps={{ + sx: { + backgroundColor: '#1a1a1a', + border: '1px solid #333', + }, + }} + > + + Генерация QR-кодов + + + setQrForm(prev => ({ ...prev, type: e.target.value }))} + margin="normal" + SelectProps={{ + native: true, + }} + sx={{ + '& .MuiOutlinedInput-root': { + '& fieldset': { borderColor: '#333' }, + '&:hover fieldset': { borderColor: '#FFD700' }, + }, + '& .MuiInputLabel-root': { color: '#888' }, + '& .MuiInputBase-input': { color: '#ffffff' }, + '& .MuiSelect-select': { color: '#ffffff' }, + }} + > + + + + + + setQrForm(prev => ({ ...prev, value: e.target.value }))} + margin="normal" + helperText={ + qrForm.type === 'reward' ? 'Количество звезд для начисления (например: 50)' : + qrForm.type === 'quiz' ? 'ID викторины (например: 1)' : + 'Код акции (например: discount_10)' + } + sx={{ + '& .MuiOutlinedInput-root': { + '& fieldset': { borderColor: '#333' }, + '&:hover fieldset': { borderColor: '#FFD700' }, + }, + '& .MuiInputLabel-root': { color: '#888' }, + '& .MuiInputBase-input': { color: '#ffffff' }, + }} + /> + + setQrForm(prev => ({ ...prev, count: parseInt(e.target.value) || 1 }))} + margin="normal" + inputProps={{ min: 1, max: 100 }} + sx={{ + '& .MuiOutlinedInput-root': { + '& fieldset': { borderColor: '#333' }, + '&:hover fieldset': { borderColor: '#FFD700' }, + }, + '& .MuiInputLabel-root': { color: '#888' }, + '& .MuiInputBase-input': { color: '#ffffff' }, + }} + /> + + {generatedQRCodes.length > 0 && ( + + + Сгенерированные QR-коды ({generatedQRCodes.length}) + + + {/* Grid of QR codes */} + + {generatedQRCodes.map((token, index) => ( + + ))} + + + {/* Bulk download button */} + + + + {generatedQRCodes.length} QR-кодов + + + + )} + + + + + + ); }; \ No newline at end of file diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx index 9b7454f..c037b6c 100644 --- a/frontend/src/pages/ProfilePage.tsx +++ b/frontend/src/pages/ProfilePage.tsx @@ -13,7 +13,6 @@ import { Chip, CircularProgress, Alert, - Button, } from '@mui/material'; import { Person, @@ -47,7 +46,7 @@ const TabPanel: React.FC = ({ children, value, index }) => { }; export const ProfilePage: React.FC = () => { - const { user, logout } = useAuth(); + const { user } = useAuth(); const [value, setValue] = useState(0); const [transactions, setTransactions] = useState([]); const [purchases, setPurchases] = useState([]); diff --git a/frontend/src/pages/QRScannerPage.tsx b/frontend/src/pages/QRScannerPage.tsx index 87acdc0..dd8cafd 100644 --- a/frontend/src/pages/QRScannerPage.tsx +++ b/frontend/src/pages/QRScannerPage.tsx @@ -44,8 +44,10 @@ export const QRScannerPage: React.FC = () => { }, []); const startScanning = async () => { + console.log('🎥 Starting QR scanner...'); if (scannerRef.current) { try { + console.log('🧹 Clearing existing scanner...'); await scannerRef.current.clear(); } catch (err) { console.error('Error clearing scanner:', err); @@ -56,6 +58,7 @@ export const QRScannerPage: React.FC = () => { setError(null); try { + console.log('📱 Creating new Html5Qrcode scanner...'); const scanner = new Html5Qrcode('qr-reader'); scannerRef.current = scanner; @@ -64,18 +67,21 @@ export const QRScannerPage: React.FC = () => { qrbox: { width: 250, height: 250 }, }; + console.log('🚀 Starting camera scanner...'); await scanner.start( { facingMode: 'environment' }, config, async (decodedText: string) => { + console.log('🔍 QR CODE DETECTED BY SCANNER:', decodedText); await handleQRScan(decodedText); }, (_errorMessage: string) => { // Handle scan errors silently } ); + console.log('✅ Scanner started successfully'); } catch (err) { - console.error('Error starting scanner:', err); + console.error('💥 Error starting scanner:', err); setError('Не удалось получить доступ к камере. Пожалуйста, проверьте разрешения.'); setScanning(false); } @@ -93,13 +99,18 @@ export const QRScannerPage: React.FC = () => { }; const handleQRScan = async (decodedText: string) => { + console.log('🚀 QR SCAN DETECTED:', decodedText); await stopScanning(); setLoading(true); setError(null); try { + console.log('📡 Sending QR validation request...'); const response = await apiService.validateQR(decodedText); + console.log('📨 QR validation response:', response); + if (response.success && response.data) { + console.log('✅ QR validation successful:', response.data); // Transform backend response types to frontend format const transformedData: any = { type: response.data.type.toLowerCase(), // Convert "REWARD" -> "reward", "OPEN_QUIZ" -> "open_quiz" @@ -121,10 +132,11 @@ export const QRScannerPage: React.FC = () => { setResult(transformedData); setShowResult(true); } else { + console.log('❌ QR validation failed:', response.message); setError(response.message || 'Недействительный QR-код'); } } catch (err) { - console.error('Error validating QR:', err); + console.error('💥 Error validating QR:', err); setError('Произошла ошибка при проверке QR-кода'); } finally { setLoading(false); diff --git a/frontend/src/pages/QuizPage.tsx b/frontend/src/pages/QuizPage.tsx index 789360a..2544722 100644 --- a/frontend/src/pages/QuizPage.tsx +++ b/frontend/src/pages/QuizPage.tsx @@ -2,8 +2,6 @@ import React, { useState, useEffect } from 'react'; import { Box, Typography, - Card, - CardContent, Button, CircularProgress, Alert, diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index a7ba877..f5067a1 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -17,28 +17,38 @@ class ApiService { // Request interceptor to add auth token this.api.interceptors.request.use( (config) => { + console.log('🚀 API Request:', config.method?.toUpperCase(), config.url, config.data); // For Telegram Web App, use X-Telegram-WebApp-Init-Data header const telegramInitData = localStorage.getItem('telegram_init_data'); if (telegramInitData) { config.headers['X-Telegram-WebApp-Init-Data'] = telegramInitData; + console.log('🔑 Using Telegram auth headers'); } else { // Fallback to Bearer token for non-Telegram auth const token = localStorage.getItem('auth_token'); if (token) { config.headers.Authorization = `Bearer ${token}`; + console.log('🔑 Using Bearer token auth'); + } else { + console.log('❌ No auth data found!'); } } return config; }, (error) => { + console.error('💥 API Request error:', error); return Promise.reject(error); } ); // Response interceptor to handle errors this.api.interceptors.response.use( - (response) => response, + (response) => { + console.log('✅ API Response:', response.config.method?.toUpperCase(), response.config.url, response.status, response.data); + return response; + }, (error) => { + console.error('❌ API Response error:', error.config?.method?.toUpperCase(), error.config?.url, error.response?.status, error.response?.data); if (error.response?.status === 401) { localStorage.removeItem('auth_token'); window.location.href = '/'; @@ -109,7 +119,9 @@ class ApiService { // QR methods async validateQR(payload: string): Promise> { + console.log('🌐 API validateQR called with payload:', payload); const response = await this.api.post('/qr/validate', { payload }); + console.log('🌐 API validateQR response:', response.data); return response.data; } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 6eec684..a94ac37 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -131,7 +131,7 @@ export interface GrantStarsRequest { } export interface GenerateQRCodesRequest { - type: 'reward' | 'quiz'; + type: 'reward' | 'quiz' | 'shop'; value: string; count: number; } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index cb6f0ee..067c485 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -5,6 +5,6 @@ import react from '@vitejs/plugin-react' export default defineConfig({ plugins: [react()], server: { - allowedHosts: [] + allowedHosts: ["*"] } })