From 90eeda2edf011dfcae049627c125b8534b7e1d2a Mon Sep 17 00:00:00 2001 From: NikitolProject Date: Thu, 18 Sep 2025 01:27:46 +0300 Subject: [PATCH] feat: Add QR code generation feature to AdminPage, including UI for input and download options, and integrate QR code library for generating codes. Update package dependencies and adjust Navigation and ProfilePage components to streamline user data handling. --- .env.backend | 14 + .env.bot | 17 + .env.db | 5 + .env.example | 26 ++ .env.redis | 2 + backend/.dockerignore | 51 +++ backend/Dockerfile | 48 +++ docker-compose.yml | 145 ++++++++ frontend/.dockerignore | 38 ++ frontend/Dockerfile | 34 ++ frontend/nginx.conf | 48 +++ frontend/package.json | 2 + frontend/pnpm-lock.yaml | 199 ++++++++++ frontend/src/components/QRCodeDisplay.tsx | 103 ++++++ frontend/src/components/layout/Navigation.tsx | 2 +- frontend/src/components/ui/AnswerOption.tsx | 2 +- frontend/src/pages/AdminPage.tsx | 350 +++++++++++++++++- frontend/src/pages/ProfilePage.tsx | 3 +- frontend/src/pages/QRScannerPage.tsx | 16 +- frontend/src/pages/QuizPage.tsx | 2 - frontend/src/services/api.ts | 14 +- frontend/src/types/index.ts | 2 +- frontend/vite.config.ts | 2 +- 23 files changed, 1112 insertions(+), 13 deletions(-) create mode 100644 .env.backend create mode 100644 .env.bot create mode 100644 .env.db create mode 100644 .env.example create mode 100644 .env.redis create mode 100644 backend/.dockerignore create mode 100644 backend/Dockerfile create mode 100644 docker-compose.yml create mode 100644 frontend/.dockerignore create mode 100644 frontend/Dockerfile create mode 100644 frontend/nginx.conf create mode 100644 frontend/src/components/QRCodeDisplay.tsx 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: ["*"] } })